diff --git a/CHANGELOG.md b/CHANGELOG.md index 053d5b79..67e1f95c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.4.10] - 2026-07-08 + +- G-040 completed (activated and flipped to achieved same day, user-directed): punk::args 0.3.0 adds `-choicealiases {alias canonical ...}` — aliases accepted exact under any `-choiceprefix`/`-nocase` setting, participating in prefix calculation when prefixing is on, always normalizing to the canonical choice in parse results; deny semantics apply to the matched name (a denied alias needs the full alias; a canonical reached via its alias is exempt from the canonical's own deny entry); usage display folds aliases into the canonical entry as an `(alias: …)` label note. The choice-word matching chain was extracted from `get_dict` into the shared resolver `punk::args::choiceword_match`, now also consumed by punk::ns 0.1.2's `cmd_traverse` — closing the doc-lookup parity gap (deny/reserve lists were honoured by parse but ignored by the walk; the pinned GAP tests flipped to agreement) and normalizing aliases before choiceinfo lookup. +- punk 0.2.3: punk::help adopts the feature — `i help` lists one entry per registered topic (`topics` +alias help, `tcl`, `env` +alias environment, `console` +aliases term|terminal), and topic words accept aliases and unique prefixes with the user-decided minimum-prefix policy: `h`/`he`/`hel`, `e`/`en`, `te`/`ter`, and the reserved `c`/`to`/`tc` all fall through to command lookup. Argless `help` output remains byte-identical to the strict-80 baseline. +- Suites: punk/args 98 pass + 1 skip (5 new alias tests incl. the punk::help policy matrix), punk/ns 28 pass (parity tests flipped, alias traverse + display-fold assertions), punk/lib 21 pass. + ## [0.4.9] - 2026-07-08 - punk 0.2.2 (doc-only, from interactive review of the help restructure): `i help` / `i help ` usage tables narrowed to reasonable widths (61-68 columns, previously ~160) by manually folding the helptopic argdoc help texts at ~70 columns and rendering the generated topic-choice grid with `-choicecolumns 2`. Only the argless `help` overview is strictly 80-column (unchanged, byte-identical); usage tables do not yet wrap to terminal width, so punk::args source line lengths directly set table width — automated (potentially language-dependent) wrapping, e.g. via tcllib's text-adjust facilities, is a possible future improvement. diff --git a/GOALS.md b/GOALS.md index c9d0ec4b..d52a254b 100644 --- a/GOALS.md +++ b/GOALS.md @@ -285,7 +285,7 @@ Detail: goals/G-039-orphan-console-spin.md Goal: the observed failure mode - an interactive punk902z left running after its hosting terminal/console went away spins roughly a full core indefinitely (observed 2026-07-08: a 37-minute orphan with a single hard-looping thread) - is reliably reproduced and root-caused, then fixed or mitigated so a shell whose console dies exits or reaches zero-CPU idle cleanly. Acceptance: a documented procedure reproduces the spin on the current kit (e.g. launch an interactive shell in a terminal, then kill/close the hosting terminal or conhost), or the investigation records the attempts made and what evidence would reopen it; the spinning code path is identified (prime suspect: a console read/event loop treating a dead console's immediate EOF/error as retryable without backoff or termination - adjacent to the console-EOF restart path G-038 takes ownership of); after fix/mitigation, the same procedure shows the orphaned process exiting or settling at effectively zero CPU within a short grace period, with live-console interactive behaviour unchanged; the wedge-scoring hazard note (orphans polluting process-liveness checks in test harnesses) is updated to match the outcome. -### G-040 [proposed] punk::args choice aliasing (-choicealiases) with parse normalization, display folding, and doc-lookup parity +### G-040 [achieved 2026-07-08] punk::args choice aliasing (-choicealiases) with parse normalization, display folding, and doc-lookup parity Scope: src/modules/punk/args-999999.0a1.0.tm (parse + usage rendering), src/modules/punk/ns-999999.0a1.0.tm (cmdinfo/cmd_traverse choice resolution parity), src/modules/punk-999999.0a1.0.tm (punk::help topic argdoc as first consumer), src/tests/modules/punk/args/testsuites/, src/tests/modules/punk/ns/testsuites/ Detail: goals/G-040-punkargs-choicealiases.md Goal: punk::args supports choice aliases (-choicealiases {alias canonical ...}) accepted at parse and normalized to the canonical choice in results, folded into the canonical entry in usage display - and the punk::ns doc-lookup walk resolves choice words by the same rules as the parser (aliases, -choiceprefix, -choiceprefixdenylist, -choiceprefixreservelist) - so alias sets like punk::help's topics|help and console|term|terminal collapse to one displayed entry per topic with `help X` and `i help X` agreeing. diff --git a/goals/G-040-punkargs-choicealiases.md b/goals/G-040-punkargs-choicealiases.md index 2dfbaf23..49e13ac5 100644 --- a/goals/G-040-punkargs-choicealiases.md +++ b/goals/G-040-punkargs-choicealiases.md @@ -1,6 +1,6 @@ # G-040 punk::args choice aliasing (-choicealiases) with parse normalization, display folding, and doc-lookup parity -Status: proposed +Status: achieved 2026-07-08 Scope: src/modules/punk/args-999999.0a1.0.tm (parse + usage rendering), src/modules/punk/ns-999999.0a1.0.tm (cmdinfo/cmd_traverse choice resolution parity), src/modules/punk-999999.0a1.0.tm (punk::help topic argdoc as first consumer), src/tests/modules/punk/args/testsuites/, src/tests/modules/punk/ns/testsuites/ Acceptance: a definition using -choicealiases parses an alias (and an alias prefix where -choiceprefix allows) to its canonical choice in the parse result, with -choicerestricted 0 passthrough and the deny/reserve lists honoured unchanged; usage display shows one entry per canonical choice with aliases folded (no duplicate rows; -choicelabels attach to the canonical); punk::ns::cmdinfo/cmd_traverse resolve subcommand words to docids with the same outcome as the parser for alias, prefix, denied, reserved and unknown words (the pre-goal characterization tests updated from pinned-GAP to fixed); punk::help's topic definition adopts the feature so `i help` lists one entry per registered topic while `help h`/`help e` still fall through to command lookup; definitions without -choicealiases behave unchanged (existing punk::args and punk::ns suites pass). @@ -75,6 +75,39 @@ keys stay canonical-only. the parse-vs-traverse divergence pinned as a GAP test (fossilmove pattern). This goal flips those GAP pins to the fixed behaviour. +## Implementation (2026-07-08 - acceptance met, goal achieved) + +- punk::args 0.3.0: `-choicealiases {alias canonical ...}` accepted on argument lines + (dict-shape validated in the option switch; alias->existing-choice and no-collision + cross-validated after spec merge - surfacing at definition resolve time, since define + is lazy). The choice-word matching chain was extracted from get_dict into the shared + resolver `punk::args::choiceword_match {word nocase allchoices choicealiases + choiceprefix denylist reservelist}` -> dict {matched exact canonical}; get_dict now + delegates to it (the writeback/lset logic stays at the call site). Exact aliases match + under any -choiceprefix setting and under -nocase; alias names join the prefix + calculation pool when -choiceprefix is true; deny applies to the matched NAME (an + alias in the denylist requires the full alias; a canonical reached via its alias is + exempt from the canonical's own deny entry). Display: aliases are folded into the + canonical entry as an '(alias: x)' / '(aliases: x|y)' note on its label (single + convergence point covers all render paths), and alias names join the display prefix + calculation so highlighted minimal prefixes stay truthful. +- punk::ns 0.1.2: cmd_traverse's subcommand walk resolves choice words via the same + choiceword_match call - the two parity GAP tests flipped to agreement (denied prefix + and reserved word now resolve for NEITHER parse nor doc walk; previously the walk + accepted both), choiceinfo lookups key on the normalized canonical, and -nocase is now + honoured in the walk. +- punk::help (punk 0.2.3): registry aliases map onto -choicealiases; choices are the four + canonical topics; minimum-prefix policy per user decision 2026-07-08 recorded as + namespace variables: denylist {help} (h/he/hel stay command words), reservelist + {c to tc} (fall through to command lookup; e/en and te/ter fall through naturally as + ambiguous). 'i help' lists one entry per registered topic with aliases folded. + +Verification: punk/args suite 98 pass + 1 skip (5 alias tests incl. the full punk::help +policy matrix, noprefix/nocase modes, resolve-time validation), punk/ns 28 pass (parity +tests flipped + alias traverse + display-fold assertion), punk/lib 21; argless 'help' +output byte-identical to the original strict-80 baseline; 'i help environment'/'i help +term'/'i help co' all render the canonical topic's documented usage. + ## Alternatives considered - Prefix mechanics alone (no new feature) - covers env|environment and term|terminal but diff --git a/punkproject.toml b/punkproject.toml index c6687e3c..07bec260 100644 --- a/punkproject.toml +++ b/punkproject.toml @@ -1,3 +1,3 @@ [project] name = "punkshell" -version = "0.4.9" +version = "0.4.10" diff --git a/src/modules/punk-999999.0a1.0.tm b/src/modules/punk-999999.0a1.0.tm index d78368ef..3d6a77c1 100644 --- a/src/modules/punk-999999.0a1.0.tm +++ b/src/modules/punk-999999.0a1.0.tm @@ -8464,16 +8464,32 @@ namespace eval punk { #(re)generate the punk::args definitions for ::punk::help and ::punk::help_chunks from the registry #so that 'i help' shows a documented topic table matching the registered topics. + #minimum-prefix policy for topic words (user decision 2026-07-08): + # - prefixes of the 'help' alias are denied: h/he/hel stay available as command + # words ('help' itself must be typed in full) + # - c, to and tc are reserved phantoms so they fall through to command lookup + # (co.../top.../full 'tcl' still match; e/en and te/ter fall through naturally + # as ambiguous prefixes of env|environment and term|terminal) + variable topic_prefixdenylist {help} + variable topic_prefixreservelist {c to tc} + proc define_docs {} { variable topics + variable topic_prefixdenylist + variable topic_prefixreservelist + #G-040: canonical topic names are the choices; registry aliases become + #-choicealiases (accepted + normalized at parse, folded into the canonical + #entry in usage display). choiceinfo/choicelabels key on canonicals only. set choices [list] + set choicealiases [dict create] set choiceinfo [dict create] set choicelabels [dict create] dict for {topic tinfo} $topics { - foreach name [list $topic {*}[dict get $tinfo aliases]] { - lappend choices $name - dict set choiceinfo $name [list [list doctype punkargs] [list subhelp [namespace current]::$topic]] - dict set choicelabels $name [dict get $tinfo summary] + lappend choices $topic + dict set choiceinfo $topic [list [list doctype punkargs] [list subhelp [namespace current]::$topic]] + dict set choicelabels $topic [dict get $tinfo summary] + foreach al [dict get $tinfo aliases] { + dict set choicealiases $al $topic } } #keep help-text lines manually folded (~70 cols) - the usage tables don't yet @@ -8488,7 +8504,7 @@ namespace eval punk { 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.\n(topics must match exactly - no prefix matching)" + 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)." 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." @@ -8497,7 +8513,7 @@ namespace eval punk { append def "@id -id $id" \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 0 -choicerestricted 0 -choicecolumns 2 -choices {$choices} -choiceinfo {$choiceinfo} -choicelabels {$choicelabels} -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 a0f9dc0a..f6928306 100644 --- a/src/modules/punk-buildversion.txt +++ b/src/modules/punk-buildversion.txt @@ -1,6 +1,7 @@ -0.2.2 +0.2.3 #First line must be a semantic version number #all other lines are ignored. +#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) #0.2.1 - 'help tcl' warning scan extended to the has_libbug_* check family (bundled/vendored library bugs, e.g. the G-036 tcludp detection) alongside has_tclbug_*; buginfo 'url' key supported for reference links to non tcl-core trackers; fixed latent unset-indent error when a triggered check had a bugref/url but no description #0.2.0 - help system restructured onto a topic registry (::punk::helptopic: register/resolve, per-topic handler procs each with a punk::args definition); ::punk::help and ::punk::help_chunks punk::args definitions (re)generated from the registry so 'i help' / 'i help ' render documented usage; 'help topics' derived from the registry; command-fallthrough and no-arg overview output unchanged; 'help env' degrades cleanly when punk::config is not initialised; help table objects destroyed after printing (leak fix) diff --git a/src/modules/punk/args-999999.0a1.0.tm b/src/modules/punk/args-999999.0a1.0.tm index 9d9e708c..8eab7477 100644 --- a/src/modules/punk/args-999999.0a1.0.tm +++ b/src/modules/punk/args-999999.0a1.0.tm @@ -911,6 +911,25 @@ tcl::namespace::eval punk::args { env, envi, ... environment all normalize to environment. (To require a longer minimum, reserve each shorter form: reserving {en env} raises the minimum to envi.) + -choicealiases {} + Dictionary mapping alias names to canonical choice names. + An alias is accepted wherever its canonical choice is: an + exact alias match works regardless of -choiceprefix, and + when -choiceprefix is true alias names participate in + prefix calculation so a unique prefix of an alias also + matches. A matched alias is normalized: the parse result + contains the canonical choice, never the alias. + Aliases are not displayed as separate choice entries - + usage display folds them into the canonical entry as an + '(alias: name)' note on its label. -choicelabels keys + should be canonical names only. + An alias listed in -choiceprefixdenylist must be typed + in full (denial applies to the alias name; a canonical + reached via its alias is not subject to the canonical's + own denylist entry). + Each alias must map to an existing choice, and must not + itself collide with a choice (validated when the + definition is resolved). -choicegroups {} Generally this would be used instead of -choices to allow usage display of choices grouped by some name (or the empty @@ -2642,6 +2661,17 @@ tcl::namespace::eval punk::args { } tcl::dict::set spec_merged -mash $specval } + -choicealiases { + #dict of alias -> canonical choice (goal G-040). + #Aliases are accepted at parse (exact, and as prefix-calculation members when + #-choiceprefix applies) and normalize to their canonical choice in the parse + #result; usage display folds them into the canonical entry. + #Cross-validation against the final choice set happens after all specs merge. + if {![punk::args::lib::string_is_dict $specval]} { + error "punk::args::resolve - invalid value for key '$spec' in specifications for argument '$argname' - value must be a dictionary of alias -> canonical-choice @id:$DEF_definition_id" + } + tcl::dict::set spec_merged $spec $specval + } -unindentedfields - -solo - -choices - -choicegroups - -choicemultiple - -choicecolumns - @@ -2761,7 +2791,7 @@ tcl::namespace::eval punk::args { -minsize -maxsize -choices -choicegroups -mincap -maxcap -choicemultiple -choicecolumns -choiceprefix -choiceprefixdenylist -choiceprefixreservelist -choicerestricted - -choicelabels -choiceinfo + -choicelabels -choiceinfo -choicealiases -unindentedfields -nocase -optional -multiple -validate_ansistripped -allow_ansi -strip_ansi -help -multipleunique -choicemultipleunique -choicemultipleuniqueset @@ -2774,6 +2804,21 @@ tcl::namespace::eval punk::args { } } ;# end foreach {spec specval} argdef_values + #cross-validate -choicealiases against the final merged choice set (goal G-040) + if {[tcl::dict::exists $spec_merged -choicealiases] && [tcl::dict::size [tcl::dict::get $spec_merged -choicealiases]]} { + set ca_allchoices [punk::args::system::Dict_getdef $spec_merged -choices {}] + foreach {_cagroup camembers} [punk::args::system::Dict_getdef $spec_merged -choicegroups {}] { + lappend ca_allchoices {*}$camembers + } + tcl::dict::for {ca_alias ca_canonical} [tcl::dict::get $spec_merged -choicealiases] { + if {$ca_canonical ni $ca_allchoices} { + error "punk::args::resolve - -choicealiases for argument '$argname' maps alias '$ca_alias' to '$ca_canonical' which is not in -choices/-choicegroups @id:$DEF_definition_id" + } + if {$ca_alias in $ca_allchoices} { + error "punk::args::resolve - -choicealiases for argument '$argname' alias '$ca_alias' collides with a defined choice @id:$DEF_definition_id" + } + } + } if {$is_opt} { #tcl::dict::set FDICT ARG_CHECKS $argname {*}{ @@ -4811,12 +4856,15 @@ tcl::namespace::eval punk::args { if {$has_choices} { if {$help ne ""} {append help \n} + #G-040: alias names participate in prefix calculation (as at parse time) + set choicealiases_display [Dict_getdef $arginfo -choicealiases {}] + set choicealias_names [dict keys $choicealiases_display] if {[dict get $arginfo -nocase]} { set casemsg " (case insensitive)" - set allchoices_prefixcalc [list {*}[string tolower $allchoices_originalcase] {*}$choiceprefixreservelist] + set allchoices_prefixcalc [list {*}[string tolower $allchoices_originalcase] {*}[string tolower $choicealias_names] {*}$choiceprefixreservelist] } else { set casemsg " (case sensitive)" - set allchoices_prefixcalc [list {*}$allchoices_originalcase {*}$choiceprefixreservelist] + set allchoices_prefixcalc [list {*}$allchoices_originalcase {*}$choicealias_names {*}$choiceprefixreservelist] } if {[dict get $arginfo -choiceprefix]} { set prefixmsg " (choice prefix allowed)" @@ -4825,6 +4873,22 @@ tcl::namespace::eval punk::args { } set choicelabeldict [Dict_getdef $arginfo -choicelabels {}] set choiceinfodict [Dict_getdef $arginfo -choiceinfo {}] + #G-040: aliases are not displayed as separate choice entries - fold each + #canonical's alias names into its label so every display path shows them once + if {[dict size $choicealiases_display]} { + set aliasesbycanonical [dict create] + dict for {ca_al ca_cn} $choicealiases_display { + dict lappend aliasesbycanonical $ca_cn $ca_al + } + dict for {ca_cn ca_als} $aliasesbycanonical { + set aliasnote "(alias[expr {[llength $ca_als] > 1 ? {es} : {}}]: [join $ca_als |])" + if {[dict exists $choicelabeldict $ca_cn]} { + dict set choicelabeldict $ca_cn "$aliasnote\n[dict get $choicelabeldict $ca_cn]" + } else { + dict set choicelabeldict $ca_cn $aliasnote + } + } + } set formattedchoices [dict create] ;#use dict rather than array to preserve order if {$help eq ""} { #first line of help - no included base of 4 indent is the normal state of first help lines in definitions scripts @@ -8074,6 +8138,155 @@ tcl::namespace::eval punk::args { #rename get_dict # + #G-040: the single implementation of choice-word matching - shared by argument parsing + #(get_dict) and the punk::ns doc-lookup walk (cmd_traverse), so 'i ' + #resolution can never diverge from what parsing accepts. + #Arguments: + # word - the supplied word (callers pass the check-form, e.g. ansistripped when applicable) + # nocase - value of -nocase for the argument + # allchoices - flat list of -choices plus all -choicegroups members (dups allowed) + # choicealiases - -choicealiases dict (alias -> canonical choice) + # choiceprefix - value of -choiceprefix + # denylist - -choiceprefixdenylist (full word required for these names) + # reservelist - -choiceprefixreservelist (phantom prefix-calculation members) + #Returns dict: + # matched - boolean: word identifies a choice (directly, by unique prefix, or via an alias) + # exact - boolean: the raw stored word already equals the resulting choice (no rewrite needed) + # canonical - the resulting choice value (empty when not matched) + proc choiceword_match {word nocase allchoices choicealiases choiceprefix denylist reservelist} { + set aliasnames [tcl::dict::keys $choicealiases] + set has_choicealiases [expr {[llength $aliasnames] > 0}] + set choicealiases_nocase [tcl::dict::create] + if {$has_choicealiases} { + tcl::dict::for {ca_al ca_cn} $choicealiases { + tcl::dict::set choicealiases_nocase [tcl::string::tolower $ca_al] $ca_cn + } + } + if {$nocase} { + set choices_test [tcl::string::tolower $allchoices] + set v_test [tcl::string::tolower $word] + } else { + set choices_test $allchoices + set v_test $word + } + set chosen "" + set choice_in_list 0 + set choice_exact_match 0 + if {$choiceprefix} { + #can we handle empty string as a choice? It should just work - REVIEW/test + if {$word in $allchoices} { + #for case when there are case-differenced duplicates - allow exact match to avoid selecting earlier match of another casing + set chosen $word + set choice_in_list 1 + set choice_exact_match 1 + } elseif {$has_choicealiases && [tcl::dict::exists $choicealiases $word]} { + #exact alias match - normalize to the canonical choice + #(deliberately not 'exact' - the stored raw value must be overwritten with the canonical) + set chosen [tcl::dict::get $choicealiases $word] + set choice_in_list 1 + } elseif {$nocase && $v_test in $choices_test} { + #full-length match except for case + #select the case from the choice list - not the supplied value + foreach avail [lsort -unique $allchoices] { + if {[tcl::string::match -nocase $word $avail]} { + set chosen $avail + } + } + #assert chosen will always get set + set choice_in_list 1 + } elseif {$has_choicealiases && $nocase && [tcl::dict::exists $choicealiases_nocase $v_test]} { + #exact alias match differing only by case - normalize to the canonical choice + set chosen [tcl::dict::get $choicealiases_nocase $v_test] + set choice_in_list 1 + } else { + #PREFIX check required - any match here is not an exact match or it would have matched above. + #in this block we can treat empty result from prefix match as a non-match + set prefix_via_alias 0 ;#set when the prefix match landed on an alias name (deny already applied to the alias) + if {$nocase} { + #nocase prefixing with case-dups: see the -choiceprefixdenylist nocase notes at the original + #get_dict site - counterintuitive DEL/delete/Delete edge cases are documented feature-not-bug + set bestmatch [tcl::prefix::match -error "" [list {*}[lsort -unique $allchoices] {*}$aliasnames {*}$reservelist] $word] + if {$bestmatch eq "" || $bestmatch in $reservelist} { + set chosen [tcl::prefix::match -error "" [list {*}[lsort -unique $choices_test] {*}[tcl::dict::keys $choicealiases_nocase] {*}$reservelist] $v_test] + if {$chosen ne "" && [tcl::dict::exists $choicealiases_nocase $chosen]} { + #matched an alias (lowercased) - deny applies to the alias name, then normalize + if {[lsearch -nocase $denylist $chosen] >= 0} { + set chosen "" + } else { + set chosen [tcl::dict::get $choicealiases_nocase $chosen] + set prefix_via_alias 1 + } + set choice_in_list [expr {$chosen ne ""}] + } else { + #now pick the earliest match in the actually defined list so that case of chosen always matches a defined entry with casing + set chosen [lsearch -inline -nocase $allchoices $chosen] + set choice_in_list [expr {$chosen ne ""}] + } + } else { + if {$bestmatch in $aliasnames} { + #prefix landed on an alias - deny applies to the alias name, then normalize + if {$bestmatch in $denylist} { + set chosen "" + set choice_in_list 0 + } else { + set chosen [tcl::dict::get $choicealiases $bestmatch] + set choice_in_list 1 + set prefix_via_alias 1 + } + } else { + set chosen $bestmatch + set choice_in_list 1 + } + } + } else { + set matchedname [tcl::prefix::match -error "" [list {*}[lsort -unique $allchoices] {*}$aliasnames {*}$reservelist] $word] + if {$matchedname eq "" || $matchedname in $reservelist} { + set chosen "" + set choice_in_list 0 + } elseif {$matchedname in $aliasnames} { + #prefix landed on an alias - deny applies to the alias name, then normalize + if {$matchedname in $denylist} { + set chosen "" + set choice_in_list 0 + } else { + set chosen [tcl::dict::get $choicealiases $matchedname] + set choice_in_list 1 + set prefix_via_alias 1 + } + } else { + set chosen $matchedname + set choice_in_list 1 + } + } + #don't allow prefixing for elements from -choiceprefixdenylist + #we still use all elements to calculate the prefixes though + #(a canonical reached via an alias is exempt - deny was already applied to the alias name) + #review - case difference edge cases in choiceprefixdenylist !todo + if {!$prefix_via_alias && $chosen in $denylist} { + set choice_in_list 0 + set chosen "" + } + } + } else { + #-choiceprefix false - exact matching only + if {$v_test in $choices_test} { + #value as stored is ok (raw word kept - historical behaviour, including nocase raw retention) + set chosen $word + set choice_in_list 1 + set choice_exact_match 1 + } elseif {$has_choicealiases && [tcl::dict::exists $choicealiases $word]} { + #exact alias match - normalize to the canonical choice + set chosen [tcl::dict::get $choicealiases $word] + set choice_in_list 1 + } elseif {$has_choicealiases && $nocase && [tcl::dict::exists $choicealiases_nocase $v_test]} { + #exact alias match differing only by case - normalize to the canonical choice + set chosen [tcl::dict::get $choicealiases_nocase $v_test] + set choice_in_list 1 + } + } + return [tcl::dict::create matched $choice_in_list exact $choice_exact_match canonical $chosen] + } + #generally we expect values to contain leading dashes only if -- specified. Otherwise no reliable way determine difference between bad flags and values #If no eopts (--) specified we stop looking for opts at the first nondash encountered in a position we'd expect a dash - so without eopt, values could contain dashes - but not in first position after flags. #only supports -flag val pairs, not solo options @@ -10023,6 +10236,10 @@ tcl::namespace::eval punk::args { set choiceprefix [tcl::dict::get $thisarg -choiceprefix] set choiceprefixdenylist [Dict_getdef $thisarg -choiceprefixdenylist {}] set choiceprefixreservelist [Dict_getdef $thisarg -choiceprefixreservelist {}] + #-choicealiases (G-040): alias -> canonical choice. Accepted exact (any -choiceprefix + #setting) and as prefix-calculation members when -choiceprefix is true; matched aliases + #normalize to their canonical choice in the parse result (see choiceword_match). + set choicealiases [Dict_getdef $thisarg -choicealiases {}] set choicerestricted [tcl::dict::get $thisarg -choicerestricted] set choicemultiple [tcl::dict::get $thisarg -choicemultiple] if {[string is integer -strict $choicemultiple]} { @@ -10097,122 +10314,60 @@ tcl::namespace::eval punk::args { set choice_in_list 0 set matches_default [expr {$has_default && $c eq $defaultval}] ;# defaultval could be a list when -choicemultiple? if {!$matches_default} { - if {$choiceprefix} { - #can we handle empty string as a choice? It should just work - REVIEW/test - set choice_exact_match 0 - if {$c_check in $allchoices} { - #for case when there are case-differenced duplicates - allow exact match to avoid selecting earlier match of another casing - set chosen $c_check - set choice_in_list 1 - set choice_exact_match 1 - } elseif {$v_test in $choices_test} { - #assert - if we're here, nocase must be true - #we know choice is present as full-length match except for case - #now we want to select the case from the choice list - not the supplied value - #we don't set choice_exact_match - because we will need to override the optimistic existing val below - #review - foreach avail [lsort -unique $allchoices] { - if {[string match -nocase $c $avail]} { - set chosen $avail - } - } - #assert chosen will always get set - set choice_in_list 1 - } else { - #puts ">>>> choiceprefixreservelist: $choiceprefixreservelist" - #PREFIX check required - any 'chosen' here is not an exact match or it would have matched above. - #assert - if empty string was a provided choice and empty string was a provided arg - we would have matched above. - #in this block we can treat empty result from prefix match as a non-match - if {$nocase} { - #nocase implies that our entered value doesn't have to match case of choices - - #but we would still like to select the best match if there are case-dups. - #e.g arg -choices {delete Delete} -nocase 1 -choiceprefixdenylist delete - # selecting Del will find Delete, del will match delete (and raise error) - # but DEL will also match delete rather than Delete - so again an error is raised. - #This is counterintuitive with -nocase - #This is probably such an edge case that best served with documentation as a feature-not-bug - #Rationale being that in a nocase situation it's arbitrary/counterintuitive to consider DEL, or DeL a better match for Delete than delete? - #The choice of the user to use -choiceprefixdenylist along with case-dups is the issue. - - set bestmatch [tcl::prefix::match -error "" [list {*}[lsort -unique $allchoices] {*}$choiceprefixreservelist] $c_check] - if {$bestmatch eq "" || $bestmatch in $choiceprefixreservelist} { - set chosen [tcl::prefix::match -error "" [list {*}[lsort -unique $choices_test] {*}$choiceprefixreservelist] $v_test] - #now pick the earliest match in the actually defined list so that case of chosen always matches a defined entry with casing - set chosen [lsearch -inline -nocase $allchoices $chosen] - set choice_in_list [expr {$chosen ne ""}] + #G-040: choice-word matching delegated to the shared resolver + #(punk::args::choiceword_match - also consumed by the punk::ns doc-lookup + # walk, so 'i ' resolution cannot diverge from parsing) + set matchinfo [choiceword_match $c_check $nocase $allchoices $choicealiases $choiceprefix $choiceprefixdenylist $choiceprefixreservelist] + set choice_in_list [tcl::dict::get $matchinfo matched] + set choice_exact_match [tcl::dict::get $matchinfo exact] + set chosen [tcl::dict::get $matchinfo canonical] + #override the optimistic existing val (prefix-normalized or alias-normalized matches) + #our existing values in $dname are not list-protected - so we need to check clause_size + if {$choice_in_list && !$choice_exact_match} { + set existing [tcl::dict::get [set $dname] $argname_or_ident] + if {$choicemultiple_max != -1 && $choicemultiple_max < 2} { + #single choice allowed per clause-member + if {$is_multiple} { + if {$clause_size == 1} { + #no list wrapping of single element in $dname dict - so don't index into it with element_index + #lset existing $element_index $chosen ;#wrong - test::punk::args test: choice_multiple_with_choiceprefix. + lset existing $clause_index $chosen } else { - set chosen $bestmatch - set choice_in_list 1 + lset existing $clause_index $element_index $chosen } + tcl::dict::set $dname $argname_or_ident $existing } else { - set chosen [tcl::prefix::match -error "" [list {*}[lsort -unique $allchoices] {*}$choiceprefixreservelist] $c_check] - if {$chosen eq "" || $chosen in $choiceprefixreservelist} { - set choice_in_list 0 - } else { - set choice_in_list 1 - } - } - #override choice_in_list if in deny list - #don't allow prefixing for elements from -choiceprefixdenylist - #we still use all elements to calculate the prefixes though - #review - case difference edge cases in choiceprefixdenylist !todo - if {$chosen in $choiceprefixdenylist} { - set choice_in_list 0 - set chosen "" + #test: choice_multielement_clause + lset existing $element_index $chosen + tcl::dict::set $dname $argname_or_ident $existing } - } - - #override the optimistic existing val - #our existing values in $dname are not list-protected - so we need to check clause_size - if {$choice_in_list && !$choice_exact_match} { - set existing [tcl::dict::get [set $dname] $argname_or_ident] - if {$choicemultiple_max != -1 && $choicemultiple_max < 2} { - #single choice allowed per clause-member - if {$is_multiple} { - if {$clause_size == 1} { - #no list wrapping of single element in $dname dict - so don't index into it with element_index - #lset existing $element_index $chosen ;#wrong - test::punk::args test: choice_multiple_with_choiceprefix. - lset existing $clause_index $chosen - } else { - lset existing $clause_index $element_index $chosen - } - tcl::dict::set $dname $argname_or_ident $existing + } else { + if {$is_multiple} { + #puts ">>> existing $existing $choice_idx" + if {$clause_size == 1} { + #no list wrapping of single element in $dname dict - so don't index into it with element_index + lset existing $clause_index $choice_idx $chosen } else { - #test: choice_multielement_clause - lset existing $element_index $chosen - tcl::dict::set $dname $argname_or_ident $existing + lset existing $clause_index $element_index $choice_idx $chosen } + tcl::dict::set $dname $argname_or_ident $existing } else { - if {$is_multiple} { - #puts ">>> existing $existing $choice_idx" - if {$clause_size == 1} { - #no list wrapping of single element in $dname dict - so don't index into it with element_index - lset existing $clause_index $choice_idx $chosen - } else { - lset existing $clause_index $element_index $choice_idx $chosen - } - tcl::dict::set $dname $argname_or_ident $existing + #test required. + # punk::args::parse {{read write w}} withdef @values {mode -type list -choices {read write} -choicemultiple {1 -1}} + #puts ">>> clause_size $clause_size" + #puts ">>> existing $existing" + #puts ">>> lset existing $element_index $choice_idx $chosen" + if {$clause_size == 1} { + #e.g -type list + #we have multiple choices allowed for a single element clause because that clause type is a list. + lset existing $choice_idx $chosen } else { - #test required. - # punk::args::parse {{read write w}} withdef @values {mode -type list -choices {read write} -choicemultiple {1 -1}} - #puts ">>> clause_size $clause_size" - #puts ">>> existing $existing" - #puts ">>> lset existing $element_index $choice_idx $chosen" - if {$clause_size == 1} { - #e.g -type list - #we have multiple choices allowed for a single element clause because that clause type is a list. - lset existing $choice_idx $chosen - } else { - #e.g -type {any any} - lset existing $element_index $choice_idx $chosen - } - tcl::dict::set $dname $argname_or_ident $existing + #e.g -type {any any} + lset existing $element_index $choice_idx $chosen } + tcl::dict::set $dname $argname_or_ident $existing } } - } else { - #value as stored in $dname is ok - set choice_in_list [expr {$v_test in $choices_test}] } } diff --git a/src/modules/punk/args-buildversion.txt b/src/modules/punk/args-buildversion.txt index 3ad14023..847b0dc4 100644 --- a/src/modules/punk/args-buildversion.txt +++ b/src/modules/punk/args-buildversion.txt @@ -1,5 +1,6 @@ -0.2.3 +0.3.0 #First line must be a semantic version number #all other lines are ignored. +#0.3.0 - new: -choicealiases {alias canonical ...} argument option (G-040) - aliases accepted exact under any -choiceprefix/-nocase setting, join the prefix-calculation pool when -choiceprefix is true, and normalize to their canonical choice in parse results; deny applies to the matched name (alias deny requires full alias; canonical reached via alias exempt from the canonical's own deny); usage display folds aliases into the canonical entry's label as an (alias:...) note and includes alias names in display prefix calculation; alias->existing-choice and no-collision validated at definition resolve. new public helper punk::args::choiceword_match - the single choice-word matching implementation extracted from get_dict, also consumed by punk::ns cmd_traverse for doc-lookup parity #0.2.3 - fixed "@opts -any 1" adhoc option passthrough: an option not in the definition crashed get_dict ("can't read argname") at the name-mapping stage instead of passing through - adhoc opts now fall back to their raw supplied name (argstate/arg_checks already carried them); commented out a debug puts stderr (">>>_get_dict_can_assign_value NOT alloc_ok...") that fired on every failed clause type assignment (e.g. any multiform parse miss) #0.2.2 - documentation-only: define doc clarifies -choicerestricted 0 passthrough (non-matching/ambiguous/denied/reserved words accepted as ordinary values), -choiceprefix result normalization to the canonical choice + minimal-prefix display highlighting, -choiceprefixdenylist behaviour for shorter prefixes in both restricted modes, and -choiceprefixreservelist phantom-entry idiom for per-choice minimum-prefix control diff --git a/src/modules/punk/ns-999999.0a1.0.tm b/src/modules/punk/ns-999999.0a1.0.tm index ee44fcd1..f564f902 100644 --- a/src/modules/punk/ns-999999.0a1.0.tm +++ b/src/modules/punk/ns-999999.0a1.0.tm @@ -5154,16 +5154,26 @@ y" {return quirkykeyscript} return [list 3 $origin $resolvedargs [list {*}$eparams {*}$queryargs_untested] $docid] break } - set resolved_q [tcl::prefix::match -error "" $allchoices $q] - if {$resolved_q eq ""} { + #G-040: resolve the subcommand word with the same shared resolver argument + #parsing uses (punk::args::choiceword_match) - so aliases normalize to their + #canonical (choiceinfo is keyed on canonicals), -choiceprefixdenylist and + #-choiceprefixreservelist are honoured, and 'i ' can never accept + #a word that parsing would reject. + set ct_matchinfo [punk::args::choiceword_match $q\ + [punk::args::system::Dict_getdef $arginfo -nocase 0]\ + $allchoices\ + [punk::args::system::Dict_getdef $arginfo -choicealiases {}]\ + [dict get $arginfo -choiceprefix]\ + [punk::args::system::Dict_getdef $arginfo -choiceprefixdenylist {}]\ + [punk::args::system::Dict_getdef $arginfo -choiceprefixreservelist {}]\ + ] + if {![dict get $ct_matchinfo matched]} { + #no match under parse rules (covers: unknown word, ambiguous prefix, + #reserved word, denied prefix, and non-exact word when -choiceprefix 0) return [list 4 $origin $resolvedargs $queryargs_untested $docid] break } - if {![dict get $arginfo -choiceprefix] && $resolved_q ne $q} { - #a unique prefix is not sufficient for this arg - return [list 5 $origin $resolvedargs $queryargs_untested $docid] - break - } + set resolved_q [dict get $ct_matchinfo canonical] #if {$resolved_q ne $q} { diff --git a/src/modules/punk/ns-buildversion.txt b/src/modules/punk/ns-buildversion.txt index 52e3ccb7..8d7dbd20 100644 --- a/src/modules/punk/ns-buildversion.txt +++ b/src/modules/punk/ns-buildversion.txt @@ -1,4 +1,5 @@ -0.1.1 +0.1.2 #First line must be a semantic version number #all other lines are ignored. +#0.1.2 - cmd_traverse subcommand walk resolves choice words via the shared punk::args::choiceword_match resolver (G-040 parity): -choiceprefixdenylist and -choiceprefixreservelist are now honoured in doc lookup (previously ignored - 'i ' could accept words parsing rejects), -choicealiases normalize to their canonical before choiceinfo lookup, and -nocase is honoured in the walk #0.1.1 - commented out five development trace puts in the doc-lookup machinery: "PROC auto def"/"ENSEMBLE auto def" (generate_autodef - emitted on STDOUT, polluting 'i'/'s' output in script/exec contexts), "cmd_traverse - skipping to documented subcommand" (space-form id path), "---> cmd_traverse ensembleparam" (ensemble -parameters traversal), and "cmd_traverse 10 ... - review" (fallthrough return). No functional change. diff --git a/src/tests/modules/punk/args/testsuites/args/choices.test b/src/tests/modules/punk/args/testsuites/args/choices.test index e228c74d..c231f73e 100644 --- a/src/tests/modules/punk/args/testsuites/args/choices.test +++ b/src/tests/modules/punk/args/testsuites/args/choices.test @@ -352,6 +352,139 @@ namespace eval ::testspace { topics\ lindex\ ] + + # ---- -choicealiases (G-040) ---- + + test choicealiases_exact_and_prefix_normalize {alias exact match and unique alias prefix normalize to the canonical choice}\ + -setup $common -body { + set docid ::testspace::choicealiases_exact_and_prefix_normalize + punk::args::define [list @id -id $docid] @values {mode -choices {read write} -choicealiases {rd read wr write}} + + set argd [punk::args::parse {rd} withid $docid] + lappend result [dict get $argd values] + #canonical exact and canonical prefix still work + set argd [punk::args::parse {write} withid $docid] + lappend result [dict get $argd values] + set argd [punk::args::parse {rea} withid $docid] + lappend result [dict get $argd values] + #'w' is a prefix of both the choice 'write' and the alias 'wr' (same target) - currently ambiguous + if {[catch {punk::args::parse {w} withid $docid}]} { + lappend result w-ambiguous-rejected + } else { + lappend result w-UNEXPECTEDLY-accepted + } + }\ + -cleanup { + punk::args::undefine $docid 1 + }\ + -result [list\ + {mode read}\ + {mode write}\ + {mode read}\ + w-ambiguous-rejected\ + ] + + test choicealiases_noprefix_exact_only {with -choiceprefix 0 an exact alias still matches and normalizes; alias prefixes do not}\ + -setup $common -body { + set docid ::testspace::choicealiases_noprefix_exact_only + punk::args::define [list @id -id $docid] @values {mode -choiceprefix 0 -choices {read write} -choicealiases {rd read}} + + set argd [punk::args::parse {rd} withid $docid] + lappend result [dict get $argd values] + set argd [punk::args::parse {read} withid $docid] + lappend result [dict get $argd values] + if {[catch {punk::args::parse {r} withid $docid}]} { + lappend result r-prefix-rejected + } else { + lappend result r-prefix-UNEXPECTEDLY-accepted + } + }\ + -cleanup { + punk::args::undefine $docid 1 + }\ + -result [list\ + {mode read}\ + {mode read}\ + r-prefix-rejected\ + ] + + test choicealiases_nocase {aliases participate in -nocase matching and normalize to the canonical}\ + -setup $common -body { + set docid ::testspace::choicealiases_nocase + punk::args::define [list @id -id $docid] @values {mode -nocase 1 -choices {read write} -choicealiases {rd read}} + + set argd [punk::args::parse {RD} withid $docid] + lappend result [dict get $argd values] + set argd [punk::args::parse {READ} withid $docid] + lappend result [dict get $argd values] + }\ + -cleanup { + punk::args::undefine $docid 1 + }\ + -result [list\ + {mode read}\ + {mode read}\ + ] + + test choicealiases_denylist_applies_to_alias_name {an alias in -choiceprefixdenylist requires the full alias; exact alias still normalizes}\ + -setup $common -body { + set docid ::testspace::choicealiases_denylist_applies_to_alias_name + punk::args::define [list @id -id $docid] {@leaders -min 0 -max -1} {topic -optional 1 -multiple 1 -choicerestricted 0 -choiceprefix 1 -choices {topics tcl env console} -choicealiases {help topics environment env term console terminal console} -choiceprefixdenylist {help} -choiceprefixreservelist {c to tc}} {@values -min 0 -max 0} + + #the punk::help topic policy matrix (user-decided 2026-07-08: c/to/tc fall through) + foreach word {help hel h environment environ env en term terminal termi ter c to tc co top tcl lindex} { + set argd [punk::args::parse [list $word] withid $docid] + lappend result [dict get [dict get $argd leaders] topic] + } + set result + }\ + -cleanup { + punk::args::undefine $docid 1 + }\ + -result [list\ + topics\ + hel\ + h\ + env\ + env\ + env\ + en\ + console\ + console\ + console\ + ter\ + c\ + to\ + tc\ + console\ + topics\ + tcl\ + lindex\ + ] + + test choicealiases_resolve_validation {alias to a missing canonical or colliding with a choice fails at definition resolve time}\ + -setup $common -body { + set docid ::testspace::choicealiases_resolve_validation_a + punk::args::define [list @id -id $docid] @values {v -choices {a b} -choicealiases {x q}} + #define is lazy - the validation error surfaces at first resolve/parse + if {[catch {punk::args::parse {a} withid $docid} errmsg]} { + lappend result [string match "*is not in -choices/-choicegroups*" $errmsg] + } else { + lappend result missing-canonical-UNEXPECTEDLY-accepted + } + set docid2 ::testspace::choicealiases_resolve_validation_b + punk::args::define [list @id -id $docid2] @values {v -choices {a b} -choicealiases {a b}} + if {[catch {punk::args::parse {b} withid $docid2} errmsg]} { + lappend result [string match "*collides with a defined choice*" $errmsg] + } else { + lappend result collision-UNEXPECTEDLY-accepted + } + }\ + -cleanup { + punk::args::undefine $docid 1 + punk::args::undefine $docid2 1 + }\ + -result [list 1 1] } tcltest::cleanupTests ;#needed to produce test summary line. diff --git a/src/tests/modules/punk/ns/testsuites/ns/cmdflow.test b/src/tests/modules/punk/ns/testsuites/ns/cmdflow.test index cc41a8e8..651f21b7 100644 --- a/src/tests/modules/punk/ns/testsuites/ns/cmdflow.test +++ b/src/tests/modules/punk/ns/testsuites/ns/cmdflow.test @@ -2,13 +2,15 @@ package require tcltest package require punk::args package require punk::ns +package require punk::ansi #Tests for the command help/doc-lookup flow: punk::ns::cmdwhich -> cmdinfo -> cmd_traverse #docid resolution, including punk::args choiceinfo subhelp mapping and the -choiceprefix / -#-choiceprefixdenylist / -choiceprefixreservelist handling of subcommand words. -#Characterization added 2026-07-08 ahead of G-040 (choice aliasing + parse/doc-lookup parity): -#tests marked GAP pin the CURRENT divergence between punk::args::parse and the cmd_traverse -#doc walk (deny/reserve lists honoured by parse, ignored by the walk). G-040 flips those pins. +#-choiceprefixdenylist / -choiceprefixreservelist / -choicealiases handling of subcommand words. +#Characterization added 2026-07-08 ahead of G-040; the parity GAP pins (deny/reserve honoured +#by parse but ignored by the doc walk) were flipped to agreement the same day when G-040 +#landed: cmd_traverse now resolves subcommand words via the shared punk::args::choiceword_match +#resolver, so the walk and the parser cannot diverge. namespace eval ::testspace { namespace import ::tcltest::* @@ -95,6 +97,19 @@ namespace eval ::testspace { @values -min 0 -max 0 } + #as flowparent but with a -choicealiases entry (first -> alpha) - G-040 + proc flowaliased {args} {} + punk::args::define { + @id -id ::testspace::flowaliased + @cmd -name testspace::flowaliased -summary "parent aliased" -help "parent aliased" + @leaders -min 1 -max 1 + subcmd -choices {alpha beta} -choicealiases {first alpha} -choiceinfo { + alpha {{doctype punkargs} {subhelp ::testspace::flowsub_alpha}} + beta {{doctype punkargs} {subhelp ::testspace::flowsub_beta}} + } + @values -min 0 -max 0 + } + #undocumented command whose subcommand is documented via a space-delimited id proc flowgap {args} {} punk::args::define { @@ -181,12 +196,10 @@ namespace eval ::testspace { }\ -result [list {::testspace::flowgap deep}] - #--- parse vs doc-walk parity (GAP pins pending G-040) ----------------------------------- - #punk::args::parse honours -choiceprefixdenylist/-choiceprefixreservelist; the cmd_traverse - #doc walk (punk::ns) consults only -choiceprefix and ignores both lists. These tests pin - #the CURRENT divergent behaviour; G-040's parity work flips them to agreement. + #--- parse vs doc-walk parity (G-040 - previously pinned as GAPs, flipped when the walk + #adopted the shared punk::args::choiceword_match resolver) ------------------------------ - test cmdinfo_parity_denylist_GAP {GAP (G-040): parse rejects a denied prefix but the doc walk still resolves it}\ + test cmdinfo_parity_denylist {parse and the doc walk agree: a denied prefix resolves for neither}\ -setup $common -body { #parse side: denied prefix is an error (restricted choices) if {[catch {punk::args::parse {al} withid ::testspace::flowdeny}]} { @@ -197,15 +210,18 @@ namespace eval ::testspace { #full word accepted by parse set argd [punk::args::parse {alpha} withid ::testspace::flowdeny] lappend result [dict get [dict get $argd leaders] subcmd] - #doc-walk side: the same denied prefix currently resolves to the subhelp docid + #doc-walk side: the denied prefix no longer resolves - docid stays at the parent set cinfo [punk::ns::cmdinfo ::testspace::flowdeny al] lappend result [dict get $cinfo docid] + #doc-walk side: the full denied word still resolves (deny only blocks prefixes) + set cinfo [punk::ns::cmdinfo ::testspace::flowdeny alpha] + lappend result [dict get $cinfo docid] }\ -cleanup { }\ - -result [list parse-rejects-denied-prefix alpha ::testspace::flowsub_alpha] + -result [list parse-rejects-denied-prefix alpha ::testspace::flowdeny ::testspace::flowsub_alpha] - test cmdinfo_parity_reservelist_GAP {GAP (G-040): parse blocks a reserved prefix but the doc walk still resolves it}\ + test cmdinfo_parity_reservelist {parse and the doc walk agree: a reserved word resolves for neither}\ -setup $common -body { #parse side: reserved word blocks itself and shorter prefixes if {[catch {punk::args::parse {en} withid ::testspace::flowreserve}]} { @@ -219,13 +235,31 @@ namespace eval ::testspace { #doc-walk side agreement for the beyond-phantom prefix set cinfo [punk::ns::cmdinfo ::testspace::flowreserve env] lappend result [dict get $cinfo docid] - #doc-walk side: the reserved word currently resolves anyway + #doc-walk side: the reserved word no longer resolves - docid stays at the parent set cinfo [punk::ns::cmdinfo ::testspace::flowreserve en] lappend result [dict get $cinfo docid] }\ -cleanup { }\ - -result [list parse-rejects-reserved-word environment ::testspace::flowsub_env ::testspace::flowsub_env] + -result [list parse-rejects-reserved-word environment ::testspace::flowsub_env ::testspace::flowreserve] + + test cmdinfo_parity_choicealiases {an alias word (and alias prefix) resolves to the canonical's subhelp docid in both parse and the doc walk}\ + -setup $common -body { + #fixture defined below the standard fixtures: choices {alpha beta} with alias first -> alpha + #parse side: alias normalizes to canonical + set argd [punk::args::parse {first} withid ::testspace::flowaliased] + lappend result [dict get [dict get $argd leaders] subcmd] + set argd [punk::args::parse {fir} withid ::testspace::flowaliased] + lappend result [dict get [dict get $argd leaders] subcmd] + #doc-walk side: alias and alias prefix resolve to the CANONICAL's choiceinfo subhelp + set cinfo [punk::ns::cmdinfo ::testspace::flowaliased first] + lappend result [dict get $cinfo docid] + set cinfo [punk::ns::cmdinfo ::testspace::flowaliased fir] + lappend result [dict get $cinfo docid] + }\ + -cleanup { + }\ + -result [list alpha alpha ::testspace::flowsub_alpha ::testspace::flowsub_alpha] #--- cmdhelp smoke ------------------------------------------------------------------------ @@ -240,6 +274,22 @@ namespace eval ::testspace { }\ -result [list 1 1] + test cmdhelp_choicealiases_display_folded {usage display folds an alias into its canonical choice entry rather than listing it separately}\ + -setup $common -body { + set out [punk::ns::cmdhelp -return string ::testspace::flowaliased] + #the alias is shown as a note on the canonical entry + lappend result [string match "*(alias: first)*" $out] + #and does not appear as its own choice row (a standalone 'first' line) + set standalone 0 + foreach ln [split [punk::ansi::ansistrip $out] \n] { + if {[string trim $ln] eq "first"} {set standalone 1} + } + lappend result $standalone + }\ + -cleanup { + }\ + -result [list 1 0] + #--- ensemble -parameters / -ensembleparameter -------------------------------------------- #Real Tcl ensembles created with 'namespace ensemble create -parameters {...}' take their #parameter(s) BEFORE the subcommand (the punk::netbox::man pattern: 'man