Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Build.PL
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ my $build = Module::Build->new(
'Module::Build' => 0,
'Test::Exception' => 0,
},
resources => {
repository => {
type => 'git',
url => 'https://github.com/BlueSquare23/Term-ReadLine-Repl',
web => 'https://github.com/BlueSquare23/Term-ReadLine-Repl',
},
bugtracker => {
web => 'https://github.com/BlueSquare23/Term-ReadLine-Repl/issues',
},
},
);

$build->create_build_script();
30 changes: 27 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,31 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## [0.0.2 - Unreleased]

### Added

- Add Zork example script!
- Add github link to META.yml

### Changed

- Change `example.pl` to be more representative of how to use module.

### Fixed

- Clarify help menu by removing `=` from help output.
- Clean up warn read write message. Had leftovers in it, oops...
- Fix `help` dispatch using regex match instead of string equality, which caused
any input containing the substring "help" to incorrectly trigger the help menu.
- Trim leading/trailing whitespace from input after `chomp` so commands with
incidental surrounding spaces are handled correctly throughout the run loop.
- Fix shell passthrough stripping all `!` characters from the command token instead
of only the leading one, which corrupted commands containing `!` in their arguments.
- Fix history file being opened in append mode on save, causing entries to accumulate
across sessions; now overwrites with the full current history on exit.
- Fix `args` validation accepting any reference type instead of requiring an ARRAY ref,
meaning a mistyped hashref or coderef passed as `args` would silently pass validation.

## [0.0.1] - 2026-03-13

Expand All @@ -24,6 +48,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `Build.PL` for distribution build and dependency management.
- `META.yml` and `MANIFEST` for CPAN packaging.
- Full POD documentation including constructor args, methods, built-in commands,
and tab completion behaviour.
and tab completion behavior.
- Test suite covering `validate_args` croak paths, construction sanity checks,
and `_tab_complete` behaviour.
and `_tab_complete` behavior.
64 changes: 64 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project

**Term::ReadLine::Repl** is a Perl module providing a batteries-included framework for building interactive CLI REPLs on top of Term::ReadLine. Users define commands as a data structure (schema-driven); the module handles the event loop, tab completion, history, help, and shell passthrough.

## Commands

```bash
# Install dependencies
cpanm --notest --installdeps .

# Build
perl Build.PL
./Build

# Run all tests
./Build test

# Run a single test file
perl -Ilib t/01_basic.t
```

CI tests against Perl 5.32, 5.36, and 5.38 on Ubuntu.

## Architecture

**Single module:** `lib/Term/ReadLine/Repl.pm`

**Constructor flow:** `new(%args)` → `validate_args()` → inject built-ins (`help`, `quit`, `exit`) → initialize Term::ReadLine + tab completion → optionally load history.

**Run loop** (`run()`):
1. Read input via Term::ReadLine
2. Exit on `quit`/`exit`
3. Skip empty input
4. Handle `help` (renders schema with args)
5. Handle `!cmd` shell passthrough (if `passthrough` enabled)
6. Call optional `get_opts` (Getopt::Long integration)
7. Call optional `custom_logic` (can short-circuit or swap the schema mid-loop)
8. Dispatch to the command's `exec` coderef

**Command schema** (the core API):
```perl
cmd_schema => {
greet => {
exec => sub { ... }, # required coderef
args => [{ name => 'Alice' }], # optional array of hashrefs for tab completion
},
}
```

**Tab completion** (`_tab_complete()`): completes command names on the first word, then values from `args` hashrefs on subsequent words. Shell passthrough commands (`!`) are filtered out of completion.

**Key constructor args:** `name` (required), `cmd_schema` (required), `prompt` (sprintf with `%s`), `passthrough` (bool), `hist_file` (path), `get_opts` (coderef), `custom_logic` (coderef receiving `@args`, returns `{action => 'next'|'last', schema => ...}`).

**Validation** is strict — `validate_args()` croaks with descriptive messages for any invalid arg type or missing required field.

## Tests

All tests are in `t/01_basic.t` using Test::More and Test::Exception. Tests cover: required-arg validation, schema structure validation, construction + built-in injection, prompt interpolation, and tab completion behavior.

See `example.pl` for a working REPL demonstrating typical usage.
2 changes: 2 additions & 0 deletions MANIFEST
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
Build.PL
CHANGELOG.md
example.pl
lib/Term/ReadLine/Repl.pm
MANIFEST This list of files
META.json
META.yml
Readme.md
t/01_basic.t
zork.pl
98 changes: 96 additions & 2 deletions MANIFEST.SKIP
100755 → 100644
Original file line number Diff line number Diff line change
@@ -1,4 +1,92 @@
#!include_default

#!start included /usr/share/perl/5.38/ExtUtils/MANIFEST.SKIP
# Avoid version control files.
\bRCS\b
\bCVS\b
\bSCCS\b
,v$
\B\.svn\b
\B\.git\b
^\.github\b
\B\.gitignore\b
\b_darcs\b
\B\.cvsignore$

