From c2574a70509dae8e250807e9e63fbcd67d35bd23 Mon Sep 17 00:00:00 2001 From: Julian Noble Date: Fri, 17 Jul 2026 21:55:16 +1000 Subject: [PATCH] punk::args 0.12.5 + punk::ns 0.8.1: parse -cache overhaul, synopsis notation, eg resolution parse -cache overhaul: - VALIDATION trap's parse_cache write now explicitly gated on -cache (formerly unconditional but landing in a discarded proc-local for -cache 0 by scoping accident - 'variable parse_cache' only ran on the -cache 1 branch) - cached validation errors stored as per {errorstyle callertext} render variants under a {arglist definition form} main key (-caller dropped from the main key - results don't depend on it); a missing variant falls through to a fresh render. Previously the first caller's errorstyle and %caller% attribution were baked in and replayed verbatim to later callers. - @dynamic definitions (and legacy leading '-dynamic 1') are never cached so re-evaluated substitutions always take effect; dev/dynamic-cache.test known-bug pin flipped - new punk::args::parse_cache view/clear proc (sgr_cache style); guarded variable init; -errorstyle definition default corrected to the operative 'standard'. New parsecache.test (7 tests). synopsis notation + internal @form -synopsis retirement: - ::punk::args::parse/::parse_status dropped their stale @form -synopsis overrides (omitted -cache/-caller) for auto-generated lines shaped with -typesynopsis {...} on -form; drift pin synopsis_parse_generated added - punk::ns::synopsis no longer errors on custom @form -synopsis lines that are not valid Tcl lists (e.g Tcl manpage style '?-form {int|...}?' - 's parse' raised 'list element in braces followed by ...'): non-list lines take a textual command-head replacement fallback - @form -synopsis directive help rewritten (display-only, any style, prefer auto-generation, known warranted case: script-level/constructed ids); ::punk::args::synopsis -help gains a notation legend and a new @examples block comparing Tcl man-page ?...? style with the bracket style (punk::args::eg) eg usability + examples indication: - punk::args::eg resolves relative/unqualified command names (punk::ns::cmdinfo from calling context when loaded, else global qualification) - in namespaces where it shadows the global 'eg' alias (e.g ::punk::args) 'eg list' returned empty while 'i list' worked; unknown ids now return a message naming the id; punk::ns::eg hands the typed name through on unresolved docids - usage-table 'Example: eg ' header row now uses scheme colour CLR(check) (interim display until G-088 footer/subtitle support) Assisted-by: harness=claude; primary-model=claude-fable-5; api-location=anthropic.com --- src/modules/punk/args-999999.0a1.0.tm | 253 +++++++++++++++--- src/modules/punk/args-buildversion.txt | 5 +- src/modules/punk/ns-999999.0a1.0.tm | 55 ++-- src/modules/punk/ns-buildversion.txt | 3 +- .../punk/args/testsuites/args/examples.test | 28 ++ .../punk/args/testsuites/args/parsecache.test | 188 +++++++++++++ .../punk/args/testsuites/args/synopsis.test | 66 +++++ .../args/testsuites/dev/dynamic-cache.test | 5 +- 8 files changed, 549 insertions(+), 54 deletions(-) create mode 100644 src/tests/modules/punk/args/testsuites/args/parsecache.test diff --git a/src/modules/punk/args-999999.0a1.0.tm b/src/modules/punk/args-999999.0a1.0.tm index daea85a5..8f5f8ca1 100644 --- a/src/modules/punk/args-999999.0a1.0.tm +++ b/src/modules/punk/args-999999.0a1.0.tm @@ -767,8 +767,9 @@ tcl::namespace::eval punk::args { are re-evaluated each time the definition is resolved. Use this when defaults, choices, help text, or other definition 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. + Dynamic definitions have a small performance cost, and their + parses are never cached - punk::args::parse -cache 1 is ignored + for them so re-evaluated substitutions always take effect. %B%@normalize%N% Bare directive (no options), conventionally placed near the top. Opts the definition into indent normalization of BLOCK-FORM @@ -821,8 +822,23 @@ tcl::namespace::eval punk::args { (used for commands with multiple forms) directive-options: -form -synopsis -overlapallowed - The -synopsis value allows overriding the auto-calculated - synopsis. + The -synopsis value replaces the auto-calculated synopsis + line for the form. It is display-only text: any style is + permitted (including Tcl manual-page style ?...? optionals) + and it need not be a valid Tcl list. The leading word(s) + should be the command invocation - display helpers such as + punk::ns::synopsis substitute the command as the user typed + it for them. + Prefer the auto-calculated synopsis where possible - it + stays current as arguments are added, while an override + silently goes stale. To shape individual elements of the + auto-calculated line use -typesynopsis on the arguments + concerned instead of overriding wholesale (see the notation + legend in punk::args::synopsis). + A known case where an override is warranted: script-level + or constructed definition ids, where the auto-synopsis + renders the raw id (e.g '(script)::punkboot::modules') + rather than an invocable command name. The -overlapallowed value names other forms this form is KNOWN to overlap with (an argument list can cleanly match both - e.g 'after cancel ' where real Tcl @@ -5320,7 +5336,14 @@ tcl::namespace::eval punk::args { } if {$has_example} { lappend blank_header_col "" - set example_display "[a+ white]eg [dict get $spec_dict id]$RST" + #short runnable hint that an @examples block exists. The raw id is used + #rather than @cmd -name: -name is display text and not always invocable + #(e.g the tclcore moduledoc's 'Autoloading Built-in: parray'). Scheme + #colour (check) so the affordance stands out and NOCOLOR handling applies. + #A frame-embedded bottom-right marker (like the top 'Usage' title) awaits + #textblock table -subtitle/footer-block support (G-088) - this header row + #is the interim display and retires when G-088 lands. + set example_display "$CLR(check)eg [dict get $spec_dict id]$RST" } else { set example_display "" } @@ -6436,7 +6459,7 @@ tcl::namespace::eval punk::args { "Arguments to parse - supplied as a single list" @opts -prefix 0 - -form -type list -default * -help\ + -form -type list -default * -typesynopsis {...} -help\ "Restrict parsing to the set of forms listed. Forms are the orthogonal sets of arguments a command can take - usually described in 'synopsis' @@ -6450,9 +6473,10 @@ tcl::namespace::eval punk::args { form's failure, and matches against several forms raise an error naming them - pass a single form to disambiguate." - #default to enhanced errorstyle despite slow 'catch' (unhappy path) performance + #operative default is 'standard' - from defaultopts in the parse proc body. + #(this definition previously claimed enhanced while the code applied standard) #todo - configurable per interp/namespace - -errorstyle -type string -default enhanced -choices {enhanced standard basic minimal} + -errorstyle -type string -default standard -choices {enhanced standard basic minimal} -caller -type string -default "" -help\ "Caller attribution for validation error messages. When non-empty, this string replaces the %caller% placeholder in @@ -6462,20 +6486,20 @@ tcl::namespace::eval punk::args { internal parse call site)." -cache -type boolean -default 0 -help\ {Use sparingly. - This caches the entire parse result or formatted validation error for the - same argument list, definition, and selected form. + This caches the entire parse result for the same argument list, definition, + and selected form. Rendered validation errors are cached per errorstyle and + caller attribution. It is a minor speedup suitable for functions that repeatedly parse a small set of identical, generally small argument lists against a stable definition. - Avoid it for one-off calls, large or highly variable argument lists, and - definitions whose dynamic substitutions must be re-evaluated on every call. - In particular, @dynamic definitions may still refresh their resolved - specification data internally, but -cache 1 can return a previously cached - parse result before those refreshed values affect the current call.} + Avoid it for one-off calls and large or highly variable argument lists. + Definitions marked @dynamic (or using the legacy leading '-dynamic 1' form) + are never cached - their substitutions are re-evaluated on every parse. + The cache can be viewed/cleared with punk::args::parse_cache.} @values -min 2 - @form -form withid -synopsis "parse arglist ?-form {int|...}? ?-errorstyle ? withid $id" + @form -form withid @values -max 2 withid -type literal(withid) -help\ "The literal value 'withid'" @@ -6483,7 +6507,7 @@ tcl::namespace::eval punk::args { "id of punk::args definition for a command" - @form -form withdef -synopsis "parse arglist ?-form {int|...}? ?-errorstyle ? withdef $def ?$def?" + @form -form withdef withdef -type literal(withdef) -help\ "The literal value 'withdef'" @@ -6497,7 +6521,10 @@ tcl::namespace::eval punk::args { how to process the definition." }] - variable parse_cache [dict create] + variable parse_cache + if {![info exists parse_cache]} { + set parse_cache [tcl::dict::create] + } proc parse {args} { #puts "punk::args::parse --> '$args'" if {[llength $args] < 3} { @@ -6585,26 +6612,51 @@ tcl::namespace::eval punk::args { error "punk::args::parse - invalid call. Argument following arglist was '$tailtype'. Must be 'withid' or 'withdef'" } } + set do_cache [dict get $opts -cache] + if {$do_cache} { + #@dynamic definitions (and the legacy leading '-dynamic 1' form) can resolve + #differently for identical raw text - never cache their parses, so + #re-evaluated substitutions always take effect. + variable rawdef_cache_about + if {[dict exists $rawdef_cache_about $deflist]} { + if {[dict get $rawdef_cache_about $deflist -dynamic]} { + set do_cache 0 + } + } elseif {[rawdef_is_dynamic $deflist]} { + set do_cache 0 + } + } try { #puts stdout "parse --> get_dict $parseargs -form [dict get $opts -form]" - if {![dict get $opts -cache]} { + if {!$do_cache} { set result [punk::args::get_dict $deflist $parseargs -form [dict get $opts -form]] } else { variable parse_cache - set key [list $parseargs $deflist [dict get $opts -form] [dict get $opts -caller]] + #parse results don't depend on -errorstyle or caller attribution, so the + #main key excludes them. Rendered validation errors bake both in - error + #entries hold per {errorstyle callertext} render variants (see the + #VALIDATION trap); a missing variant falls through to a fresh parse whose + #trap renders and caches it. + set key [list $parseargs $deflist [dict get $opts -form]] if {[dict exists $parse_cache $key]} { set cached [dict get $parse_cache $key] if {[dict get $cached type] eq "result"} { return [dict get $cached value] + } + set matched_errorstyle [tcl::prefix::match -error "" {enhanced standard basic minimal debug} [dict get $opts -errorstyle]] + if {[dict get $opts -caller] ne ""} { + set caller_text [dict get $opts -caller] } else { + set caller_text [Get_caller] + } + if {[dict exists $cached value [list $matched_errorstyle $caller_text]]} { #return the error 'elist' - return {*}[dict get $cached value] + return {*}[dict get $cached value [list $matched_errorstyle $caller_text]] } - } else { - set result [punk::args::get_dict $deflist $parseargs -form [dict get $opts -form]] - dict set parse_cache $key [dict create type "result" value $result] - return $result } + set result [punk::args::get_dict $deflist $parseargs -form [dict get $opts -form]] + dict set parse_cache $key [dict create type "result" value $result] + return $result } } trap {PUNKARGS VALIDATION} {msg erroropts} { set opt_errorstyle [dict get $opts -errorstyle] @@ -6620,10 +6672,14 @@ tcl::namespace::eval punk::args { if {[dict get $opts -caller] ne ""} { #explicit caller attribution supplied (e.g punk::ns::cmdhelp usage display #naming the queried command) - frame walking would name internal call sites. - set msg [string map [list %caller% [dict get $opts -caller]] $msg] + set caller_text [dict get $opts -caller] } else { - set msg [string map [list %caller% [Get_caller]] $msg] + set caller_text [Get_caller] } + #caller_text is baked into the rendered message below - cached error entries + #are therefore keyed per {errorstyle caller_text} variant so one caller/style + #never replays another's render. + set msg [string map [list %caller% $caller_text] $msg] #G-041: display-form selection for the usage render. For the multi-form #candidacy failures the engine reports, show the candidate forms rather than #the caller's (usually *) -form selection - best candidate first so its @@ -6706,8 +6762,18 @@ tcl::namespace::eval punk::args { } } - set key [list $parseargs $deflist [dict get $opts -form] [dict get $opts -caller]] - dict set parse_cache $key [dict create type "error" value $elist] + if {$do_cache} { + variable parse_cache + set key [list $parseargs $deflist [dict get $opts -form]] + set variantkey [list $matched_errorstyle $caller_text] + if {[dict exists $parse_cache $key] && [dict get $parse_cache $key type] eq "error"} { + dict set parse_cache $key value $variantkey $elist + } else { + #no entry, or a stale 'result' entry (same key can only flip outcome if + #the definition's behaviour changed underneath us) - replace wholesale + dict set parse_cache $key [dict create type "error" value [dict create $variantkey $elist]] + } + } return {*}$elist } trap {PUNKARGS} {msg erropts} { append msg \n "Unexpected PUNKARGS error" @@ -6721,6 +6787,59 @@ tcl::namespace::eval punk::args { return $result } + lappend PUNKARGS [list { + @id -id ::punk::args::parse_cache + @cmd -name punk::args::parse_cache\ + -summary\ + "View or clear the punk::args::parse -cache 1 result cache"\ + -help\ + "Convenience function to view and optionally clear the cache used by + punk::args::parse -cache 1. + Entries are keyed by {arglist definition form} and hold either a + complete parse result or a set of rendered validation-error variants + keyed by {errorstyle callertext}." + @opts + -action -default "" -choices {clear} -help\ + "-action clear will reset the punk::args::parse_cache dict to empty" + -return -default summary -type string -choices {summary keys dict} -help\ + "summary - dict of counts: entries, results, errors, error_variants, chars + keys - list of cache keys ({arglist definition form} triples) + dict - the underlying cache dict" + @values -min 0 -max 0 + }] + proc parse_cache {args} { + #uncached self-parse - so this function doesn't add entries to what it reports on + set argd [punk::args::parse $args withid ::punk::args::parse_cache] + set action [dict get $argd opts -action] + variable parse_cache + if {$action eq "clear"} { + set parse_cache [tcl::dict::create] + return "parse_cache cleared" + } + switch -- [dict get $argd opts -return] { + keys { + return [dict keys $parse_cache] + } + dict { + return $parse_cache + } + summary { + set results 0 + set errors 0 + set variants 0 + dict for {k v} $parse_cache { + if {[dict get $v type] eq "result"} { + incr results + } else { + incr errors + incr variants [dict size [dict get $v value]] + } + } + return [dict create entries [dict size $parse_cache] results $results errors $errors error_variants $variants chars [string length $parse_cache]] + } + } + } + #classify a PUNKARGS VALIDATION failure class-word into the parse-status overall status #payload = elements of the errorcode description after the class word proc private::parse_status_classify {failureclass payload} { @@ -7530,7 +7649,7 @@ tcl::namespace::eval punk::args { arglist -type list -optional 0 -help\ "Arguments to parse - supplied as a single list" @opts -prefix 0 - -form -type list -default * -help\ + -form -type list -default * -typesynopsis {...} -help\ "Restrict parsing to the set of forms listed (see punk::args::parse). The per-argument statuses are built for the first matching form." -caller -type string -default "" -help\ @@ -7540,14 +7659,14 @@ tcl::namespace::eval punk::args { walk parse would perform." @values -min 2 - @form -form withid -synopsis "parse_status arglist ?-form {int|...}? ?-caller ? withid $id" + @form -form withid @values -max 2 withid -type literal(withid) -help\ "The literal value 'withid'" id -type string -help\ "id of punk::args definition for a command" - @form -form withdef -synopsis "parse_status arglist ?-form {int|...}? ?-caller ? withdef $def ?$def?" + @form -form withdef withdef -type literal(withdef) -help\ "The literal value 'withdef'" def -type string -multiple 1 -optional 0 -help\ @@ -11702,9 +11821,29 @@ tcl::namespace::eval punk::args { set id [lindex $cmditems 0] set cmdargs [lrange $cmditems 1 end] + if {$id ne "" && ![string match ::* $id]} { + #punk::args ids are canonically fully-qualified. Accommodate relative command + #names for shell use - callers in a namespace where this proc shadows the + #global 'eg' alias (e.g whilst in ::punk::args itself) would otherwise have to + #fully qualify while 'i'/cmdhelp resolves fine. Resolve from the calling + #context via punk::ns::cmdinfo when punk::ns is loaded (also handles + #subcommand words e.g 'dict filter'), else assume the global namespace. + set resolved "" + if {[info commands ::punk::ns::cmdinfo] ne ""} { + catch { + set resolved [dict get [uplevel 1 [list ::punk::ns::cmdinfo {*}$cmditems]] docid] + } + } + if {$resolved ne ""} { + set id $resolved + } else { + set id ::$id + } + } + set spec [get_spec $id] if {$spec eq ""} { - return + return "no punk::args definition found for $id" } set spec [private::expand_display_fields $spec] ;#G-046 @examples -help may be deferred if {[dict exists $spec examples_info -help]} { @@ -11874,6 +12013,27 @@ tcl::namespace::eval punk::args { on separate lines. If -form is given, supply only the synopsis for that form. + + Notation used in generated synopsis lines: + argname italicised placeholder - the argument's name + italicised placeholder - shown by type when no + name hint is available + word unitalicised word - a literal, typed as-is. + literal()/literalprefix() types and restricted + choice sets of up to 3 choices render as literals + a|b alternates - exactly one may be supplied + (a|b) alternates grouped in parentheses where a plain + pipe would be ambiguous - e.g an option's value + alternates vs option-alias pipes such as -f|--file + [element] optional element + [element]... optional element, repeatable + elem [elem]... required element, repeatable + --opt= long option accepting an inline =value + {a b} braces protect a multi-word literal - supply it + as a single word + text* / *text value must start / end with 'text' + Custom @form -synopsis overrides are passed through verbatim and + may use any style (see punk::args::define). " @opts -noheader -type none @@ -11881,6 +12041,33 @@ tcl::namespace::eval punk::args { -return -type string -default full -choices {full summary dict} @values -min 1 -max -1 cmditem -multiple 1 -optional 0 + @examples -help { + Comparison with the Tcl manual-page synopsis style. + + Tcl man pages mark an optional element by wrapping it in question + marks; punk::args wraps it in square brackets. With nesting, the + bracket form stays readable because the opening and closing + delimiters differ, whereas the question mark is the same character + at both ends: + + Tcl style: lseq start ??'..'|'to'? end? ??'by'? step? + punk::args: lseq start [['..'|'to'] end] [['by'] step] + + A generated example (abridged from 's textblock::frame'): + + ::textblock::frame [-type (choice|)] [-title ] [contents] + + -type accepts alternates: 'choice' is an unitalicised literal, + an italicised type placeholder, and the group is + parenthesized so its pipe is not read as an option-alias pipe + (compare -f|--file). 'contents' is an optional value displayed by + its argument name. + + An explicit @form -synopsis override is passed through verbatim - + any style may be used there, including Tcl manual-page style: + + @form -form range -synopsis "lseq start ?('..'|'to')? end ??'by'? step?" + } }] proc synopsis {args} { #synopsis potentially called repeatedly with same args? use -cache 1 diff --git a/src/modules/punk/args-buildversion.txt b/src/modules/punk/args-buildversion.txt index a4e64d10..4113db51 100644 --- a/src/modules/punk/args-buildversion.txt +++ b/src/modules/punk/args-buildversion.txt @@ -1,6 +1,9 @@ -0.12.4 +0.12.5 #First line must be a semantic version number #all other lines are ignored. +#0.12.5 - eg usability: punk::args::eg resolves relative/unqualified command names (via punk::ns::cmdinfo from the calling context when punk::ns is loaded - handles subcommand words too - else global qualification). Rationale: in a namespace where punk::args::eg shadows the global 'eg' alias (e.g whilst in ::punk::args itself) 'eg list' hit the raw-id API and returned empty while 'i list' resolved - Tcl name resolution, not an alias deficiency. Unknown ids now return a message naming the id instead of empty; punk::ns::eg hands the typed name through when resolution yields no docid so the message can name it. The usage-table 'Example: eg ' header row (the short indication an @examples block exists) now uses scheme colour CLR(check) instead of hardcoded white - the raw id is kept (not @cmd -name, which is display text and not always invocable e.g 'Autoloading Built-in: parray'); a frame-embedded bottom-right marker awaits textblock table footer-block support (candidate goal). New examples.test pins: relative-name resolution, unknown-id message. +#0.12.5 - synopsis notation documented + internal @form -synopsis overrides retired. ::punk::args::parse and ::parse_status dropped their @form -synopsis overrides - they had gone stale (omitted -cache/-caller) and their only added value (the -form member hint) is now expressed properly via -typesynopsis {...} on the -form option; the auto-generated lines stay current as options are added (drift pin: synopsis.test synopsis_parse_generated). @form -synopsis remains fully supported for documentors: define's directive help now documents that it is display-only text needing no Tcl-list validity, that display helpers substitute the command head, and the known warranted case (script-level/constructed ids rendering the raw id - the G-030 lesson recorded in src/modules/AGENTS.md; head-render preference for @cmd -name remains an open generation deficiency). ::punk::args::synopsis -help gains a notation legend ([...] optional, italic argname/ placeholders, unitalicised literals, (a|b) alternate grouping to disambiguate from -f|--file alias pipes, {a b} multi-word literal protection, --opt=, text*/*text) and a new @examples block comparing Tcl man-page ?...? style (ambiguous when nested - same delimiter both ends) with the bracket style, viewable via punk::args::eg. New synopsis.test pins: non-list custom -synopsis passthrough + ns head-replacement survival, parse generated-line drift pin, examples block presence. +#0.12.5 - parse -cache overhaul. (1) The VALIDATION trap's parse_cache write is now explicitly gated on -cache - previously it was unconditional but only 'worked' by scoping accident ('variable parse_cache' was declared solely on the -cache 1 branch, so -cache 0 failures wrote to a discarded proc-local). (2) Cached validation errors are now stored as per {errorstyle callertext} render variants under a main key of {arglist definition form} (-caller removed from the main key - results don't depend on it): previously the rendered message baked in the first caller's errorstyle and %caller% attribution and replayed them verbatim to later callers wanting a different style/caller (e.g. a minimal-style render replayed to a -errorstyle standard call). A missing variant falls through to a fresh parse whose trap renders and caches it. (3) @dynamic definitions (and legacy leading '-dynamic 1') are never cached - parse -cache 1 is ignored for them so re-evaluated substitutions always take effect (dev/dynamic-cache.test known-bug pin flipped). (4) New punk::args::parse_cache convenience proc (view summary/keys/dict, -action clear - sgr_cache style); parse_cache variable init now guarded with info exists like the sibling caches; parse's -errorstyle definition default corrected to 'standard' matching the operative defaultopts (doc previously claimed enhanced). New testsuite parsecache.test. #0.12.4 - resolve: retired the (already disabled) blanket 'called with undefined id' stderr warning - it fired on the supported inline usages (parse ... withdef, direct resolve calls) where a first-sighting cache miss is by design. In its place the miss path is now collision-aware: a first-seen rawdef whose @id is already registered with different content purges the id's existing cache entries (quiet undefine) before registering, mirroring define's re-creation behaviour - previously it repointed id_cache_rawdef while leaving the old rawdef_cache_about/rawdef_cache_argdata entries in place, so two rawdefs claimed the same id and a subsequent define with the original text was a silent no-op (stale cache-about hit) leaving withid lookups stuck on the inline definition. Guard tests id_cache_rawdef directly (id_exists also matches aliases, where the rawdef lookup would error). Known residual (comment marker retained): the miss path still records no -defspace, so @dynamic definitions first seen via resolve lack a defining namespace for re-substitution. New test define.test define_resolve_id_shadow (characterized pre-fix: re-define no-op reproduced with the purge disabled). #0.12.3 - fixed the stringstartswith(*) arm of get_dict_can_assign_value's multi-member tail-clause reservation walk: it matched against $tp (the type string) instead of $rv (the candidate value), so for a later optional clause mixing literal(...) and stringstartswith(...) members, genuinely matching trailing words were never reserved from a greedy -multiple argument (the optional tail clause was silently lost), while a prefix text that happened to prefix the literal type string (e.g 'str' vs 'stringstartswith(str)') wrongly reserved non-matching words and raised a spurious toomanyarguments overflow. Found flagged-only in the 0.12.2 comment review; characterized pre-fix then pin flipped (allocation.test allocation_tailclause_ssw_reservation + literal-only and no-match guards). Note the walk still only runs when the tail clause's typelist contains a literal* member (the pre-existing acknowledged approximation) - a stringstartswith-only tail clause remains unreserved. #0.12.2 - comment/documentation hygiene pass (no behaviour change). Removed ~1100 lines of superseded commented-out code: the old-signature private::check_clausecolumn, the abandoned _check_clausecolumn2 'interim version' experiment (its one keeper idea - collect per-clause validation results and report at end instead of raising on first failure - recorded in an in-place note for G-072-era rework), the pre-parsekey opts ordering loop, a disabled XXXliteral* switch arm in get_dict_can_assign_value's multi-member clause walk (open item noted: literalprefix() members unhandled there), and misc debug remnants. Expanded the leaders/values loop MAINTENANCE notes into a real refactor assessment: shared clause-assignment helper extraction is indicated (drift is real - G-082 duplicated its selection block, parsekey idents exist only values-side) but deferred to ride the G-084 leaders/values parsekey parity work under the G-046 hot-path constraints; until then edits to one loop must be mirrored or justified. Documentation corrections: get_dict PUNKARGS/doctools return shape now lists the real result keys (leaders opts values received solos multis id form + formstatus when candidacy ran); directive lists updated beyond @cmd/@leaders/@opts/@values; removed false pre-modern claims ('only supports -flag val pairs, not solo options', 'only the last value is used'); fixed the broken parse -help example (dict exists bracketing) and the module-header dofilestuff example (stray trailing 1 after -type existingfile); assorted typos. Goal cross-references annotated in code comments (G-053 -multiple occurrence ranges, G-084 values/leaders parsekey, corrected test-name refs to parsekey_repeat_ordering). Flagged (comment only, behaviour unchanged, no pins exist): suspected bug in the multi-member clause stringstartswith( arm - it string-matches against $tp (the type) instead of $rv (the value), so it always takes the success path. diff --git a/src/modules/punk/ns-999999.0a1.0.tm b/src/modules/punk/ns-999999.0a1.0.tm index 84d74883..2c5edde9 100644 --- a/src/modules/punk/ns-999999.0a1.0.tm +++ b/src/modules/punk/ns-999999.0a1.0.tm @@ -5184,6 +5184,11 @@ y" {return quirkykeyscript} #set resolved_id [dict get $resolveinfo origin] #set result [::punk::args::eg $resolved_id] set docid [dict get $resolveinfo docid] + if {$docid eq ""} { + #undocumented or unresolved - hand the typed name through so + #punk::args::eg's 'no punk::args definition' message can name it + set docid [lindex $cmdwords 0] + } set result [::punk::args::eg $docid] } @@ -5292,25 +5297,41 @@ y" {return quirkykeyscript} #append resultstr [join [lreplace $synline 0 $replaceuntil {*}$resolved_args] " "] \n ;#don't use join - will destroy braced sets #e.g see s dict filter - #treating a somewhat arbitrary string $synline as a list here is a bit risky - #todo - consider always using 'punk::args::synopsis -return dict' and operating on that list to rebuild string - REVIEW - set adjusted_synline [lreplace $synline 0 $replaceuntil {*}$resolved_args] ;#don't use join - will destroy braced sets - #however - we don't want the extra bracing around ansi elements caused by list rep! - #::dict filter {dictionaryValue} script {keyVariable valueVariable} {script} - #vs - #::dict filter dictionaryValue script {keyVariable valueVariable} script - #(due to ansi in dictionaryValue and trailing script) - #manually join based on list length review - - set lineout "" - foreach part $adjusted_synline { - if {[llength $part] == 1} { - append lineout " " $part - } else { - append lineout " " [list $part] + #custom @form -synopsis lines are arbitrary display text and need not + #be valid Tcl lists (e.g Tcl manpage style '?-opt {a|b}?' puts a + #braced group hard against a question mark) - list operations would + #raise 'list element in braces followed by ...'. For such lines + #replace the leading command word(s) textually and pass the tail + #through verbatim. + if {[catch {llength $synline}]} { + set tailstart 0 + set count 0 + while {$count <= $replaceuntil && [regexp -indices -start $tailstart {\S+} $synline wordindices]} { + set tailstart [expr {[lindex $wordindices 1]+1}] + incr count + } + set lineout [string trim "[join $resolved_args " "] [string trimleft [string range $synline $tailstart end]]"] + } else { + #treating a somewhat arbitrary string $synline as a list here is a bit risky + #todo - consider always using 'punk::args::synopsis -return dict' and operating on that list to rebuild string - REVIEW + set adjusted_synline [lreplace $synline 0 $replaceuntil {*}$resolved_args] ;#don't use join - will destroy braced sets + #however - we don't want the extra bracing around ansi elements caused by list rep! + #::dict filter {dictionaryValue} script {keyVariable valueVariable} {script} + #vs + #::dict filter dictionaryValue script {keyVariable valueVariable} script + #(due to ansi in dictionaryValue and trailing script) + #manually join based on list length review + + set lineout "" + foreach part $adjusted_synline { + if {[llength $part] == 1} { + append lineout " " $part + } else { + append lineout " " [list $part] + } } + set lineout [string trim $lineout] } - set lineout [string trim $lineout] #G-041: underline the form(s) the supplied trailing words match if {[llength $markforms] && [lindex $docid_forms $formidx] in $markforms} { set lineout "[punk::ansi::a+ underline]$lineout[punk::ansi::a+ nounderline]" diff --git a/src/modules/punk/ns-buildversion.txt b/src/modules/punk/ns-buildversion.txt index 1460a69c..7552625d 100644 --- a/src/modules/punk/ns-buildversion.txt +++ b/src/modules/punk/ns-buildversion.txt @@ -1,6 +1,7 @@ -0.8.0 +0.8.1 #First line must be a semantic version number #all other lines are ignored. +#0.8.1 - synopsis: fixed 'list element in braces followed by "?" instead of space' error when rendering a command whose definition carries a custom @form -synopsis that is not a valid Tcl list (e.g Tcl manpage style '?-form {int|...}?' as ::punk::args::parse formerly used - 's parse' errored). The command-head replacement treated each synopsis line as a Tcl list (lreplace); non-list lines now take a textual fallback that replaces the leading word(s) via index walking and passes the tail through verbatim. Pinned in punk/args synopsis.test (synopsis_form_custom_override_nonlist) since that suite already exercises the punk::ns::synopsis path. #0.8.0 - removed the global command aliases nscommands1 and nscommands2 (user direction 2026-07-14, flagged as removal candidates in the 0.7.0 hygiene pass): earlier pipeline-based iterations superseded by the plain proc nscommands (which adds weird-namespace handling). Neither was documented/exported and no callers existed; nscommands1 was broken - it referenced nonexistent commands (nsthis2, inspect at global scope) and errored on first use. Recoverable via git history. #0.7.1 - hygiene-pass backlog fixes (both pinned before the fix, pins flipped with it): (a) get_nslist's -types default was the literal string $known_types (braced-literal defaults bug) so any call without -types - notably bare user-level 'nslist' - errored with a malformed 'Unrecognised namespace member type: $known_types'; an unsupplied -types now defaults to all known member types (nslist.test nslist_types_default; nslist's own PUNKARGS -types gained help text and deliberately no -default so the passthrough stays absent when unsupplied). (b) nsjoinall's non-empty-prefix-vs-absolute-path error message carried a copy-paste 'nsjoin:' prefix - now 'nsjoinall:' (nsprimitives.test nsjoinall_edges). Also commented out a development trace puts ('>>> base: ...') that polluted nscommands stdout on the weird-namespace path (same treatment as the 0.1.1 trace-puts cleanup). #0.7.0 - comment/doc hygiene pass (no change to shipped behaviour; minor bump for removed unused procs). Dead code removed (recoverable via git history): the fully commented-out predecessor help command 'arginfo' (~930 lines, replaced by cmdhelp), the superseded/deprecated name-primitive twins nsparts1/nsprefix1/nsprefix_orig/nstail1/nstail_orig (never exported, no callers; NOT drop-in equivalents - they collapse colon runs of 5+ differently, divergence pinned in tests ns/nsprimitives.test git history), obsolete nsglob_as_re1, empty stubs ns_relative_to_location/ns_absolute_to_location, obsolete internal::_pkguse_vars, an 'if 0' block in nstree_list, unused interp_aliases assignment in get_ns_dicts, and assorted commented-out code remnants. Stale docs corrected: nsprefix comment (:::a prefix is :: not ::a), nsglob_as_re 'should be fixed' note (fix has long been in place - * matches an inner single colon), cmdhelp typos, corp -syntax truncated help sentence, nstree_list empty PUNKARGS help fields filled. Flagged (comment only): get_nslist red-strike/masked alias display branches appear unreachable (see nslist.test fixture findings), nscommands1/nscommands2 pipeline aliases are superseded removal candidates (nscommands1 errors on use - references nonexistent nsthis2), cmdtrace stdout/stderr noise deferred to proposed G-085, test_switch* marked as live cmdtrace test fixtures. All inserted/updated comments carry 2026-07-14 Agent marks. diff --git a/src/tests/modules/punk/args/testsuites/args/examples.test b/src/tests/modules/punk/args/testsuites/args/examples.test index 25d88573..1d6c9c35 100644 --- a/src/tests/modules/punk/args/testsuites/args/examples.test +++ b/src/tests/modules/punk/args/testsuites/args/examples.test @@ -134,6 +134,34 @@ namespace eval ::testspace { }\ -result [list "keep1|keep2"] + #added 2026-07-17: punk::args::eg formerly required a fully-qualified id - callers in a + #namespace where it shadows the global 'eg' alias (e.g whilst in ::punk::args) had to + #type 'eg ::list' while 'i list' resolved fine. It now resolves relative names from the + #calling context (punk::ns::cmdinfo when punk::ns is loaded, else global qualification). + test eg_relative_name_resolution {punk::args::eg resolves a relative/unqualified command name}\ + -setup $common -body { + package require punk::args::moduledoc::tclcore + set qualified [punk::args::eg ::lseq] + lappend result [expr {[string length $qualified] > 100}] + lappend result [expr {[punk::args::eg lseq] eq $qualified}] + #from another namespace - still resolves (this is the shadowing scenario) + namespace eval ::testspace::somewhere { + lappend ::testspace::result [expr {[punk::args::eg lseq] eq [punk::args::eg ::lseq]}] + } + set result + }\ + -cleanup { + catch {namespace delete ::testspace::somewhere} + }\ + -result [list 1 1 1] + + #added 2026-07-17 + test eg_unknown_id_message {punk::args::eg names the id in its no-definition message instead of returning empty}\ + -setup $common -body { + lappend result [punk::args::eg ::testspace::egnonexistent] + }\ + -result [list "no punk::args definition found for ::testspace::egnonexistent"] + test tclcore_lseq_examples_live {the tclcore moduledoc's ::lseq definition (a heavy example consumer) resolves, renders, and serves 'eg' content}\ -setup $common -body { package require punk::args::moduledoc::tclcore diff --git a/src/tests/modules/punk/args/testsuites/args/parsecache.test b/src/tests/modules/punk/args/testsuites/args/parsecache.test new file mode 100644 index 00000000..a1f9e470 --- /dev/null +++ b/src/tests/modules/punk/args/testsuites/args/parsecache.test @@ -0,0 +1,188 @@ +#parsecache.test - behaviour of punk::args::parse -cache 1 and the punk::args::parse_cache +#inspection utility (punk::args 0.12.5 cache overhaul). +#Assertions filter cache keys/entries by the test definition's id substring rather than +#asserting global counts - internal punk::args machinery (e.g. synopsis rendering during +#error display) may legitimately add its own -cache 1 entries during a test body. +package require tcltest + +namespace eval ::testspace { + namespace import ::tcltest::* + variable common { + set result "" + punk::args::parse_cache -action clear + } + + #return {key value ...} for parse_cache entries whose key contains idglob, + #optionally restricted to entries of type 'result' or 'error'. + #NOTE: internal punk::args machinery (e.g. usage-table rendering for standard/enhanced + #errorstyles) makes its own -cache 1 parses whose keys can contain the definition text + #of the id under test - filter by entry type to isolate the entries a test created. + proc pc_entries {idglob {type ""}} { + set matched {} + dict for {k v} [punk::args::parse_cache -return dict] { + if {[string match *${idglob}* $k]} { + if {$type eq "" || [dict get $v type] eq $type} { + lappend matched $k $v + } + } + } + return $matched + } + + test parsecache_result_hit {Ensure -cache 1 stores a result entry and the cached path returns an identical result}\ + -setup $common -body { + punk::args::define { + @id -id ::testspace::pc_res + @opts -anyopts 0 + -x -default 0 -type integer + } + set r1 [punk::args::parse {-x 5} -cache 1 withid ::testspace::pc_res] + set r2 [punk::args::parse {-x 5} -cache 1 withid ::testspace::pc_res] + set entries [pc_entries pc_res result] + list [expr {$r1 eq $r2}] [llength $entries] [dict get [lindex $entries 1] type] + }\ + -cleanup { + punk::args::undefine ::testspace::pc_res + punk::args::parse_cache -action clear + }\ + -result {1 2 result} + + test parsecache_cache0_no_write {Ensure failing parses without -cache add no cache entries (write is gated on -cache)}\ + -setup $common -body { + punk::args::define { + @id -id ::testspace::pc_c0 + @opts -anyopts 0 + -x -default 0 -type integer + } + catch {punk::args::parse {-x notint1} withid ::testspace::pc_c0} + catch {punk::args::parse {-x notint2} -errorstyle minimal withid ::testspace::pc_c0} + #internal rendering machinery may add its own 'result' entries mentioning this + #definition - our uncached failing parses must not have added 'error' entries, + #and no entry may be keyed by our failing arglists. + set leaks 0 + foreach k [punk::args::parse_cache -return keys] { + if {[lindex $k 0] in {{-x notint1} {-x notint2}}} { + incr leaks + } + } + list [llength [pc_entries pc_c0 error]] $leaks + }\ + -cleanup { + punk::args::undefine ::testspace::pc_c0 + punk::args::parse_cache -action clear + }\ + -result {0 0} + + test parsecache_error_variant_replay {Ensure an identical repeated failing -cache 1 call replays its cached render (single variant, identical message)}\ + -setup $common -body { + punk::args::define { + @id -id ::testspace::pc_replay + @opts -anyopts 0 + -x -default 0 -type integer + } + catch {punk::args::parse {-x notint} -cache 1 -errorstyle minimal withid ::testspace::pc_replay} m1 + catch {punk::args::parse {-x notint} -cache 1 -errorstyle minimal withid ::testspace::pc_replay} m2 + set entries [pc_entries pc_replay error] + set entry [lindex $entries 1] + list [expr {$m1 eq $m2}] [llength $entries] [dict get $entry type] [dict size [dict get $entry value]] + }\ + -cleanup { + punk::args::undefine ::testspace::pc_replay + punk::args::parse_cache -action clear + }\ + -result {1 2 error 1} + + test parsecache_errorstyle_variants {Ensure a cached error render is not replayed to a call wanting a different -errorstyle}\ + -setup $common -body { + punk::args::define { + @id -id ::testspace::pc_style + @opts -anyopts 0 + -x -default 0 -type integer + } + catch {punk::args::parse {-x notint} -cache 1 -errorstyle minimal -caller CST withid ::testspace::pc_style} m_min + catch {punk::args::parse {-x notint} -cache 1 -errorstyle standard -caller CST withid ::testspace::pc_style} m_std + set entry [lindex [pc_entries pc_style error] 1] + list [expr {[string length $m_std] > [string length $m_min]}] [dict size [dict get $entry value]] + }\ + -cleanup { + punk::args::undefine ::testspace::pc_style + punk::args::parse_cache -action clear + }\ + -result {1 2} + + test parsecache_caller_variants {Ensure cached error attribution follows each call's -caller rather than replaying the first caller's render}\ + -setup $common -body { + punk::args::define { + @id -id ::testspace::pc_caller + @opts -anyopts 0 + -x -default 0 -type integer + } + catch {punk::args::parse {-x notint} -cache 1 -errorstyle minimal -caller ONECMD withid ::testspace::pc_caller} m1 + catch {punk::args::parse {-x notint} -cache 1 -errorstyle minimal -caller TWOCMD withid ::testspace::pc_caller} m2 + set entry [lindex [pc_entries pc_caller error] 1] + list [string match *ONECMD* $m1] [string match *TWOCMD* $m2] [string match *ONECMD* $m2] [dict size [dict get $entry value]] + }\ + -cleanup { + punk::args::undefine ::testspace::pc_caller + punk::args::parse_cache -action clear + }\ + -result {1 1 0 2} + + test parsecache_dynamic_bypass {Ensure @dynamic definitions are never cached by parse -cache 1}\ + -setup $common -body { + variable pc_dyn_list {A B C} + proc ::testspace::pc_dyn_get {} { + variable pc_dyn_list + return $pc_dyn_list + } + namespace eval dynwhatever { + set DYN_LIST {${[::testspace::pc_dyn_get]}} + punk::args::define { + @dynamic + @id -id ::testspace::pc_dyn + @values + param -type list -default "${$DYN_LIST}" + } + } + set argd [punk::args::parse {} -cache 1 withid ::testspace::pc_dyn] + lappend result [dict get $argd values param] + set ::testspace::pc_dyn_list {X Y Z} + set argd [punk::args::parse {} -cache 1 withid ::testspace::pc_dyn] + lappend result [dict get $argd values param] + list $result [llength [pc_entries pc_dyn]] + }\ + -cleanup { + namespace delete ::testspace::dynwhatever + rename ::testspace::pc_dyn_get {} + punk::args::undefine ::testspace::pc_dyn + punk::args::parse_cache -action clear + }\ + -result [list [list {A B C} {X Y Z}] 0] + + test parsecache_utility {Ensure parse_cache summary/keys/dict/clear views behave}\ + -setup $common -body { + punk::args::define { + @id -id ::testspace::pc_util + @opts -anyopts 0 + -x -default 0 -type integer + } + punk::args::parse {-x 1} -cache 1 withid ::testspace::pc_util + catch {punk::args::parse {-x notint} -cache 1 -errorstyle minimal -caller CST withid ::testspace::pc_util} + set s [punk::args::parse_cache] + lappend result [expr {[dict get $s entries] >= 2}] + lappend result [expr {[dict get $s results] >= 1}] + lappend result [expr {[dict get $s errors] >= 1}] + lappend result [expr {[dict get $s error_variants] >= 1}] + lappend result [expr {[dict get $s chars] > 0}] + lappend result [expr {[llength [punk::args::parse_cache -return keys]] == [dict get $s entries]}] + lappend result [punk::args::parse_cache -action clear] + set s2 [punk::args::parse_cache] + lappend result [dict get $s2 entries] + }\ + -cleanup { + punk::args::undefine ::testspace::pc_util + punk::args::parse_cache -action clear + }\ + -result {1 1 1 1 1 1 {parse_cache cleared} 0} +} +tcltest::cleanupTests ;#needed to produce test summary line. diff --git a/src/tests/modules/punk/args/testsuites/args/synopsis.test b/src/tests/modules/punk/args/testsuites/args/synopsis.test index 9c52ddf6..0ce25abd 100644 --- a/src/tests/modules/punk/args/testsuites/args/synopsis.test +++ b/src/tests/modules/punk/args/testsuites/args/synopsis.test @@ -358,6 +358,72 @@ namespace eval ::testspace { -result [list\ "# summary\n::testspace::testns::t1 \[-t FOO\]" ] + + #added 2026-07-17: custom @form -synopsis strings are arbitrary display text and need + #not be valid Tcl lists (e.g Tcl manpage style '?-a {b|c}?' places a braced group hard + #against a question mark). punk::ns::synopsis formerly raised + #'list element in braces followed by "?" instead of space' on such lines (its command + #head replacement used list operations) - it now falls back to textual head replacement. + test synopsis_form_custom_override_nonlist {a custom @form -synopsis that is not a valid Tcl list passes through punk::args::synopsis verbatim and survives punk::ns::synopsis head replacement}\ + -setup $common -body { + namespace eval testns { + punk::args::define { + @id -id ::testspace::testns::tcust + @cmd -summary "custom synopsis test" + @form -form {manual auto} + @leaders + a1 -optional 0 + @form -form manual -synopsis "tcust a1 ?-a {b|c}? thing" + @values -min 0 -max 0 + @form -form auto + @values + v1 -optional 1 + } + } + #direct render includes the custom line verbatim + set direct [punk::args::synopsis ::testspace::testns::tcust] + lappend result [expr {[string first "tcust a1 ?-a {b|c}? thing" $direct] >= 0}] + #the ns-level render (the shell 's' path) must not error on the non-list line, + #and substitutes the command head as typed + set nsout [punk::ns::synopsis ::testspace::testns::tcust] + set lines {} + foreach ln [split $nsout \n] { + if {[string match "#*" $ln]} {continue} + lappend lines $ln + } + lappend result [lindex $lines 0] + }\ + -cleanup { + namespace delete ::testspace::testns + }\ + -result [list\ + 1\ + "::testspace::testns::tcust a1 ?-a {b|c}? thing" + ] + + #added 2026-07-17: ::punk::args::parse dropped its @form -synopsis overrides (which had + #gone stale - they omitted -caller and -cache) in favour of the auto-generated line + #shaped with -typesynopsis on -form. This pin catches future drift: a new parse option + #must appear here (that's the point - the auto-synopsis stays current). + test synopsis_parse_generated {::punk::args::parse withid synopsis is auto-generated and lists all current options}\ + -setup $common -body { + set syntext [punk::args::synopsis -form withid ::punk::args::parse] + set lines {} + foreach ln [split [punk::ansi::ansistrip $syntext] \n] { + if {[string match "#*" $ln]} {continue} + lappend lines $ln + } + lindex $lines 0 + }\ + -result {::punk::args::parse arglist [-form (...)] [-errorstyle ] [-caller ] [-cache ] withid id} + + #added 2026-07-17 + test synopsis_examples_block {the @examples block of punk::args::synopsis compares Tcl man-page style with punk::args bracket style}\ + -setup $common -body { + set eg [punk::args::eg ::punk::args::synopsis] + list [string match "*Tcl style:*" $eg] [string match "*punk::args:*" $eg] + }\ + -result {1 1} } tcltest::cleanupTests ;#needed to produce test summary line. diff --git a/src/tests/modules/punk/args/testsuites/dev/dynamic-cache.test b/src/tests/modules/punk/args/testsuites/dev/dynamic-cache.test index 503f0240..a8f99604 100644 --- a/src/tests/modules/punk/args/testsuites/dev/dynamic-cache.test +++ b/src/tests/modules/punk/args/testsuites/dev/dynamic-cache.test @@ -8,8 +8,9 @@ namespace eval ::testspace { set result "" } - test define_dynamic_cache_refreshes_resolved_defaults {Known bug: @dynamic defaults should refresh when parsed with cache}\ - -constraints punkargsKnownBug\ + #pin flipped 2026-07-17: punk::args 0.12.5 bypasses the parse -cache for @dynamic + #definitions, so re-evaluated substitutions always take effect. + test define_dynamic_cache_refreshes_resolved_defaults {@dynamic defaults refresh when parsed with -cache 1 (cache bypassed for dynamic definitions)}\ -setup $common -body { variable test_list set test_list {A B C}