Skip to content

Commit 0196daf

Browse files
committed
M7: extract pure CCFE::Config section tokenizer from load_config
The top-level `SECTION { ... }` walk -- the third copy of the same extract_bracketed loop (after the menu and form parsers) -- becomes a pure, terminal-free CCFE::Config module returning the sections in file order ({name, body} + status), matching the MenuFile/FormFile shape. Unlike menu/form, the config parser's per-section dispatch is not purely extractable: it is dominated by `eval "$VAR = ..."` colour/attribute assignments that must run in ccfe.pl's own package to see its colour helpers and curses constants, plus term-specific (FIELD_ATTR.$TERM) and $COLS-dependent handling. That effectful, scope-bound dispatch stays in load_config verbatim -- only the section walk is lifted out, removing the duplicated bracket boilerplate and making the tokenizer testable. Unit-tested in t/15-config.t (17 cases); the colour/theme integration test (t/06-color.t) and the full suite (now 252 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 86de4d4 commit 0196daf

4 files changed

Lines changed: 141 additions & 14 deletions

File tree

ROADMAP.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,8 +175,18 @@ the conformance tests.
175175
layout not parsing), select-item resolution and the `%form`/`$SCREEN_DIR`
176176
side effects. This cut ~325 lines of inline parser to a ~20-line call. Unit-
177177
tested in `t/14-formfile.t` (40 cases); the multipage-form pty test and full
178-
suite (235 tests) stay green. Next: `Config`, `Action`, the pure `Layout`
179-
helpers, and finally the `$ctx` threading.
178+
suite (235 tests) stay green.
179+
- 🔄 **`CCFE::Config`** (done): the top-level `SECTION { ... }` walk -- the third
180+
copy of the same `extract_bracketed` loop -- is now a pure tokenizer returning
181+
the sections in file order (`{name, body}` + status). Unlike the menu/form
182+
parsers, the config parser's per-section dispatch is *not* purely extractable:
183+
it is dominated by `eval "$VAR = ..."` colour/attribute assignments that must
184+
run in `ccfe.pl`'s own package to see its colour helpers and curses constants,
185+
plus term-specific (`FIELD_ATTR.$TERM`) and `$COLS`-dependent handling -- so
186+
that (effectful, scope-bound) dispatch stays in `load_config` verbatim. Unit-
187+
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.
180190

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

src/ccfe.pl

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
use CCFE::Theme ();
4949
use CCFE::MenuFile (); # pure .menu/.item parser (see load_menu)
5050
use CCFE::FormFile (); # pure .form parser (see load_form)
51+
use CCFE::Config (); # pure .conf section tokenizer (see load_config)
5152
use FindBin (); # to locate the program at runtime (see the path block below)
5253