# Avoid VMS specific MakeMaker generated files
\bDescrip.MMS$
\bDESCRIP.MMS$
\bdescrip.mms$

# Avoid Makemaker generated and utility files.
\bMANIFEST\.bak
\bMakefile$
\bblib/
\bMakeMaker-\d
\bpm_to_blib\.ts$
\bpm_to_blib$
\bblibdirs\.ts$ # 6.18 through 6.25 generated this
\b_eumm/ # 7.05_05 and above

# Avoid Module::Build generated and utility files.
\bBuild$
\b_build/
\bBuild.bat$
\bBuild.COM$
\bBUILD.COM$
\bbuild.com$

# and Module::Build::Tiny generated files
\b_build_params$

# Avoid temp and backup files.
~$
\.old$
\#$
\b\.#
\.bak$
\.tmp$
\.#
\.rej$
\..*\.sw.?$

# Avoid OS-specific files/dirs
# Mac OSX metadata
\B\.DS_Store
# Mac OSX SMB mount metadata files
\B\._
# Placeholder files created when iCloud will "optimize Mac storage"
\.i[cC]loud$

# Avoid Devel::Cover and Devel::CoverX::Covered files.
\bcover_db\b
\bcovered\b

# Avoid prove files
\B\.prove$

# Avoid MYMETA files
^MYMETA\.

# Temp files for new META
^META_new\.(?:json|yml)

# Avoid travis-ci.org file
^\.travis\.yml

# Build artifacts
^Build$
\.tar\.gz$
\.bak$

# Editor/OS junk
\.swp$
~$
^\.DS_Store

# Avoid AppVeyor file
^\.?appveyor.yml
#!end included /usr/share/perl/5.38/ExtUtils/MANIFEST.SKIP

# Avoid configuration metadata file
^MYMETA\.

Expand All @@ -11,5 +99,11 @@
\bbuild.com$
^MANIFEST\.SKIP

# Misc
\B\.claude\b
\bbuild.sh$
\bCLAUDE.md$
\bTODO.md$

# Avoid archives of this distribution
\bTerm-ReadLine-Repl-[\d\.\_]+
\bTerm-ReadLine-Repl-[\d\.\d\.\d].tar.gz
8 changes: 4 additions & 4 deletions META.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"John R. <johnlradford23@gmail.com>"
],
"dynamic_config" : 1,
"generated_by" : "Module::Build version 0.4231",
"generated_by" : "Module::Build version 0.4234",
"license" : [
"perl_5"
],
Expand Down Expand Up @@ -39,7 +39,7 @@
"provides" : {
"Term::ReadLine::Repl" : {
"file" : "lib/Term/ReadLine/Repl.pm",
"version" : "v0.0.1"
"version" : "v0.0.2"
}
},
"release_status" : "stable",
Expand All @@ -48,6 +48,6 @@
"http://dev.perl.org/licenses/"
]
},
"version" : "v0.0.1",
"x_serialization_backend" : "JSON::PP version 4.06"
"version" : "v0.0.2",
"x_serialization_backend" : "JSON::PP version 4.16"
}
6 changes: 3 additions & 3 deletions META.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ build_requires:
configure_requires:
Module::Build: '0.42'
dynamic_config: 1
generated_by: 'Module::Build version 0.4231, CPAN::Meta::Converter version 2.150010'
generated_by: 'Module::Build version 0.4234, CPAN::Meta::Converter version 2.150010'
license: perl
meta-spec:
url: http://module-build.sourceforge.net/META-spec-v1.4.html
Expand All @@ -17,7 +17,7 @@ name: Term-ReadLine-Repl
provides:
Term::ReadLine::Repl:
file: lib/Term/ReadLine/Repl.pm
version: v0.0.1
version: v0.0.2
requires:
Carp: '0'
Data::Dumper: '0'
Expand All @@ -27,5 +27,5 @@ requires:
perl: '5.010'
resources:
license: http://dev.perl.org/licenses/
version: v0.0.1
version: v0.0.2
x_serialization_backend: 'CPAN::Meta::YAML version 0.018'
95 changes: 95 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
## Todos

* [x] **Fix warn message on read and write history**
- You can tell its human generated cause I copied it outta the old script and
forgot to change it.

* [x] **Include Github link in Build.PL/META**

* [x] **Fix help menu print to be less confusing / remove = sign**
- Coworker pointed out the help menu print is confusing cause it prints an equal for opt args.
- Going to make it not do that.

---

### Bugs (from code review)

* [x] **[CRITICAL] `help` check uses regex match instead of string equality (run(), line 328)**
- `if ($input =~ 'help')` matches any input that *contains* the substring "help",
not just the literal command `help`. This means commands like `helpful`, `gethelp`,
or any user command whose name includes "help" will be silently intercepted and
display the help menu instead of executing. The fix is to use string equality:
`if ($input eq 'help')`. This is a correctness bug that would silently break any
user-defined command with "help" anywhere in its name.

