Skip to content

Commit 666d811

Browse files
committed
M7: extract pure CCFE::Action parser, de-dup do_menu/do_form
The action-string parse -- `VERB[(opt,...)]:ARGS` into verb, option list and raw argument string -- was the same five-line split/regex duplicated at the do_menu and do_form call sites. Lift it into a pure, terminal-free CCFE::Action module; both sites now call CCFE::Action::parse. The dispatch stays in ccfe.pl, where it belongs: running the verb, drawing the `confirm` list, honouring `log`/`wait_key`, and spawning the command are all effectful. The head is leading-whitespace-trimmed before matching (as do_form already did, a no-op for the already-trimmed menu actions), and a malformed head yields verb => undef so the caller's verb dispatch simply finds no match -- behaviour unchanged at both sites. Unit-tested in t/16-action.t (23 cases); the menu/form tty tests and the full suite (now 275 tests) stay green. All four CI checks pass locally (compile, prove, perlcritic src/lib, perltidy). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0196daf commit 666d811

4 files changed

Lines changed: 128 additions & 15 deletions

File tree

ROADMAP.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,8 +185,14 @@ the conformance tests.
185185
plus term-specific (`FIELD_ATTR.$TERM`) and `$COLS`-dependent handling -- so
186186
that (effectful, scope-bound) dispatch stays in `load_config` verbatim. Unit-
187187
tested in `t/15-config.t` (17 cases); the colour/theme integration test
188-
(`t/06-color.t`) and full suite (252 tests) stay green. Next: `Action`, the
189-
pure `Layout` helpers, and finally the `$ctx` threading.
188+
(`t/06-color.t`) and full suite (252 tests) stay green.
189+
- 🔄 **`CCFE::Action`** (done): the action-string parse (`VERB[(opts)]:ARGS` ->
190+
`{verb, opts, args}`) -- the same five-line split/regex duplicated in
191+
`do_menu` and `do_form` -- is now a pure module. The dispatch (running the
192+
verb, prompting for `confirm`, honouring `log`/`wait_key`, spawning commands)
193+
stays in `ccfe.pl` as it draws lists and runs processes. Unit-tested in
194+
`t/16-action.t` (23 cases); the menu/form tty tests and full suite (275 tests)
195+
stay green. Next: the pure `Layout` helpers, and finally the `$ctx` threading.
190196

191197
## M8 — Non-functional close-out audit _(final gate)_
192198
Five dimensions: **test coverage, code quality, performance, security,

src/ccfe.pl

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
use CCFE::MenuFile (); # pure .menu/.item parser (see load_menu)
5050
use CCFE::FormFile (); # pure .form parser (see load_form)
5151
use CCFE::Config (); # pure .conf section tokenizer (see load_config)
52+
use CCFE::Action (); # pure action-string parser (see do_menu/do_form)
5253
use FindBin (); # to locate the program at runtime (see the path block below)
5354

