From 4f45fcce7e49a5bafa889f1fe3a1b51e6b1c56e9 Mon Sep 17 00:00:00 2001 From: John R Date: Tue, 14 Apr 2026 19:58:49 -0400 Subject: [PATCH 1/8] Add link to github in meta + todos & tweaks --- Build.PL | 10 ++++++++++ CHANGELOG.md | 4 ++-- TODO.md | 7 +++++++ example.pl | 2 +- lib/Term/ReadLine/Repl.pm | 4 ++-- 5 files changed, 22 insertions(+), 5 deletions(-) create mode 100644 TODO.md diff --git a/Build.PL b/Build.PL index 4ba1980..3ccaa99 100644 --- a/Build.PL +++ b/Build.PL @@ -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(); diff --git a/CHANGELOG.md b/CHANGELOG.md index d5e8e37..c69fedb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,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. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..e97c657 --- /dev/null +++ b/TODO.md @@ -0,0 +1,7 @@ +## Todos + +* [ ] **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** diff --git a/example.pl b/example.pl index 50eb68b..e0bb8b3 100755 --- a/example.pl +++ b/example.pl @@ -6,7 +6,7 @@ use Getopt::Long; use Data::Dumper; -use lib './lib'; +#use lib './lib'; use Term::ReadLine::Repl; my %O = ( diff --git a/lib/Term/ReadLine/Repl.pm b/lib/Term/ReadLine/Repl.pm index 283e0b8..7bea5c6 100755 --- a/lib/Term/ReadLine/Repl.pm +++ b/lib/Term/ReadLine/Repl.pm @@ -3,7 +3,7 @@ use warnings; package Term::ReadLine::Repl; -our $VERSION = '0.0.1'; +our $VERSION = '0.0.2'; =head1 NAME @@ -225,7 +225,7 @@ Passthrough commands (those beginning with C) are excluded from completion. =head1 AUTHORS -Written by John R. Copyright (c) 2026 +Written by John L. Radford, Copyright (c) 2026 =head1 LICENSE From 790cd645391675587d89cf52887cd1eae3cbd7f7 Mon Sep 17 00:00:00 2001 From: John R Date: Tue, 14 Apr 2026 20:04:09 -0400 Subject: [PATCH 2/8] Fix history fail read / write msg + builtin help menu print * See TODO.md for more info. --- TODO.md | 6 +++++- lib/Term/ReadLine/Repl.pm | 6 +++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/TODO.md b/TODO.md index e97c657..9beecd3 100644 --- a/TODO.md +++ b/TODO.md @@ -1,7 +1,11 @@ ## Todos -* [ ] **Fix warn message on read and write history** +* [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. diff --git a/lib/Term/ReadLine/Repl.pm b/lib/Term/ReadLine/Repl.pm index 7bea5c6..d2a547b 100755 --- a/lib/Term/ReadLine/Repl.pm +++ b/lib/Term/ReadLine/Repl.pm @@ -456,7 +456,7 @@ sub _help { for my $arg (keys %{$args}) { my $opt = $args->{$arg}; $output .= "$arg"; - $output .= defined $opt ? "=<$opt>, " : ", "; + $output .= defined $opt ? " <$opt>, " : ", "; } substr($output, -1) = ""; # Remove trailing space substr($output, -1) = ""; # Remove trailing , @@ -470,7 +470,7 @@ sub _read_history { my ($self, $term) = @_; if (-f $self->{hist_file}) { - open my $fh, '<', $self->{hist_file} or warn "Couldn't read auto bal history file: $!"; + open my $fh, '<', $self->{hist_file} or warn "Couldn't read history file: $!"; while (my $line = <$fh>) { chomp $line; $term->addhistory($line); @@ -484,7 +484,7 @@ sub _save_history { my $attribs = $term->Attribs; - open my $fh, '>>', $self->{hist_file} or warn "Couldn't save auto bal history: $!"; + open my $fh, '>>', $self->{hist_file} or warn "Couldn't save history file: $!"; if ($term->ReadLine =~ /Gnu/) { for my $line ($term->GetHistory) { print $fh "$line\n"; From 2c4ab18870d674d802eff07b64aa509af79730e1 Mon Sep 17 00:00:00 2001 From: John R Date: Tue, 14 Apr 2026 20:53:35 -0400 Subject: [PATCH 3/8] Improve example.pl and add zork.pl * You gotta have zork! --- CLAUDE.md | 64 ++++++++++ example.pl | 178 +++++++++------------------- zork.pl | 335 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 455 insertions(+), 122 deletions(-) create mode 100644 CLAUDE.md create mode 100755 zork.pl diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d39d86b --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/example.pl b/example.pl index e0bb8b3..62423ae 100755 --- a/example.pl +++ b/example.pl @@ -1,127 +1,61 @@ #!/usr/bin/env perl - -use warnings; use strict; +use warnings; -use Getopt::Long; -use Data::Dumper; - -#use lib './lib'; +#use lib './lib'; # Uncomment to run from the repo root without installing use Term::ReadLine::Repl; -my %O = ( - dry => 1, -); - -# Used for both regular getopts cli args parse and repl -flags parsing. -sub get_opts_parse { - GetOptions(\%O, - 'dry|n!', - 'force', - 'mem_buffer=i', - 'timeout=i', - 'verbose!', - ) or die "cant!"; -} - -sub custom_logic { - my $args = shift; - - print Dumper \%O; - - # Testing return - if ($args->[0] eq 'test') { - return { - action => 'next' - }; - } - - if ($args->[0] eq 'end') { - return { - action => 'last' - }; - } - - if ($args->[0] eq 'fart') { - return { - schema => { - blah => { - exec => sub {print "fart\n";}, - args => [{ - refresh => undef, - }] - } - } - }; - } -} - -sub get_stats { - my $arg = shift; - - if ($arg eq 'a') { - print "a\n"; - return; - } - print "1,2,3,4,5\n"; -} - -#my $term = Term::ReadLine::Repl->new( -# { -# name => 'myrepl', -# prompt => '(%s)>', -# cmd_schema => { -# stats => { -# exec => \&get_stats, -# args => [{ -# refresh => undef, -# host => 'hostname', -# guest => 'guestname', -# list => 'host|guest', -# cluster => undef, -# }, -# { -# test => undef, -# another => undef, -# }], -# }, -# xml => { -# exec => \&list_items, -# args => [{refresh=>undef, 'cluster|host'=>undef, 'hostname'=>undef}], -# } -# }, -# passthrough => 1, -# get_opts => \&get_opts_parse, -# custom_logic => \&custom_logic, -# } -#); - -# A simple repl -my $term = Term::ReadLine::Repl->new( - { - name => 'myrepl', - cmd_schema => { - ls => { - exec => sub {my @list = qw(a b c); print for @list}, # Coderef to custom function for cmd - } - } - } -); - -## A simple repl -#my $term = Term::ReadLine::Repl->new( -# { -# name => 'myrepl', -# cmd_schema => { -# ls => { -# exec => sub {my @list = qw(a b c); print for @list}, # Coderef to custom function for cmd -# } -# } -# } -#); - -#print Dumper $term; - -$term->run(); - - +# In-memory note storage for this session. +my @notes; + +my $repl = Term::ReadLine::Repl->new({ + name => 'notes', + prompt => '[%s]>', + passthrough => 1, + hist_file => "$ENV{HOME}/.notes_repl_history", + cmd_schema => { + + # Add a note. All words after 'add' become the note text. + add => { + exec => sub { + my @words = @_; + unless (@words) { + print "Usage: add \n"; + return; + } + push @notes, join(' ', @words); + printf "Note %d saved.\n", scalar @notes; + }, + }, + + # List all saved notes with index numbers. + list => { + exec => sub { + unless (@notes) { + print "No notes yet. Use 'add ' to create one.\n"; + return; + } + printf "%2d. %s\n", $_ + 1, $notes[$_] for 0 .. $#notes; + }, + }, + + # Remove a note by its index number. + remove => { + exec => sub { + my ($id) = @_; + unless (defined $id && $id =~ /^\d+$/ && $id >= 1 && $id <= @notes) { + printf "Usage: remove <1-%d>\n", scalar @notes || 1; + return; + } + my $removed = splice(@notes, $id - 1, 1); + print "Removed: $removed\n"; + }, + # Tab completion hints for the first argument position. + args => [{ '1' => undef, '2' => undef, '3' => undef, + '4' => undef, '5' => undef }], + }, + + }, +}); + +$repl->run(); diff --git a/zork.pl b/zork.pl new file mode 100755 index 0000000..63d2c04 --- /dev/null +++ b/zork.pl @@ -0,0 +1,335 @@ +#!/usr/bin/env perl +use strict; +use warnings; + +use Getopt::Long (); +use Term::ANSIColor qw(colored); + +#use lib './lib'; # Uncomment to run from the repo root without installing +use Term::ReadLine::Repl; + +# ============================================================================= +# Game state +# ============================================================================= + +my %state = ( + location => 'clearing', + moves => 0, + score => 0, +); +my @inventory; + +# ============================================================================= +# World definition +# ============================================================================= + +my %rooms = ( + clearing => { + name => 'West of House', + desc => 'You are standing in an open field west of a white house, ' + . 'with a boarded front door. A mailbox stands nearby.', + long => 'You are standing in an open field west of a white house, ' + . 'with a boarded front door. The windows are dark and shuttered. ' + . 'A weathered mailbox leans by the path. A trail winds north ' + . 'into the forest; a gravel path leads south into a gully.', + exits => { north => 'forest', south => 'gully' }, + items => [qw(lantern leaflet)], + }, + forest => { + name => 'Forest', + desc => 'You are in a dimly lit forest. Trees press in from all sides. ' + . 'A path leads south back toward the house.', + long => 'You are deep in a pine forest. The canopy overhead blocks most ' + . 'of the sky. Roots push up through the leaf litter underfoot. ' + . 'The only clear path leads south, back toward the house. ' + . 'Something on the ground catches your eye.', + exits => { south => 'clearing' }, + items => [qw(sword)], + }, + gully => { + name => 'Rocky Gully', + desc => 'You are in a steep rocky gully. The path continues north. ' + . 'A narrow cave entrance gapes open to the east.', + long => 'You are in a steep gully carved by an ancient stream. ' + . 'Mossy boulders crowd the walls. The path winds north back ' + . 'toward the house. To the east, an ominous cave entrance ' + . 'exhales cold, stale air.', + exits => { north => 'clearing', east => 'cave' }, + items => [], + }, + cave => { + name => 'Dark Cave', + desc => 'You are in a damp cave. Water drips from the ceiling. ' + . 'The exit is to the west.', + long => 'You are in a narrow cave. Stalactites hang from the ceiling ' + . 'and the walls glisten with moisture. Something large seems ' + . 'to be lurking just out of sight. The exit is to the west.', + exits => { west => 'gully' }, + items => [qw(treasure)], + dark => 1, + }, +); + +my %items = ( + lantern => { + name => 'brass lantern', + desc => 'A battery-powered brass lantern. It will keep the dark at bay.', + }, + leaflet => { + name => 'leaflet', + desc => 'A small leaflet. It reads: ' + . '"WELCOME TO ZORK! Your quest: find the treasure ' + . 'hidden in the cave and return it to the clearing. ' + . 'Beware of the grue."', + }, + sword => { + name => 'elvish sword', + desc => 'A sword of elvish workmanship. The blade glows faintly blue, ' + . 'a sure sign of nearby danger.', + }, + treasure => { + name => 'pile of treasure', + desc => 'Gold coins, priceless jewels, and ancient artifacts. ' + . 'This must be worth a fortune!', + }, +); + +# ============================================================================= +# Helpers +# ============================================================================= + +sub has_item { + my ($item) = @_; + return grep { $_ eq $item } @inventory; +} + +sub room_has_item { + my ($room_id, $item) = @_; + return grep { $_ eq $item } @{ $rooms{$room_id}{items} }; +} + +sub describe_room { + my ($verbose) = @_; + my $room = $rooms{ $state{location} }; + + # Dark rooms are impassable without the lantern. + if ($room->{dark} && !has_item('lantern')) { + print "\n", colored('Darkness', 'bold'), "\n"; + print "It is pitch black. You can't see a thing.\n\n"; + return; + } + + print "\n", colored($room->{name}, 'bold'), "\n"; + print( $verbose ? $room->{long} : $room->{desc} ); + print "\n"; + + if (@{ $room->{items} }) { + my @names = map { $items{$_}{name} } @{ $room->{items} }; + print "You can see: ", join(', ', @names), ".\n"; + } + + my @exits = sort keys %{ $room->{exits} }; + print "Exits: ", join(', ', @exits), ".\n\n"; +} + +# ============================================================================= +# get_opts callback +# +# Called by the REPL before each command dispatch. Parses flags out of the +# raw input line (via @ARGV) and stores results in %opts so command handlers +# can read them. 'pass_through' suppresses warnings for unrecognised options +# typed for commands that don't use any flags. +# ============================================================================= + +my %opts; + +sub parse_flags { + %opts = ( verbose => 0 ); + Getopt::Long::Configure('pass_through'); + Getopt::Long::GetOptions( \%opts, 'verbose|v' ); +} + +# ============================================================================= +# custom_logic callback +# +# Called by the REPL every iteration, before command dispatch. Handles +# global game-loop concerns: move counting, darkness death, and win detection. +# Returning { action => 'last' } exits the REPL loop entirely. +# ============================================================================= + +sub game_loop_logic { + my ($args) = @_; + + $state{moves}++; + + # Darkness check — grue kills the player in any dark room without a lantern. + if ( $rooms{ $state{location} }{dark} && !has_item('lantern') ) { + print "\nIt is pitch black. You are likely to be eaten by a grue.\n"; + print "A grue slithers out of the darkness and swallows you whole.\n\n"; + print colored('*** YOU HAVE DIED ***', 'red bold'), "\n\n"; + printf "You lasted %d move%s.\n\n", + $state{moves}, $state{moves} == 1 ? '' : 's'; + return { action => 'last' }; + } + + # Win check — player has brought the treasure back to the clearing. + if ( $state{location} eq 'clearing' && has_item('treasure') ) { + print "\nYou step back into the sunlight, arms full of treasure.\n"; + print "A strange sense of completion washes over you.\n\n"; + print colored('*** YOU HAVE WON ***', 'green bold'), "\n\n"; + printf "Completed in %d move%s with a score of %d.\n\n", + $state{moves}, $state{moves} == 1 ? '' : 's', $state{score}; + return { action => 'last' }; + } + + return undef; +} + +# ============================================================================= +# Command handlers +# ============================================================================= + +sub cmd_look { + describe_room( $opts{verbose} ); +} + +sub cmd_go { + my ($dir) = @_; + unless ($dir) { + print "Go where? (north, south, east, west)\n"; + return; + } + my $exits = $rooms{ $state{location} }{exits}; + unless ( exists $exits->{$dir} ) { + print "You can't go $dir from here.\n"; + return; + } + $state{location} = $exits->{$dir}; + print "You head $dir.\n"; + describe_room(0); +} + +sub cmd_take { + my ($item_id) = @_; + unless ($item_id) { + print "Take what?\n"; + return; + } + unless ( exists $items{$item_id} ) { + print "I don't know what '$item_id' is.\n"; + return; + } + unless ( room_has_item( $state{location}, $item_id ) ) { + print "There is no $items{$item_id}{name} here.\n"; + return; + } + push @inventory, $item_id; + $rooms{ $state{location} }{items} = + [ grep { $_ ne $item_id } @{ $rooms{ $state{location} }{items} } ]; + $state{score} += 10; + print "Taken.\n"; +} + +sub cmd_drop { + my ($item_id) = @_; + unless ($item_id) { + print "Drop what?\n"; + return; + } + unless ( has_item($item_id) ) { + my $name = $items{$item_id} ? $items{$item_id}{name} : $item_id; + print "You aren't carrying the $name.\n"; + return; + } + @inventory = grep { $_ ne $item_id } @inventory; + push @{ $rooms{ $state{location} }{items} }, $item_id; + print "Dropped.\n"; +} + +sub cmd_inventory { + if (@inventory) { + print "You are carrying:\n"; + print " - $items{$_}{name}\n" for @inventory; + } else { + print "You are empty-handed.\n"; + } +} + +sub cmd_examine { + my ($item_id) = @_; + unless ($item_id) { + print "Examine what?\n"; + return; + } + unless ( exists $items{$item_id} ) { + print "I don't know what '$item_id' is.\n"; + return; + } + unless ( has_item($item_id) || room_has_item( $state{location}, $item_id ) ) { + print "You don't see that here.\n"; + return; + } + print "$items{$item_id}{desc}\n"; +} + +sub cmd_score { + printf "Moves: %d | Score: %d\n", $state{moves}, $state{score}; +} + +# ============================================================================= +# Build and launch the REPL +# ============================================================================= + +# Reuse the same completion list (all item IDs) for take/drop/examine. +my $all_items = { map { $_ => undef } keys %items }; + +my $repl = Term::ReadLine::Repl->new({ + name => 'zork', + prompt => '[%s]>', + hist_file => "$ENV{HOME}/.zork_history", + get_opts => \&parse_flags, + custom_logic => \&game_loop_logic, + cmd_schema => { + look => { + exec => \&cmd_look, + args => [{ '--verbose' => undef, '-v' => undef }], + }, + go => { + exec => \&cmd_go, + args => [{ north => undef, south => undef, + east => undef, west => undef }], + }, + take => { + exec => \&cmd_take, + args => [$all_items], + }, + drop => { + exec => \&cmd_drop, + args => [$all_items], + }, + examine => { + exec => \&cmd_examine, + args => [$all_items], + }, + inventory => { + exec => \&cmd_inventory, + }, + score => { + exec => \&cmd_score, + }, + }, +}); + +# Print the opening crawl, then describe the starting room before handing +# control to the REPL loop. +print "\n"; +print "=" x 58, "\n"; +print " ZORK I: The Great Underground Empire\n"; +print " (A Term::ReadLine::Repl feature demo)\n"; +print "=" x 58, "\n"; +print "\nType 'help' to list commands. Tab completes commands and args.\n"; +print "Use 'look -v' for verbose room descriptions.\n"; + +describe_room(0); + +$repl->run(); From 6d7f034c0a30b4cc1fb3247344e9257d225f5280 Mon Sep 17 00:00:00 2001 From: John R Date: Tue, 14 Apr 2026 20:59:55 -0400 Subject: [PATCH 4/8] Update version in META.yml --- META.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/META.yml b/META.yml index 4c54ccd..db52701 100644 --- a/META.yml +++ b/META.yml @@ -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' @@ -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' From dd60d8adc73d7932761f8efc0def55abe9e3a548 Mon Sep 17 00:00:00 2001 From: John R Date: Wed, 15 Apr 2026 17:48:06 -0400 Subject: [PATCH 5/8] Update changelog --- CHANGELOG.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c69fedb..c31d0d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,21 @@ 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... ## [0.0.1] - 2026-03-13 From c677df8c479647ce4819dc956c444f654f72bade Mon Sep 17 00:00:00 2001 From: John R Date: Wed, 15 Apr 2026 18:04:59 -0400 Subject: [PATCH 6/8] Identify and fix three critical bugs * help string equality * fix unbounded history file writes --- CHANGELOG.md | 6 +++ TODO.md | 84 +++++++++++++++++++++++++++++++++++++++ lib/Term/ReadLine/Repl.pm | 6 +-- 3 files changed, 93 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c31d0d6..318a1de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. +- 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. ## [0.0.1] - 2026-03-13 diff --git a/TODO.md b/TODO.md index 9beecd3..0dee02e 100644 --- a/TODO.md +++ b/TODO.md @@ -9,3 +9,87 @@ * [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. + +* [ ] **[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. diff --git a/lib/Term/ReadLine/Repl.pm b/lib/Term/ReadLine/Repl.pm index d2a547b..8b51b34 100755 --- a/lib/Term/ReadLine/Repl.pm +++ b/lib/Term/ReadLine/Repl.pm @@ -325,7 +325,7 @@ sub run { next unless $input; - if ($input =~ 'help') { + if ($input eq 'help') { $self->_help(); next; } @@ -334,7 +334,7 @@ sub run { # Command line passthrough. if ($self->{passthrough} && @args && $args[0] =~ /^\!/) { - $args[0] =~ s/\!//g; + $args[0] =~ s/^\!//; system(@args); next; } @@ -484,7 +484,7 @@ sub _save_history { my $attribs = $term->Attribs; - open my $fh, '>>', $self->{hist_file} or warn "Couldn't save history file: $!"; + open my $fh, '>', $self->{hist_file} or warn "Couldn't save history file: $!"; if ($term->ReadLine =~ /Gnu/) { for my $line ($term->GetHistory) { print $fh "$line\n"; From ace09c57a4ee51b545ff89ad9edd255e77b46b7e Mon Sep 17 00:00:00 2001 From: John R Date: Wed, 15 Apr 2026 23:34:47 -0400 Subject: [PATCH 7/8] Fix args ARRAY ref validation + trim leading spaces after chomp * Also add tests to ensure accepts ARRAY ref only --- CHANGELOG.md | 4 ++++ TODO.md | 2 +- lib/Term/ReadLine/Repl.pm | 3 ++- t/01_basic.t | 16 +++++++++++++++- 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 318a1de..db93e02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,10 +22,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 diff --git a/TODO.md b/TODO.md index 0dee02e..1cf2fbc 100644 --- a/TODO.md +++ b/TODO.md @@ -45,7 +45,7 @@ automatically into every REPL"). The fix is to add `$self->{cmd_schema}{exit} = {}` alongside the existing `quit` injection. -* [ ] **[SIGNIFICANT] `args` validation accepts any reference, not just ARRAY refs (validate_args(), line 287)** +* [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 diff --git a/lib/Term/ReadLine/Repl.pm b/lib/Term/ReadLine/Repl.pm index 8b51b34..46bbbee 100755 --- a/lib/Term/ReadLine/Repl.pm +++ b/lib/Term/ReadLine/Repl.pm @@ -285,7 +285,7 @@ sub validate_args { # Ensure that args is an array if (exists $schema->{args} && defined $schema->{args}) { - croak "'$cmd' args is NOT a arrayref!" unless ref $schema->{args}; + croak "'$cmd' args is NOT a arrayref!" unless ref $schema->{args} eq 'ARRAY'; croak "'$cmd' args array is empty!" if scalar @{$schema->{args}} < 1; @@ -321,6 +321,7 @@ sub run { # Simple REPL loop. while (defined (my $input = $term->readline($prompt))) { chomp $input; + $input =~ s/^\s+|\s+$//g; last if ($input =~ /^(exit|quit)$/); next unless $input; diff --git a/t/01_basic.t b/t/01_basic.t index edf3e1f..78ea84e 100644 --- a/t/01_basic.t +++ b/t/01_basic.t @@ -60,7 +60,21 @@ subtest 'cmd args not an arrayref croaks' => sub { name => 'test', cmd_schema => { foo => { exec => $dummy_exec, args => 'bad' } }, }) - } qr/args is NOT a arrayref/, 'croaks when args is not an arrayref'; + } qr/args is NOT a arrayref/, 'croaks when args is a plain string'; + + throws_ok { + Term::ReadLine::Repl->new({ + name => 'test', + cmd_schema => { foo => { exec => $dummy_exec, args => {} } }, + }) + } qr/args is NOT a arrayref/, 'croaks when args is a hashref'; + + throws_ok { + Term::ReadLine::Repl->new({ + name => 'test', + cmd_schema => { foo => { exec => $dummy_exec, args => sub {} } }, + }) + } qr/args is NOT a arrayref/, 'croaks when args is a coderef'; }; subtest 'cmd args empty arrayref croaks' => sub { From c5847fdc90390f6f0185bce3e715e1b82c6ff7e8 Mon Sep 17 00:00:00 2001 From: John R Date: Thu, 7 May 2026 10:07:25 -0400 Subject: [PATCH 8/8] Clean up build files & package up build --- MANIFEST | 2 ++ MANIFEST.SKIP | 98 +++++++++++++++++++++++++++++++++++++++++++++++++-- META.json | 8 ++--- META.yml | 2 +- build.sh | 9 +++++ 5 files changed, 112 insertions(+), 7 deletions(-) mode change 100755 => 100644 MANIFEST.SKIP create mode 100755 build.sh diff --git a/MANIFEST b/MANIFEST index 54a3441..411ab89 100644 --- a/MANIFEST +++ b/MANIFEST @@ -1,4 +1,5 @@ Build.PL +CHANGELOG.md example.pl lib/Term/ReadLine/Repl.pm MANIFEST This list of files @@ -6,3 +7,4 @@ META.json META.yml Readme.md t/01_basic.t +zork.pl diff --git a/MANIFEST.SKIP b/MANIFEST.SKIP old mode 100755 new mode 100644 index c864074..51fd9bf --- a/MANIFEST.SKIP +++ b/MANIFEST.SKIP @@ -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\. @@ -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 diff --git a/META.json b/META.json index bbaf3fd..e524f09 100644 --- a/META.json +++ b/META.json @@ -4,7 +4,7 @@ "John R. " ], "dynamic_config" : 1, - "generated_by" : "Module::Build version 0.4231", + "generated_by" : "Module::Build version 0.4234", "license" : [ "perl_5" ], @@ -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", @@ -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" } diff --git a/META.yml b/META.yml index db52701..f2fdc34 100644 --- a/META.yml +++ b/META.yml @@ -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 diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..523085c --- /dev/null +++ b/build.sh @@ -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