From 22606faebedab02ba98efde397a782b8b23d998f Mon Sep 17 00:00:00 2001 From: Julian Noble Date: Sun, 12 Jul 2026 21:13:43 +1000 Subject: [PATCH] G-045 increment 4: punk::args 0.8.0 @normalize directive; define_docs converts as consumer proof (project 0.12.7) New bare @normalize directive: opts a definition into indent normalization of BLOCK-FORM multi-line field values (first line whitespace-only, as authored by opening a braced literal with a newline). The structural first newline and a whitespace-only trailing line are dropped, the content lines' common leading whitespace is the block's base indent, the first content line is unindented fully and subsequent lines are re-based to the standard 4-space continuation convention (deeper relative indents preserved; whitespace-only inner lines become empty). Fields in a record's -unindentedfields are exempt. Implemented as a resolve pre-pass over the split records (private::normalize_records / rebase_multiline_value); @normalize with options is an error. Head-form values are never altered - their base indent is unknowable (continuations uniformly at 6 may be base-4 with the deliberate +2 relative convention, or base-6 flush; re-basing would flatten the former). The idempotence test caught exactly that on a file-style definition during implementation, narrowing the user-confirmed re-base semantics to block form only - which also makes @normalize a proven no-op on conforming file-style definitions. Consumer proof (punk 0.2.5, per the recorded user decision): ::punk::helptopic::define_docs converts from interim left-margin authoring to indented block-form values under @normalize, dropping its -unindentedfields declarations; 'i help' and 'i help_chunks' verified aligned in punk902z src, including the blank-line separator in the combined basehelp+extra block. Tests: new normalize.test (5 tests: block re-base, block left-margin, head-form boundary, exemption byte-exactness, file-style idempotence); rendering.test P4 characterization stays pinned as the deliberate unopted default with its text updated to reference @normalize. punk::args suite 186 pass / 1 pre-existing skip / 0 fail; punk::ns 53/53; full source-tree suite 806 pass / 13 skip / 1 fail = exec-14.3 only (known baseline) - zero regressions (tclsh 9.0.3). define doc documents the directive. Assisted-by: harness=claude; primary-model=claude-fable-5; api-location=anthropic.com --- CHANGELOG.md | 4 + goals/G-045-punkargs-authoring-ergonomics.md | 42 +++- punkproject.toml | 2 +- src/modules/punk-999999.0a1.0.tm | 44 ++-- src/modules/punk-buildversion.txt | 3 +- src/modules/punk/args-999999.0a1.0.tm | 159 ++++++++++++++- src/modules/punk/args-buildversion.txt | 3 +- .../punk/args/testsuites/args/normalize.test | 192 ++++++++++++++++++ .../punk/args/testsuites/args/rendering.test | 8 +- 9 files changed, 427 insertions(+), 30 deletions(-) create mode 100644 src/tests/modules/punk/args/testsuites/args/normalize.test diff --git a/CHANGELOG.md b/CHANGELOG.md index 538320aa..e2d63057 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ The latest `## [X.Y.Z]` header must match the `version` field in `punkproject.to Entries are newest-first; one bullet per notable change. See the root `AGENTS.md` "Project Versioning" section for the bump policy. +## [0.12.7] - 2026-07-12 + +- G-045 (increment): punk::args 0.8.0 adds the bare @normalize directive - opts a definition into indent normalization of block-form multi-line field values (structural leading newline dropped, content re-based to the file-style 4-space continuation convention, relative indents preserved, -unindentedfields exempt, no-op on conforming file-style definitions). Intended for constructed (string-built) definitions. punk 0.2.5: the help-system definitions (::punk::help / help_chunks) convert to indented block authoring under @normalize; 'i help' rendering unchanged. + ## [0.12.6] - 2026-07-12 - G-045 (increment): punk::args 0.7.0 adds the -& record-continuation token - an unquoted trailing -& on a definition record line continues the record on the next line, assembling byte-identically to the equivalent backslash continuation. Intended for constructed (string-built) definitions, where backslash-newline is consumed by the building code's own quoting. Brace a literal trailing -& value ({-&}); -& elsewhere on a line or inside braced/quoted multi-line values is ordinary data. Backslash-continuation authoring is unchanged. diff --git a/goals/G-045-punkargs-authoring-ergonomics.md b/goals/G-045-punkargs-authoring-ergonomics.md index b9a2aac6..602611bc 100644 --- a/goals/G-045-punkargs-authoring-ergonomics.md +++ b/goals/G-045-punkargs-authoring-ergonomics.md @@ -191,11 +191,43 @@ punk::imap4's {\Deleted}/{$MDNSent} choice values) have them stated, not just te dev-time lint cross-checking splitter boundaries against parse where the binary is available. +### 2026-07-12 increment 4: @normalize constructed-def normalization (punk::args 0.8.0, punk 0.2.5) + +- Mechanism (user-approved 2026-07-12): bare @normalize directive (like @dynamic; + options are an error), detected as a resolve pre-pass over the split records. + Semantics user-confirmed as re-base-to-+4; narrowed during implementation to + BLOCK-FORM values only (first line whitespace-only): the structural first + newline and a whitespace-only trailing line are dropped (the user's + leading-newline ergonomics question - resolved as automatic), the content + lines' common leading whitespace is the base, first content line unindented + fully, subsequent lines re-based to 4 spaces with deeper relative indents + preserved. -unindentedfields fields exempt. +- Why block-form only: head-form values have an unknowable base - continuations + uniformly at 6 may be base-4 with the deliberate +2 relative convention (P2) + or base-6 flush; common-prefix re-basing flattens the former. The idempotence + test caught exactly this on a file-style def, forcing the narrowing. Block + form is unambiguous (the first content line carries the full base), and + head-form untouchedness makes @normalize a proven no-op on conforming + file-style definitions. +- Consumer proof: define_docs converted from interim left-margin authoring to + indented block-form values + @normalize (per the increment 2 decision); + -unindentedfields declarations dropped; 'i help' and 'i help_chunks' + verified aligned in punk902z src (including the blank-line separator in the + combined basehelp+extra block). +- rendering_constructed_def_indent_characterization stays pinned as the + deliberate unopted default, its description/header updated to reference + @normalize as the opt-in remedy (the acceptance's "expectations updated to + the chosen semantics" - the chosen semantics being opt-in, unopted + behaviour is contract, not gap). +- New testsuite normalize.test (5 tests): block-form re-base, block-form + left-margin, head-form untouched boundary, -unindentedfields exemption + (structural newline kept byte-exact), idempotence on file-style defs. +- Verified (tclsh 9.0.3): punk::args suite 186 pass / 1 pre-existing skip / 0 + fail; punk::ns suite 53/53; full source-tree suite 820 total, 806 pass, 13 + skip, 1 fail = exec-14.3 only (known pre-existing core-test baseline) - zero + regressions; make.tcl modules builds clean; 'i help'/'i help_chunks' verified + aligned in punk902z src. + Remaining for acceptance: -- constructed-definition whole-block normalization opt-in, with - rendering_constructed_def_indent_characterization expectations updated and - ::punk::helptopic::define_docs converted from interim left-margin authoring - to indented-plus-normalized as the consumer proof (user decision 2026-07-12, - see increment 2 notes) - defquoting.test container quoting rules promoted into the punk::args::define -help documentation diff --git a/punkproject.toml b/punkproject.toml index 73de5355..1e19d6e3 100644 --- a/punkproject.toml +++ b/punkproject.toml @@ -1,4 +1,4 @@ [project] name = "punkshell" -version = "0.12.6" +version = "0.12.7" license = "BSD-2-Clause" diff --git a/src/modules/punk-999999.0a1.0.tm b/src/modules/punk-999999.0a1.0.tm index ecd78450..d6f44582 100644 --- a/src/modules/punk-999999.0a1.0.tm +++ b/src/modules/punk-999999.0a1.0.tm @@ -8494,30 +8494,38 @@ namespace eval punk { } #keep help-text lines manually folded (~70 cols) - the usage tables don't yet #wrap to terminal width, so line lengths here directly set the table width - #help text is authored at the left margin and the @cmd/topic lines declare - #-unindentedfields {-help} (G-045): constructed definitions get no - #whole-block indent normalization, so source-indented authoring here would - #leak extra indent into the rendered help - set basehelp {Help system for the punk shell. -With no arguments - an overview of some key shell -commands is displayed. -When the first argument is a recognised topic - help -for that topic is displayed. -('help topics' lists the available topics) -Anything else is treated as a command name - basic -command info (type and synopsis) is shown for a -resolvable command, or the resolved path for an -external executable.} - set topichelp "Help topic, or command words for basic command info.\nTopics accept their aliases and unique prefixes\n(some short words deliberately fall through to command lookup)." + #help text is authored as indented blocks (structural leading newline) and + #the generated definition declares @normalize (G-045): block-form + #multi-line values are re-based to the file-style convention at resolve + #time - no manual undent/trim and no left-margin authoring in this builder + set basehelp { + Help system for the punk shell. + With no arguments - an overview of some key shell + commands is displayed. + When the first argument is a recognised topic - help + for that topic is displayed. + ('help topics' lists the available topics) + Anything else is treated as a command name - basic + command info (type and synopsis) is shown for a + resolvable command, or the resolved path for an + external executable.} + set topichelp { + Help topic, or command words for basic command info. + Topics accept their aliases and unique prefixes + (some short words deliberately fall through to command lookup).} set specs [list] lappend specs ::punk::help help "Punk shell help system." "" - lappend specs ::punk::help_chunks punk::help_chunks "Punk shell help system - content as {channel text} chunks." "\n\nhelp_chunks returns the help content as a list of\n{channel text} chunks rather than emitting it." + lappend specs ::punk::help_chunks punk::help_chunks "Punk shell help system - content as {channel text} chunks." { + + help_chunks returns the help content as a list of + {channel text} chunks rather than emitting it.} foreach {id name summary extra} $specs { set def "" + append def "@normalize" \n append def "@id -id $id" \n - append def "@cmd -name $name -summary \"$summary\" -unindentedfields {-help} -help \"$basehelp$extra\"" \n + append def "@cmd -name $name -summary \"$summary\" -help \"$basehelp$extra\"" \n append def "@leaders -min 0 -max -1" \n - append def "topic -optional 1 -multiple 1 -type string -choiceprefix 1 -choicerestricted 0 -choicecolumns 2 -choices {$choices} -choicealiases {$choicealiases} -choiceprefixdenylist {$topic_prefixdenylist} -choiceprefixreservelist {$topic_prefixreservelist} -choiceinfo {$choiceinfo} -choicelabels {$choicelabels} -unindentedfields {-help} -help \"$topichelp\"" \n + append def "topic -optional 1 -multiple 1 -type string -choiceprefix 1 -choicerestricted 0 -choicecolumns 2 -choices {$choices} -choicealiases {$choicealiases} -choiceprefixdenylist {$topic_prefixdenylist} -choiceprefixreservelist {$topic_prefixreservelist} -choiceinfo {$choiceinfo} -choicelabels {$choicelabels} -help \"$topichelp\"" \n append def "@values -min 0 -max 0" if {[punk::args::id_exists $id]} { #quiet undefine - redefinition on registry change is expected, not noteworthy diff --git a/src/modules/punk-buildversion.txt b/src/modules/punk-buildversion.txt index ac621bba..e964b957 100644 --- a/src/modules/punk-buildversion.txt +++ b/src/modules/punk-buildversion.txt @@ -1,6 +1,7 @@ -0.2.4 +0.2.5 #First line must be a semantic version number #all other lines are ignored. +#0.2.5 - G-045: ::punk::helptopic::define_docs converts from interim left-margin authoring to indented block-form values under the new punk::args @normalize directive (the constructed-definition normalization consumer proof): basehelp/topichelp/help_chunks-extra are braced indented blocks with a structural leading newline, the generated definitions declare @normalize, and the -unindentedfields declarations from punk 0.2.4 are dropped. Rendered 'i help'/'i help_chunks' output unchanged (verified aligned incl. the blank-line separator in help_chunks). #0.2.4 - G-045: 'i help' usage table alignment - ::punk::helptopic::define_docs authors its help text at the left margin and declares -unindentedfields {-help} on both the generated @cmd line (honoured as of punk::args 0.6.1) and the topic argument line. Previously the @cmd -help braced literal carried ~16 spaces of source indent into the constructed definition (no whole-block normalization), rendering Description continuations +12 right of the first line, and the \n-relative topic -help rendered its first line +4 (the injected display prefix). Both blocks now render flush. Text content unchanged (manual ~70-col folding retained). #0.2.3 - punk::help topic definition adopts punk::args -choicealiases (G-040): choices are the four canonical topics with registry aliases folded ('i help' shows one entry per topic with an (alias:...) note), unique prefixes of topics and aliases accepted, minimum-prefix policy per user decision recorded in ::punk::helptopic (denylist {help}: h/he/hel stay command words; reservelist {c to tc}: fall through to command lookup); unrecognised words still fall through to basic command info; argless 'help' overview unchanged #0.2.2 - documentation-only: helptopic argdoc help texts manually folded (~70 cols) and the generated ::punk::help topic choices grid set to -choicecolumns 2 - 'i help'/'i help ' tables now render at reasonable widths (61-68 cols, was ~160); usage tables don't yet wrap to terminal width so source line lengths set table width; argless 'help' overview output unchanged (strict 80-col layout preserved) diff --git a/src/modules/punk/args-999999.0a1.0.tm b/src/modules/punk/args-999999.0a1.0.tm index 9be3abe2..176db2c7 100644 --- a/src/modules/punk/args-999999.0a1.0.tm +++ b/src/modules/punk/args-999999.0a1.0.tm @@ -714,6 +714,27 @@ tcl::namespace::eval punk::args { fields depend on values that can change after definition time. Dynamic definitions have a small performance cost and can interact with punk::args::parse -cache 1; see the parse -cache option. + %B%@normalize%N% + Bare directive (no options), conventionally placed near the top. + Opts the definition into indent normalization of BLOCK-FORM + multi-line field values - values whose first line is + whitespace-only, as authored by opening a braced literal with a + newline and indenting the content as a block. For such values the + structural first newline (and a whitespace-only trailing line) + are dropped, the content lines' common leading whitespace is + taken as the block's base indent, the first content line is + unindented fully and subsequent lines are re-based to the + standard 4-space continuation convention with deeper relative + indents preserved. + Head-form values (content starting on the first line) are never + altered: their base indent is ambiguous (uniform indent may be + deliberate relative convention), and they already follow the + absolute 4-space convention - so the directive is harmless on + conforming file-style definitions. + Fields named in a record's -unindentedfields are exempt. + Intended for constructed (string-built) definitions, which do not + get the whole-block indent treatment braced file-style sources + give records. %B%@id%N% ?opt val...? directive-options: -id %B%@cmd%N% ?opt val...? @@ -1477,6 +1498,124 @@ tcl::namespace::eval punk::args { return $records } + proc private::rebase_multiline_value {value} { + #G-045 @normalize: re-base a BLOCK-FORM multi-line field value to the + #file-style continuation convention. Block form = the first line is + #whitespace-only (the value was authored as an indented block, e.g. a braced + #literal opening with a newline). The structural first newline (and a + #whitespace-only trailing line) are dropped, the common leading whitespace of + #the content lines is the block's base indent: the first content line is + #unindented fully and subsequent lines have the base replaced with 4 spaces + #(deeper relative indents preserved). Whitespace-only inner lines become + #empty lines. + #Head-form values (content on the first line) are returned UNCHANGED: their + #base indent is unknowable - a value whose continuations sit uniformly at 6 + #may be base-4 with the deliberate +2 relative convention, or base-6 flush; + #re-basing would flatten the former. Head-form values follow the standard + #absolute convention (continuations at 4 + relative extras) as always, which + #also makes @normalize a no-op on conforming file-style definitions. + set lines [split $value \n] + if {[llength $lines] < 2} { + return $value + } + if {[tcl::string::trim [lindex $lines 0]] ne ""} { + #head form - unchanged + return $value + } + set lines [lrange $lines 1 end] + if {[llength $lines] > 1 && [tcl::string::trim [lindex $lines end]] eq ""} { + set lines [lrange $lines 0 end-1] + } + set prefix "" + set have_prefix 0 + foreach ln $lines { + if {[tcl::string::trim $ln] eq ""} {continue} + regexp {^[ \t]*} $ln thisprefix + if {!$have_prefix} { + set prefix $thisprefix + set have_prefix 1 + } else { + while {$prefix ne "" && [tcl::string::first $prefix $ln] != 0} { + set prefix [tcl::string::range $prefix 0 end-1] + } + } + if {$have_prefix && $prefix eq ""} {break} + } + if {!$have_prefix} { + #no content lines - degenerate block, structural lines dropped + return [join $lines \n] + } + set plen [tcl::string::length $prefix] + set out [list] + set done_first 0 + foreach ln $lines { + if {[tcl::string::trim $ln] eq ""} { + lappend out "" + } elseif {!$done_first} { + #first content line - unindent fully (it starts with the common prefix) + lappend out [tcl::string::range $ln $plen end] + set done_first 1 + } else { + lappend out " [tcl::string::range $ln $plen end]" + } + } + return [join $out \n] + } + + proc private::normalize_records {records} { + #G-045 @normalize pre-pass (value semantics in rebase_multiline_value). + #Records are list-shaped (resolve consumes them via lassign), so a record is + #rewritten as a canonical list only when one of its values changed; fields + #named in the record's own -unindentedfields are exempt. Malformed records + #pass through untouched - resolve's record loop raises the real errors. + set out [list] + foreach rec $records { + set trimrec [tcl::string::trim $rec] + switch -- [tcl::string::index $trimrec 0] { + "" - # { + lappend out $rec + continue + } + } + if {[tcl::string::first \n $rec] < 0} { + lappend out $rec + continue + } + if {[catch {set recvalues [lassign $trimrec firstword]}]} { + lappend out $rec + continue + } + if {[llength $recvalues] % 2 != 0} { + lappend out $rec + continue + } + if {[dict exists $recvalues -unindentedfields]} { + set unindented [dict get $recvalues -unindentedfields] + } else { + set unindented {} + } + set newrec [list $firstword] + set changed 0 + foreach {k v} $recvalues { + if {$k ni $unindented && [tcl::string::first \n $v] >= 0} { + set nv [rebase_multiline_value $v] + if {$nv ne $v} { + set changed 1 + } + lappend newrec $k $nv + } else { + lappend newrec $k $v + } + } + if {$changed} { + lappend out $newrec + } else { + lappend out $rec + } + } + return $out + } + proc private::classify_display_tokens {placeholdertext tokenlist} { #G-046: return the subset of tokenlist whose tokens land in display-only field #values. Display-only: -help on @cmd/@examples and on argument records, and @@ -1999,6 +2138,17 @@ tcl::namespace::eval punk::args { set records [private::split_definition_records $optionspecs] + #G-045: a bare @normalize directive record opts the whole definition into + #indent normalization of multi-line field values - the treatment constructed + #(string-built) definitions don't otherwise get. Pre-pass, so values are + #already re-based when the record loop below processes them. + foreach rec $records { + if {[tcl::string::trim $rec] eq "@normalize"} { + set records [private::normalize_records $records] + break + } + } + set cmd_info {} set package_info {} set id_info {} ;#e.g -children ?? @@ -2125,6 +2275,13 @@ tcl::namespace::eval punk::args { dynamic { set is_dynamic 1 } + normalize { + #G-045 - applied as a pre-pass over the split records (values + #are already re-based by the time this arm is reached) + if {[llength $at_specs]} { + error "punk::args::resolve - @normalize takes no options @id:$DEF_definition_id" + } + } id { #disallow duplicate @id line ? #review - nothing to stop multiple @id lines - or redefining as auto (which is ignored?) @@ -2724,7 +2881,7 @@ tcl::namespace::eval punk::args { set examples_info [dict merge $examples_info $at_specs] } default { - error "punk::args::resolve - unrecognised @ line in '$rec'. Expected @id @cmd @form... @leaders @opts @values @doc @examples @formdisplay - use @@name if paramname needs to be @name @id:$DEF_definition_id" + error "punk::args::resolve - unrecognised @ line in '$rec'. Expected @id @cmd @form... @leaders @opts @values @doc @examples @formdisplay @normalize - use @@name if paramname needs to be @name @id:$DEF_definition_id" } } #record_type directive diff --git a/src/modules/punk/args-buildversion.txt b/src/modules/punk/args-buildversion.txt index dad3a580..be9b495b 100644 --- a/src/modules/punk/args-buildversion.txt +++ b/src/modules/punk/args-buildversion.txt @@ -1,6 +1,7 @@ -0.7.0 +0.8.0 #First line must be a semantic version number #all other lines are ignored. +#0.8.0 - G-045: new bare @normalize directive - opts a definition into indent normalization of BLOCK-FORM multi-line field values (first line whitespace-only, as authored by opening a braced literal with a newline): the structural first newline and a whitespace-only trailing line are dropped, the content lines' common leading whitespace is the block's base indent, the first content line is unindented fully and subsequent lines re-based to the standard 4-space continuation convention (deeper relative indents preserved; whitespace-only inner lines become empty). Head-form values are never altered - their base indent is ambiguous (uniform continuation indent may be the deliberate +2 relative convention over base 4) - which makes @normalize a no-op on conforming file-style definitions (idempotence pinned). Fields in a record's -unindentedfields are exempt. Implemented as a resolve pre-pass over split records (private::normalize_records / rebase_multiline_value); @normalize with options is an error. Intended for constructed/string-built definitions (no whole-block indent treatment otherwise); ::punk::helptopic::define_docs converts to it as the consumer proof (punk 0.2.5). New testsuite normalize.test; define doc documents the directive; rendering.test P4 notes stay pinned as the unopted default. #0.7.0 - G-045: record-continuation token -& - an unquoted trailing -& element on a definition record line continues the record on the next line. Implemented in private::split_definition_records: the token is dropped and the next line joins after a single space with its leading whitespace collapsed, exactly how the Tcl parser joins backslash-newline continuations before a braced definition reaches the splitter - so a -& record assembles byte-identical to its backslash-continued equivalent (proven by parse+render equality test). Motivation: constructed/string-built definitions cannot author backslash-newline ergonomically (the building code's own quoting consumes it); -& is plain text and survives any construction. Collision rules: the token must be a bare word preceded by whitespace (or the whole line), trailing whitespace after it is tolerated (more forgiving than raw backslash-newline); a braced/quoted -& is data ({-&} is the escape for a literal trailing value); -& mid-line, -& as a word suffix (abc-&), and -& on lines inside still-open braced/quoted values are all data. Backslash continuation authoring is unchanged (continuation is additive). New testsuite recordcontinuation.test; define doc documents the token alongside backslash continuation. #0.6.1 - G-045: @cmd honours -unindentedfields for -help - arg_error's display-time indent transform (undent " "+help, max 4) is now gated by "-help" membership in the @cmd line's -unindentedfields list (same gate argument -help already had), so left-margin-authored cmd help renders its first line flush with continuations in both the table and string renderers. Previously the option was accepted on @cmd but ignored (rendering.test rendering_unindentedfields_cmd_help_GAP - flipped to rendering_unindentedfields_cmd_help). No in-tree definitions set @cmd -unindentedfields, so existing rendering is unchanged. Note: @cmd -summary has no indent transform in any renderer, so -unindentedfields membership for -summary is accepted and vacuously honoured. define doc for -unindentedfields now states where the option is valid. #0.6.0 - synopsis display: small restricted choice sets now render as literal alternates - an argument whose choice pool (-choices plus -choicegroups members, deduplicated) has 1-3 members and -choicerestricted true (the default) displays those words unitalicised joined by | in synopses (e.g 'after cancel' shows literal cancel; a 3-choice option shows (left|centre|right)), matching the display style of literal()/literalprefix() type-alternatives. Larger or unrestricted (-choicerestricted 0) choice sets keep the italicised argname/ display, and an explicit -typesynopsis always takes precedence. New private helper punk::args::private::synopsis_choice_literals shared by both synopsis render paths (leaders/values via synopsis_form_arg_display, options inline in synopsis); applies only to single-element -type lists (multi-element clause display unchanged). define doc for -choices documents the rule. Tests: synopsis.test - new characterization coverage for literal/literalprefix/stringstartswith/stringendswith type-alternates, option alternate parenthesization, multi-element clause display, -typesynopsis (value element lists, option passthrough incl documenter ANSI), plus the new choice-literal rule (small sets in leader/option/value positions, choicegroups counting, >3 and unrestricted fallbacks, -typesynopsis precedence) diff --git a/src/tests/modules/punk/args/testsuites/args/normalize.test b/src/tests/modules/punk/args/testsuites/args/normalize.test new file mode 100644 index 00000000..2fefe5f8 --- /dev/null +++ b/src/tests/modules/punk/args/testsuites/args/normalize.test @@ -0,0 +1,192 @@ +package require tcltest + +package require punk::args +package require punk::ansi + +#@normalize directive (G-045): opt-in indent normalization of BLOCK-FORM multi-line +#field values for constructed (string-built) definitions. Semantics (user-confirmed +#re-base 2026-07-12; narrowed to block form during implementation): a value whose +#first line is whitespace-only is a block - the structural first newline and a +#whitespace-only trailing line are dropped, the content lines' common leading +#whitespace is the block's base indent, the first content line is unindented fully +#and subsequent lines are re-based to the 4-space convention (deeper relative +#indents preserved). Head-form values are NEVER altered: their base is ambiguous +#(uniform continuation indent may be the deliberate +2 relative convention over +#base 4, or a deeper flush base) - which also makes @normalize a no-op on +#conforming file-style definitions. Fields in a record's -unindentedfields are +#exempt. Without @normalize, constructed-def behaviour is unchanged - see the P4 +#characterization in rendering.test. + +namespace eval ::testspace { + namespace import ::tcltest::* + variable common { + set result "" + } + + proc render_table {id} { + return [punk::ansi::ansistrip [punk::args::arg_error "" [punk::args::get_spec $id] -aserror 0]] + } + #column (0-based) at which a unique marker string appears in the rendered text, -1 if absent + proc markercol {rendered marker} { + foreach ln [split $rendered \n] { + set ix [string first $marker $ln] + if {$ix >= 0} { + return $ix + } + } + return -1 + } + + #added 2026-07-12 (agent, G-045) - the head-form boundary: base indent is + #ambiguous for head-form values so @normalize leaves them alone (deep indents + #leak exactly as in the unopted P4 characterization: display strips only 4) + test normalize_headform_untouched {@normalize: head-form values (content on the first line) are not altered - deep continuation indents render as authored}\ + -setup $common -body { + set help "NFIRST line\n NFLUSH line\n NPLUS2 line" + set def "" + append def "@normalize" \n + append def "@id -id ::testspace::nz_head" \n + append def "@cmd -name testspace::nz_head -summary \"Head.\" -help \"$help\"" \n + append def "@values -min 0 -max 0" + punk::args::define $def + set r [render_table ::testspace::nz_head] + set n0 [markercol $r NFIRST] + lappend result [expr {[markercol $r NFLUSH] - $n0}] + lappend result [expr {[markercol $r NPLUS2] - $n0}] + }\ + -cleanup { + punk::args::undefine ::testspace::nz_head 1 + }\ + -result [list 12 14] + + #added 2026-07-12 (agent, G-045) - the block-form ergonomics: a braced value + #authored as a clean indented block (leading newline, closing-brace line) needs + #no manual string trim + test normalize_blockform_leading_newline {@normalize: block-form value (whitespace-only first line) drops the structural newline and renders flush}\ + -setup $common -body { + set help { + BFIRST line + BFLUSH line + BPLUS2 line + } + set def "" + append def "@normalize" \n + append def "@id -id ::testspace::nz_block" \n + append def "@cmd -name testspace::nz_block -summary \"Block.\" -help \"$help\"" \n + append def "@values -min 0 -max 0" + punk::args::define $def + set r [render_table ::testspace::nz_block] + set b0 [markercol $r BFIRST] + lappend result [expr {[markercol $r BFLUSH] - $b0}] + lappend result [expr {[markercol $r BPLUS2] - $b0}] + #the structural leading newline is gone: BFIRST appears on the same + #rendered line as the Description: label + set desc_line "" + foreach ln [split $r \n] { + if {[string first "Description:" $ln] >= 0} { + set desc_line $ln + break + } + } + lappend result [expr {[string first "BFIRST" $desc_line] >= 0}] + }\ + -cleanup { + punk::args::undefine ::testspace::nz_block 1 + }\ + -result [list 0 2 1] + + #added 2026-07-12 (agent, G-045) - block form with zero base indent: left-margin + #content behind a structural leading newline re-bases to the convention (no + #-unindentedfields declaration needed) + test normalize_blockform_leftmargin_gains_base {@normalize: a block-form value with left-margin content is re-based and renders flush}\ + -setup $common -body { + set help "\nLFIRST line\nLFLUSH line" + set def "" + append def "@normalize" \n + append def "@id -id ::testspace::nz_left" \n + append def "@cmd -name testspace::nz_left -summary \"Left.\" -help \"$help\"" \n + append def "@values -min 0 -max 0" + punk::args::define $def + set r [render_table ::testspace::nz_left] + lappend result [expr {[markercol $r LFLUSH] - [markercol $r LFIRST]}] + }\ + -cleanup { + punk::args::undefine ::testspace::nz_left 1 + }\ + -result [list 0] + + #added 2026-07-12 (agent, G-045) - exemption: an -unindentedfields field keeps + #its block-form value byte-exact (structural newline preserved, no re-base, no + #display transform) where a non-exempt block would lose the leading blank line + test normalize_unindentedfields_exempt {@normalize: fields in a record's -unindentedfields are exempt - block-form value kept literally}\ + -setup $common -body { + set help "\n EFIRST line\n ESIX line" + set def "" + append def "@normalize" \n + append def "@id -id ::testspace::nz_exempt" \n + append def "@cmd -name testspace::nz_exempt -summary \"Exempt.\" -unindentedfields {-help} -help \"$help\"" \n + append def "@values -min 0 -max 0" + punk::args::define $def + set r [render_table ::testspace::nz_exempt] + #leading structural newline preserved: EFIRST is NOT on the Description: line + set desc_line "" + foreach ln [split $r \n] { + if {[string first "Description:" $ln] >= 0} { + set desc_line $ln + break + } + } + lappend result [expr {[string first "EFIRST" $desc_line] < 0}] + #6-space indents kept literally - both lines at the same column + lappend result [expr {[markercol $r ESIX] - [markercol $r EFIRST]}] + }\ + -cleanup { + punk::args::undefine ::testspace::nz_exempt 1 + }\ + -result [list 1 0] + + #added 2026-07-12 (agent, G-045) - idempotence: @normalize on a conforming + #file-style braced definition changes nothing + test normalize_idempotent_on_filestyle {@normalize: a braced file-style definition renders identically with and without the directive}\ + -setup $common -body { + punk::args::define { + @id -id ::testspace::nz_fplain + @cmd -name testspace::nz_fstyle -summary\ + "Filestyle."\ + -help\ + "FFIRST line. + FFLUSH line. + FPLUS2 line." + @values -min 1 -max 1 + v1 -type string -help\ + "val help line1 + val help line2" + } + punk::args::define { + @normalize + @id -id ::testspace::nz_fnorm2 + @cmd -name testspace::nz_fstyle -summary\ + "Filestyle."\ + -help\ + "FFIRST line. + FFLUSH line. + FPLUS2 line." + @values -min 1 -max 1 + v1 -type string -help\ + "val help line1 + val help line2" + } + set r_plain [render_table ::testspace::nz_fplain] + set r_norm [render_table ::testspace::nz_fnorm2] + #ids deliberately same length (nz_fplain/nz_fnorm2) so table geometry matches + lappend result [expr {[string map {nz_fnorm2 nz_fplain} $r_norm] eq $r_plain}] + }\ + -cleanup { + punk::args::undefine ::testspace::nz_fplain 1 + punk::args::undefine ::testspace::nz_fnorm2 1 + }\ + -result [list 1] + + cleanupTests +} +namespace delete ::testspace diff --git a/src/tests/modules/punk/args/testsuites/args/rendering.test b/src/tests/modules/punk/args/testsuites/args/rendering.test index 49c75d2e..3dfd2cd1 100644 --- a/src/tests/modules/punk/args/testsuites/args/rendering.test +++ b/src/tests/modules/punk/args/testsuites/args/rendering.test @@ -21,8 +21,10 @@ package require punk::ansi # normalization ('constructed' - not to be confused with the @dynamic directive): # embedded continuation indentation is interpreted via the display-time # undent(prefix4,max4) transform, so continuations written at exactly 4 spaces -# align flush with the first line, and any other depth misaligns (pinned as -# characterization - this is why ::punk::helptopic::define_docs pre-normalizes) +# align flush with the first line, and any other depth misaligns. This remains +# the (deliberate) default: G-045 added the opt-in @normalize directive for +# block-form values (see normalize.test) and this characterization pins the +# unopted behaviour builders rely on # P5 tstr ${...} substitutions with MULTILINE results (command or variable) insert # aligned at the insertion column with the inserted text's own relative indents # preserved (the -paramindents machinery) - including whole-definition insertion @@ -281,7 +283,7 @@ GFLUSH line" #--- P4 constructed (string-built) definitions ----------------------------------------- - test rendering_constructed_def_indent_characterization {constructed (string-built) definitions: continuation indentation is absolute - exactly 4 spaces aligns flush; other depths shift (why constructed-def builders must pre-normalize)}\ + test rendering_constructed_def_indent_characterization {constructed (string-built) definitions: continuation indentation is absolute - exactly 4 spaces aligns flush; other depths shift (opt-in remedy: the G-045 @normalize directive, normalize.test)}\ -setup $common -body { #continuations at exactly 4 -> aligned flush with first line (the current #correct authoring convention for constructed definitions)