5455
# Optional display-width support. In a UTF-8 locale a label/title can occupy
@@ -2121,12 +2122,10 @@ sub do_menu {
21212122
$ci = item_index( current_item($cmenu) );
21222123
$last_item_id = $menu{items}[$ci]{id};
21232124
if ( $menu{items}[$ci]{action} ) {
2124-
( $action, $args ) = split /:/, $menu{items}[$ci]{action},
2125-
2;
2126-
$action = lc $action;
2127-
$action =~ /^([a-zA-Z]+)\(?([a-zA-Z_,]*)\)?$/;
2128-
$action = $1;
2129-
@actopts = split /,\s*/, $2;
2125+
my $act = CCFE::Action::parse( $menu{items}[$ci]{action} );
2126+
$action = $act->{verb};
2127+
$args = $act->{args};
2128+
@actopts = @{ $act->{opts} };
21302129

21312130
$wait_key = $NO;
21322131
$LOG_REQUESTED = $NO;
@@ -3663,13 +3662,10 @@ sub do_form {
36633662
}
36643663

36653664
unless ($empty_required) {
3666-
( $action, $args ) = split /:/, $form{action}, 2;
3667-
$action =~ s/^\s+//;
3668-
$action = lc $action;
3669-
3670-
$action =~ /^([a-zA-Z]+)\(?([a-zA-Z_,]*)\)?$/;
3671-
$action = $1;
3672-
@actopts = split /,\s*/, $2;
3665+
my $act = CCFE::Action::parse( $form{action} );
3666+
$action = $act->{verb};
3667+
$args = $act->{args};
3668+
@actopts = @{ $act->{opts} };
36733669

36743670
$wait_key = $NO;
36753671
$LOG_REQUESTED = $NO;

src/lib/CCFE/Action.pm

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package CCFE::Action;
2+
3+
# Pure parser for a CCFE action string (ROADMAP M7, REFACTOR.md §3).
4+
#
5+
# An action string is `VERB[(opt,opt,...)]:ARGS` -- e.g. "run:ls -l",
6+
# "menu:submenu", "system(confirm,wait_key):reboot". parse() splits it into
7+
# its verb, its option list and its raw argument string, with no terminal and
8+
# no globals. do_menu()/do_form() in ccfe.pl own the dispatch -- running the
9+
# verb, prompting for `confirm`, honouring `log`/`wait_key` -- which is
10+
# effectful (it draws confirmation lists and spawns commands) and stays there.
11+
# This was the same five-line parse duplicated at both call sites.
12+
#
13+
# parse($str) returns a hashref:
14+
# { verb => 'run', # lower-cased; undef if the head is malformed
15+
# opts => [ 'confirm', ... ], # option names, in order (possibly empty)
16+
# args => 'ls -l' } # everything after the first ':', verbatim
17+
# # (undef if the string carried no ':')
18+
#
19+
# The head is leading-whitespace-trimmed before matching (as do_form already
20+
# did, and a no-op for menu actions, which the .menu parser trims). A head
21+
# that is not `word` or `word(opts)` yields verb => undef, opts => [] -- the
22+
# caller's verb dispatch then simply finds no match, exactly as before.
23+
24+
use v5.36;
25+
26+
our $VERSION = '2.1';
27+
28+
sub parse ($str) {
29+
my ( $head, $args ) = split /:/, $str // '', 2;
30+
$head //= '';
31+
$head =~ s/^\s+//;
32+
$head = lc $head;
33+
34+
my ( $verb, $optstr );
35+
if ( $head =~ /^([a-zA-Z]+)\(?([a-zA-Z_,]*)\)?$/ ) {
36+
$verb = $1;
37+
$optstr = $2;
38+
}
39+
my @opts = defined $optstr ? split( /,\s*/, $optstr ) : ();
40+
return { verb => $verb, opts => \@opts, args => $args };
41+
}
42+
43+
1;

src/t/16-action.t

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
#!/usr/bin/perl
2+
#
3+
# Unit tests for the pure CCFE::Action parser (ROADMAP M7).
4+
#
5+
# An action string `VERB[(opt,opt,...)]:ARGS` was parsed by the same five-line
6+
# split/regex duplicated in do_menu() and do_form(). That parse is now a pure
7+
# module; the dispatch (running the verb, prompting for `confirm`, honouring
8+
# `log`/`wait_key`) stays in ccfe.pl. These drive parse($str) directly.
9+
#
10+
use strict;
11+
use warnings;
12+
use FindBin qw($Bin);
13+
use lib "$Bin/../lib";
14+
use Test::More;
15+
16+
require_ok('CCFE::Action') or BAIL_OUT('cannot load CCFE::Action');
17+
18+
# ---- a plain verb:args --------------------------------------------------
19+
my $a = CCFE::Action::parse('run:ls -l /tmp');
20+
is( $a->{verb}, 'run', 'verb captured' );
21+
is( $a->{args}, 'ls -l /tmp', ' args captured verbatim (incl. spaces)' );
22+
is_deeply( $a->{opts}, [], ' no options' );
23+
24+
# ---- only the first colon splits verb from args -------------------------
25+
$a = CCFE::Action::parse('run:ssh host:cmd');
26+
is( $a->{verb}, 'run', 'first colon splits' );
27+
is( $a->{args}, 'ssh host:cmd', ' later colons stay in args' );
28+
29+
# ---- the verb is lower-cased --------------------------------------------
30+
$a = CCFE::Action::parse('MENU:sub');
31+
is( $a->{verb}, 'menu', 'verb is lower-cased' );
32+
is( $a->{args}, 'sub', ' args kept as-is' );
33+
34+
# ---- options in parentheses ---------------------------------------------
35+
$a = CCFE::Action::parse('system(confirm,wait_key):reboot');
36+
is( $a->{verb}, 'system', 'verb with options' );
37+
is_deeply( $a->{opts}, [ 'confirm', 'wait_key' ], ' options split in order' );
38+
is( $a->{args}, 'reboot', ' args after the option list' );
39+
40+
# ---- a single option ----------------------------------------------------
41+
$a = CCFE::Action::parse('run(log):make');
42+
is_deeply( $a->{opts}, ['log'], 'a single option parses' );
43+
is( $a->{verb}, 'run', ' verb still captured' );
44+
is( $a->{args}, 'make', ' args still captured' );
45+
46+
# ---- a verb with no args (no colon) -------------------------------------
47+
$a = CCFE::Action::parse('back');
48+
is( $a->{verb}, 'back', 'a bare verb parses' );
49+
is( $a->{args}, undef, ' args is undef when there is no colon' );
50+
is_deeply( $a->{opts}, [], ' no options' );
51+
52+
# ---- leading whitespace on the head is trimmed --------------------------
53+
$a = CCFE::Action::parse(' run:x');
54+
is( $a->{verb}, 'run', 'leading whitespace before the verb is trimmed' );
55+
56+
# ---- a malformed head yields an undef verb (caller finds no match) -------
57+
$a = CCFE::Action::parse('123bad:x');
58+
is( $a->{verb}, undef, 'a head that is not word/word(opts) -> undef verb' );
59+
is( $a->{args}, 'x', ' args still returned' );
60+
is_deeply( $a->{opts}, [], ' no options' );
61+
62+
# ---- empty / undef input is safe ----------------------------------------
63+
$a = CCFE::Action::parse('');
64+
is( $a->{verb}, undef, 'empty string -> undef verb' );
65+
$a = CCFE::Action::parse(undef);
66+
is( $a->{verb}, undef, 'undef input -> undef verb (no warning/crash)' );
67+
68+
done_testing();

0 commit comments

Comments
 (0)