5354
# Optional display-width support. In a UTF-8 locale a label/title can occupy
@@ -1373,13 +1374,16 @@ sub load_config {
13731374
$text = join( '', @lines );
13741375

13751376
my $term = uc $ENV{TERM};
1376-
( $val, undef, $key ) =
1377-
extract_bracketed( $text, '{', '\s*[a-zA-Z_\.]+\s*' );
1378-
while ($key) {
1379-
$val =~ s/^\{\s*//;
1380-
$val =~ s/\s*\n?\s*\}$//;
1381-
$key =~ s/^\s+//;
1382-
$key =~ s/\s+$//;
1377+
1378+
# The top-level `SECTION { ... }` walk now lives in the pure
1379+
# CCFE::Config tokenizer (ROADMAP M7). The (effectful,
1380+
# scope-bound) per-section dispatch below -- including the
1381+
# `eval "$VAR = ..."` colour/attribute assignments and the
1382+
# term-/$COLS-dependent handling -- stays here.
1383+
my ( $sections, $cstatus ) = CCFE::Config::parse($text);
1384+
foreach my $sec ( @{$sections} ) {
1385+
$key = $sec->{name};
1386+
$val = $sec->{body};
13831387
SWITCH: {
13841388
$_ = uc $key;
13851389
if (/^GLOBAL$/) {
@@ -1915,12 +1919,8 @@ sub load_config {
19151919
$res = $ES_SYNTAX_ERR;
19161920
}
19171921
}
1918-
( $val, undef, $key ) =
1919-
extract_bracketed( $text, '{', '\s*[a-zA-Z_\.]+\s*' );
1920-
}
1921-
$res = $ES_SYNTAX_ERR if !pos($text);
1922-
if ( $res == $ES_NO_ERR ) {
19231922
}
1923+
$res = $ES_SYNTAX_ERR if $cstatus eq 'syntax_error';
19241924
}
19251925
else {
19261926
trace("error opening $fname: $!");

src/lib/CCFE/Config.pm

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package CCFE::Config;
2+
3+
# Pure tokenizer for CCFE `.conf` content (ROADMAP M7, REFACTOR.md §3).
4+
#
5+
# parse($text) walks the top-level `SECTION { ... }` blocks of an already
6+
# comment-stripped config and returns them in file order as a plain list, with
7+
# no terminal, no globals and no I/O. load_config() in ccfe.pl keeps the file
8+
# finding/reading/comment-stripping and owns the (heavily effectful,
9+
# scope-bound) dispatch: validating each section's `key = value` lines and
10+
# assigning the program globals -- including the `eval "$VAR = ..."` colour and
11+
# attribute settings, which must run in ccfe.pl's own package to see its colour
12+
# helpers and curses constants, and the term-specific (`FIELD_ATTR.$TERM`) and
13+
# $COLS-dependent handling. So this module is just the section walk -- the one
14+
# genuinely pure, duplicated-three-ways piece -- and its tests drive it
15+
# directly.
16+
#
17+
# Returns ($sections, $status, \@warnings):
18+
# $sections = [ { name => 'GLOBAL', body => "KEY = val\n..." }, ... ] in file
19+
# order. A section may legitimately repeat; the caller applies
20+
# each in turn. `name` keeps the file's case (e.g.
21+
# "FIELD_ATTR.xterm"); the caller upper-cases for matching.
22+
# $status = 'ok' | 'syntax_error' ('syntax_error' only for an
23+
# unterminated bracket walk; an unknown section or key is the
24+
# caller's concern, since it owns the dispatch)
25+
# warnings = kept for shape-consistency with the other parsers (empty here)
26+
27+
use v5.36;
28+
use Text::Balanced qw(extract_bracketed);
29+
30+
our $VERSION = '2.1';
31+
32+
sub parse ($text) {
33+
my @sections;
34+
my @warn;
35+
my $status = 'ok';
36+
37+
my ( $val, $key );
38+
( $val, undef, $key ) =
39+
extract_bracketed( $text, '{', '\s*[a-zA-Z_\.]+\s*' );
40+
while ($key) {
41+
$val =~ s/^\{\s*//;
42+
$val =~ s/\s*\n?\s*\}$//;
43+
$key =~ s/^\s+//;
44+
$key =~ s/\s+$//;
45+
push @sections, { name => $key, body => $val };
46+
( $val, undef, $key ) =
47+
extract_bracketed( $text, '{', '\s*[a-zA-Z_\.]+\s*' );
48+
}
49+
$status = 'syntax_error' if !pos($text);
50+
return ( \@sections, $status, \@warn );
51+
}
52+
53+
1;

src/t/15-config.t

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
#!/usr/bin/perl
2+
#
3+
# Unit tests for the pure CCFE::Config section tokenizer (ROADMAP M7).
4+
#
5+
# The top-level `SECTION { ... }` walk was lifted out of load_config() -- the
6+
# third copy of the same extract_bracketed loop -- into a pure, terminal-free
7+
# module. load_config keeps the (effectful, scope-bound) per-section dispatch:
8+
# the eval-based colour/attribute assignments, the term-specific matching and
9+
# the global side effects all stay there. These tests drive parse($text) and
10+
# check the returned section list, ordering, status and body trimming.
11+
#
12+
use strict;
13+
use warnings;
14+
use FindBin qw($Bin);
15+
use lib "$Bin/../lib";
16+
use Test::More;
17+
18+
require_ok('CCFE::Config') or BAIL_OUT('cannot load CCFE::Config');
19+
20+
# ---- several sections, in file order ------------------------------------
21+
my ( $secs, $st, $w ) = CCFE::Config::parse( <<'EOT');
22+
GLOBAL {
23+
SCREEN_LAYOUT = smit
24+
HIDE_CURSOR = yes
25+
}
26+
FORM_GLOBAL {
27+
SHOW_DOTS = no
28+
}
29+
EOT
30+
31+
is( $st, 'ok', 'a well-formed config parses with status ok' );
32+
is( scalar @{$secs}, 2, ' two sections' );
33+
is( $secs->[0]{name}, 'GLOBAL', ' section 0 name' );
34+
is( $secs->[1]{name}, 'FORM_GLOBAL', ' section 1 name (order preserved)' );
35+
like( $secs->[0]{body}, qr/SCREEN_LAYOUT = smit/, ' section 0 body captured' );
36+
like( $secs->[0]{body}, qr/HIDE_CURSOR = yes/, ' section 0 second line' );
37+
unlike( $secs->[0]{body}, qr/[{}]/, ' body has the braces trimmed off' );
38+
like( $secs->[1]{body}, qr/^SHOW_DOTS = no$/, ' section 1 body trimmed' );
39+
is_deeply( $w, [], ' no warnings' );
40+
41+
# ---- a repeated section is kept in order, applied by the caller ---------
42+
( $secs, $st, $w ) = CCFE::Config::parse(
43+
"GLOBAL { PATH = a }\nGLOBAL { PATH = b }\n");
44+
is( scalar @{$secs}, 2, 'a repeated section is kept (not merged)' );
45+
like( $secs->[0]{body}, qr/PATH = a/, ' first occurrence' );
46+
like( $secs->[1]{body}, qr/PATH = b/, ' second occurrence' );
47+
48+
# ---- a term-specific section keeps its dotted name verbatim -------------
49+
( $secs, $st, $w ) =
50+
CCFE::Config::parse("FIELD_ATTR.xterm {\n LABEL_FG = COLOR_RED\n}\n");
51+
is( $secs->[0]{name}, 'FIELD_ATTR.xterm',
52+
'a dotted/term-specific section name is preserved verbatim (case kept)' );
53+
54+
# ---- an unterminated bracket walk is a syntax error ---------------------
55+
( $secs, $st, $w ) = CCFE::Config::parse("GLOBAL { PATH = a\n");
56+
is( $st, 'syntax_error', 'an unterminated section -> syntax_error' );
57+
58+
# ---- empty input: no sections (the !pos guard flags it, as the original
59+
# load_config did -- an empty/comment-only config is a syntax error) ----
60+
( $secs, $st, $w ) = CCFE::Config::parse('');
61+
is( $st, 'syntax_error', 'empty input trips the unterminated-walk guard' );
62+
is_deeply( $secs, [], ' no sections (empty array, not undef)' );
63+
64+
done_testing();

0 commit comments

Comments
 (0)