* [x] **[CRITICAL] History file grows unboundedly on every session exit (_save_history(), line 487)**
- The file is opened with `>>` (append mode). On startup, all existing history lines
are loaded into Term::ReadLine's in-memory history buffer via `addhistory`. On exit,
`GetHistory()` returns the *entire* in-memory buffer — including all the entries that
were loaded from the file — and appends them all again. This means every entry gets
duplicated on every run. After N sessions a history file with 10 entries becomes 10*N
lines. The fix is to open with `>` (overwrite): since `GetHistory()` already returns
the full canonical history, overwriting gives the correct persistent-history behavior
without accumulation.

* [x] **[CRITICAL] Passthrough strips all `!` characters instead of just the leading one (run(), line 337)**
- `$args[0] =~ s/\!//g` uses the global `/g` flag with no anchor, so it removes every
`!` character anywhere in the first token. For example `!echo he!lo` would execute
`echo hello` rather than `echo he!lo`. The fix is `s/^\!//` — anchored to the start,
no global flag — which removes only the leading `!` that signals a passthrough command.

* [ ] **[SIGNIFICANT] `exit` not injected into `cmd_schema` (new(), lines 260–261)**
- The run loop exits on both `exit` and `quit` (line 324: `/^(exit|quit)$/`), but only
`quit` is added to `cmd_schema`. This means `exit` never appears in the help output
or tab completion, which contradicts the documentation ("quit/exit are injected
automatically into every REPL"). The fix is to add
`$self->{cmd_schema}{exit} = {}` alongside the existing `quit` injection.

* [x] **[SIGNIFICANT] `args` validation accepts any reference, not just ARRAY refs (validate_args(), line 287)**
- `unless ref $schema->{args}` is true for *any* reference type — HASH, CODE, SCALAR,
etc. — so the error message "args is NOT a arrayref!" would never fire for a user who
accidentally passes a hashref or coderef as `args`. The check should be
`unless ref $schema->{args} eq 'ARRAY'` to actually enforce the expected type.

* [ ] **[SIGNIFICANT] Deprecated array-slice syntax generates warnings in tab completion (_tab_complete(), line 428)**
- `my $args = @{$schema->{args}}[$arg_index]` is a single-element array slice used in
scalar context. Under `use warnings` this generates: "Scalar value
@{$schema->{args}}[$arg_index] better written as $schema->{args}[$arg_index]". This
warning fires every time the tab-completion function is called, so in an interactive
session it would spam warnings into the terminal on every TAB press. The fix is to
write it as a direct element access: `$schema->{args}[$arg_index]`.

* [ ] **[SIGNIFICANT] `_read_history` reads from filehandle even if open failed (lines 473–478)**
- `open my $fh, '<', $file or warn "..."` only warns on failure and lets execution
continue. At that point `$fh` is undef, and the `while (my $line = <$fh>)` loop
immediately produces a second confusing warning ("readline() on unopened filehandle")
before silently doing nothing. The user sees two unrelated-looking warnings for one
problem. The fix is either to use `or die`/`or croak` so the error is hard, or to
restructure as `if (open my $fh, ...) { ... } else { warn ... }` so the loop body
is only reached when the open actually succeeded.

* [ ] **[MINOR] `use Data::Dumper` imported but never used (line 239)**
- `Data::Dumper` is listed as a dependency but is never called anywhere in the module.
It should be removed to avoid loading an unnecessary module and to keep the dependency
list honest. Likely a leftover from development/debugging.

* [ ] **[MINOR] `Term::ReadLine->new` name hardcoded to `'Simple Shell'` (run(), line 307)**
- `Term::ReadLine->new('Simple Shell')` ignores `$self->{name}`, which the user already
supplied as the REPL's identity. The name passed to `Term::ReadLine::new` is used
internally by some backends (e.g. for history separation between different REPLs on
the same system). It should be `Term::ReadLine->new($self->{name})`.

* [ ] **[MINOR] `sort` on array of hashrefs in `_help` is meaningless (_help(), line 454)**
- `sort @{$self->{cmd_schema}{$cmd}{args}}` sorts hashrefs using Perl's default string
comparison, which compares the stringified reference addresses (`HASH(0x55f3a2...)`).
These addresses are non-deterministic between runs and have no relationship to the
arg names or their order in the schema. The sort produces unpredictable output and
should simply be removed — the args will render in their original schema-defined order.

* [ ] **[MINOR] `eval` around `custom_logic` call silently swallows exceptions (run(), lines 356–368)**
- The `eval { $self->{custom_logic}->(\@args) }` block captures any die/croak thrown
by the user's callback, but `$@` is never checked afterward. If the callback dies,
execution silently continues to the next loop iteration with no indication to the user
or calling code that anything went wrong. At minimum, a `warn $@ if $@` after the
eval would surface the error. Alternatively, if the intent is to let exceptions
propagate, the eval should be removed entirely.
9 changes: 9 additions & 0 deletions build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#!/bin/bash
# Run's Build.PL

perl Build.PL
./Build test # make sure everything passes
./Build manifest # ensure all files are listed
./Build distcheck # catch any MANIFEST inconsistencies
./Build disttest # final sanity check
./Build dist # Build dist
Loading
Loading