diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 02ea965b..feeb39bd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -42,7 +42,7 @@ app package from src/lib/ (app-punkshell, app-punkscript, app-repl, app-shellsp ## Boot and launch chain - **Kit anatomy.** Each `src/vfs/*.vfs` folder is a runtime payload; `src/runtime/mapvfs.toml` (G-024, tomlish-parsed; deprecated `mapvfs.config` line format still readable) maps payload folders to platform runtimes under `bin/runtime//` as named kit outputs, with named groups and generative version-named schemes; an optional sibling `src/vfs/.vfs.toml` declares payload packages materialized INTO the folder with drop-in-wins precedence (G-115 - spec: `src/vfs/README.md`); `src/vfs/_vfscommon.vfs/` is a generated merge of common libraries (never hand-edited); `src/vfs/_config/` holds the entry scripts. Sources: `src/vfs/AGENTS.md`, `src/runtime/AGENTS.md`. -- **Entry point.** `src/vfs/_config/punk_main.tcl` (thin main sourcing the shared boot core - G-031). An optional first argument is a dash-delimited package mode composed of tokens from `minted`, `os`, `src`, `internal` (`internal` is always appended when absent; `minted` was named `dev` pre-0.43.0), optionally scoped with the `proj:` prefix (G-033: `minted`/`src` resolve against the project containing the cwd - walk-up to the nearest git/fossil repo root with a punkshell-style src tree - instead of the executable's own project; discovery is always reported, never a silent rebind). The next argument is the subcommand: `shell`, `script`, `punk`, `tclsh`, `shellspy`, `buildinfo`, `help`. A non-subcommand first argument naming an existing file (or `lib:*` reference) is treated as a script invocation; a first argument of exactly `-e` runs the following argument as a Tcl one-liner (G-077, achieved - handled as `script -e`; tool-style kits keep `-e` routed to their own processor); anything else is refused with usage (G-032); no arguments at all means interactive shell. Subcommands map to app packages under `src/lib/` (`app-punkshell`, `app-punkscript`, `app-repl`, `app-shellspy`). The launch surface self-documents through punk::args (G-032; single-mechanism revision 2026-08-05 in boot core 0.5.0): `help ?subcommand? ?arg ...?` - the only help entry, trailing words accepted so `help` can be prepended to a command line - renders tabular usage from the moduledoc definitions plus project registrations (`::punkboot::project_subcommand_info` - summaries, definition ids, optional declared parse), degrading to a plain subcommand list when that stack is unavailable; help FLAGS are never intercepted (a leading `-help` earns the refusal, ` -help` belongs to the subcommand). Source: `src/vfs/AGENTS.md`. +- **Entry point.** `src/vfs/_config/punk_main.tcl` (thin main sourcing the shared boot core - G-031). An optional first argument is a dash-delimited package mode composed of tokens from `minted`, `os`, `src`, `internal` (`internal` is always appended when absent; `minted` was named `dev` pre-0.43.0), optionally scoped with the `proj:` prefix (G-033: `minted`/`src` resolve against the project containing the cwd - walk-up to the nearest git/fossil repo root with a punkshell-style src tree - instead of the executable's own project; discovery is always reported, never a silent rebind). The next argument is the subcommand: `shell`, `script`, `punk`, `tclsh`, `shellspy`, `buildinfo`, `help`. A non-subcommand first argument naming an existing file (or `lib:*` reference) is treated as a script invocation; a first argument of exactly `-e` runs the following argument as a Tcl one-liner (G-077, achieved - handled as `script -e`; tool-style kits keep `-e` routed to their own processor); anything else is refused with usage (G-032); no arguments at all means interactive shell. Subcommands map to app packages under `src/lib/` (`app-punkshell`, `app-punkscript`, `app-repl`, `app-shellspy`). The launch surface self-documents through punk::args (G-032; single-mechanism revision 2026-08-05 in boot core 0.5.0): `help ?subcommand? ?arg ...?` - the only help entry, trailing words accepted so `help` can be prepended to a command line, with depth since G-170 (boot core 0.7.0): a single word naming one of a multi-form definition's forms (or a decimal ordinal) narrows the render to that form's argument table, any other tail on a parse-true subcommand is dry-run through its definition (accepted lines add the `punk::args::parse_report` landing report, rejected lines emit the parse diagnosis on stderr with exit 1), and passthrough subcommands keep the ignored tail - renders tabular usage from the moduledoc definitions plus project registrations (`::punkboot::project_subcommand_info` - summaries, definition ids, optional declared parse), degrading to a plain subcommand list when that stack is unavailable; help FLAGS are never intercepted (a leading `-help` earns the refusal, ` -help` belongs to the subcommand). Source: `src/vfs/AGENTS.md`. - **Package modes pick module provenance.** `internal` uses module snapshots baked into the kit at bake time; `minted` loads the mint output trees at repo-root `modules/` and `lib/`; `src` loads the unbuilt working tree (`src/modules`, `src/lib`, `src/bootsupport`, `src/vendormodules`) with `package prefer latest` so magic-version dev modules (`999999.0a1.0`) beat stamped snapshots. `src` mode is the standard way to verify working-tree changes without a rebuild. Source: `bin/AGENTS.md` "Launch package modes". - **Payload mount derivation (G-129, achieved).** The boot keys zipfs presence on `tcl::zipfs::mount` and derives where the executable's attached archive actually mounted from the mount table itself (the entry whose archive file is the executable; fallback: the mount containing the boot script) instead of assuming `tcl::zipfs::root` + `//zipfs:/app`. Modern runtimes still answer `//zipfs:/app`; the androwish/undroidwish 8.6 backport has no `root` command, mounts at the executable's own path, and boots the same payload through its `app/main.tcl` in-archive hook (`src/vfs/punk8win.vfs` carries fauxlinks for both conventions). A mounted archive the boot cannot attribute is reported on stderr rather than silently yielding no internal paths. The derivation procs stay defined post-boot (`::punkboot::zipfs_kit_mountbase`). Sources: `src/vfs/AGENTS.md`, `bin/AGENTS.md` "Kit-wrappable runtime requirements", `src/tests/shell/testsuites/punkexe/kitmountpoint.test`. - **Static package capture (G-058, achieved).** Boot probe-loads the runtime's statically linked packages in a throwaway interp and records what each provides; every interp the shell fabricates (boot, codethread, shellthread workers) seeds `package ifneeded {load {} }` from that record, and `punk::packagepreference` resolves static-vs-bundled version-aware. Sources: `src/vfs/AGENTS.md`, `goals/archive/G-058-static-runtime-packages.md`. diff --git a/CHANGELOG.md b/CHANGELOG.md index af9df474..27575773 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ 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.56.0] - 2026-08-06 + +- Coherent-set promotion + bake (the G-169 and G-170 follow-on promotion lines, + landed together): the launcher-family work of G-151, G-168, G-169 and G-170 + reaches the kits. Promoted punk::args 0.18.0 and punk::ansi 0.2.0 into + bootsupport and `_vfscommon.vfs`, plus punkboot core 0.7.0, punkexe moduledoc + 0.9.0, punkboot moduledoc 0.3.1 and app-punkscript/app-punkscript-docs 1.3 + into `_vfscommon.vfs`; 11 kits rebaked and deployed. User-visible in a + deployed kit for the first time: ` help ` + narrows a multi-form definition to that form's argument table, the multi-form + synopsis lines carry actionable `help %formname%` labels instead of the + suppressed column, any other tail on a parse-true subcommand is dry-run + through its definition (accepted lines render the matched form plus the + punk::args::parse_report landing report; rejected lines emit the parse + diagnosis on stderr, exit 1), and `help script` renders the app-owned + definition that now lives beside the code implementing it. +- The tclsh dry-run arm goes live with this bake: it requires the G-168 fence + witness in the moduledoc actually served, so kits carrying the older 0.6.1 + snapshot had kept the historic ignored-tail render. That mixed-vintage state + is now resolved in the deployed kits. + ## [0.55.1] - 2026-08-05 - G-149 follow-on landed: make.tcl's multi-form definitions (tool, buildsuite) diff --git a/goals/archive/G-169-app-owned-launch-docs.md b/goals/archive/G-169-app-owned-launch-docs.md index 9c9c91c5..d4352f27 100644 --- a/goals/archive/G-169-app-owned-launch-docs.md +++ b/goals/archive/G-169-app-owned-launch-docs.md @@ -149,5 +149,5 @@ Verification evidence: ## Follow-ons -Follow-on: the next vfscommonupdate/bake promotion carries the coherent set together - src/lib/app-punkscript (pkgIndex + punkscript.tcl 1.2), punkboot core 0.6.0 and punkexe moduledoc 0.8.0 (plus punkboot moduledoc 0.3.1) - after which the baked kit serves 'help script' from the app-owned definition in internal mode; mixed intermediate states degrade gracefully as recorded in Progress => open +Follow-on: the next vfscommonupdate/bake promotion carries the coherent set together - src/lib/app-punkscript (pkgIndex + punkscript.tcl 1.2), punkboot core 0.6.0 and punkexe moduledoc 0.8.0 (plus punkboot moduledoc 0.3.1) - after which the baked kit serves 'help script' from the app-owned definition in internal mode; mixed intermediate states degrade gracefully as recorded in Progress => landed 2026-08-06 (the same promotion + bake event that landed G-170's coherent-set line, by then carrying app-punkscript 1.3 / core 0.7.0 / punkexe moduledoc 0.9.0; the deployed kit's 'help script' now renders the app-owned definition in internal mode) Follow-on: extend app ownership to the other app-handled built-ins (shell/punk via app-punkshell, shellspy via app-shellspy) whose moduledoc entries are summary-level passthrough contracts today - same docs-package pattern, launcher_builtin_docpackages gains their entries => open diff --git a/goals/archive/G-170-launcher-help-depth.md b/goals/archive/G-170-launcher-help-depth.md index c838a715..66e25795 100644 --- a/goals/archive/G-170-launcher-help-depth.md +++ b/goals/archive/G-170-launcher-help-depth.md @@ -167,5 +167,5 @@ Verification evidence: ## Follow-ons -Follow-on: coherent-set promotion - the same vfscommonupdate/bake event as G-169's open promotion line, now additionally carrying punkboot core 0.7.0 + punkexe moduledoc 0.9.0 + app-punkscript 1.3 (with punk::args 0.18.0 + punk::ansi 0.2.0 for the landing report); mixed intermediate states degrade gracefully by construction (fence witness, guarded parse_report) => open +Follow-on: coherent-set promotion - the same vfscommonupdate/bake event as G-169's open promotion line, now additionally carrying punkboot core 0.7.0 + punkexe moduledoc 0.9.0 + app-punkscript 1.3 (with punk::args 0.18.0 + punk::ansi 0.2.0 for the landing report); mixed intermediate states degrade gracefully by construction (fence witness, guarded parse_report) => landed 2026-08-06 (promotion + bake: bootsupport punk::args 0.18.0 + punk::ansi 0.2.0; _vfscommon.vfs punkboot core 0.7.0 + punkexe moduledoc 0.9.0 + punkboot moduledoc 0.3.1 + app-punkscript 1.3; 11 kits rebaked and deployed - the deployed kit now serves narrowing, formhint labels and the parse_report dry-run, and the tclsh fence witness is satisfied so its dry-run is live instead of the historic ignored-tail render) Follow-on: punkboot moduledoc registration guidance - note at the 'argsid' key that a multi-form project definition may carry its own '@cmd -formhint " help %formname%"' template now that the launcher narrows on form words (project parse=1 dry-run and narrowing already work; only the authoring hint is missing) => open diff --git a/punkproject.toml b/punkproject.toml index a41bf6c2..a2b87541 100644 --- a/punkproject.toml +++ b/punkproject.toml @@ -1,6 +1,6 @@ [project] name = "punkshell" -version = "0.55.1" +version = "0.56.0" license = "BSD-2-Clause" url = "https://www.gitea1.intx.com.au/jn/punkshell" #packager: declared identity for published artifacts (declarative, not proof - diff --git a/src/bootsupport/modules/punk/ansi-0.1.3.tm b/src/bootsupport/modules/punk/ansi-0.2.0.tm similarity index 99% rename from src/bootsupport/modules/punk/ansi-0.1.3.tm rename to src/bootsupport/modules/punk/ansi-0.2.0.tm index be13f441..899ec5ad 100644 --- a/src/bootsupport/modules/punk/ansi-0.1.3.tm +++ b/src/bootsupport/modules/punk/ansi-0.2.0.tm @@ -7,7 +7,7 @@ # (C) 2023 # # @@ Meta Begin -# Application punk::ansi 0.1.3 +# Application punk::ansi 0.2.0 # Meta platform tcl # Meta license # @@ Meta End @@ -16,7 +16,7 @@ # doctools header # ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ #*** !doctools -#[manpage_begin punkshell_module_punk::ansi 0 0.1.3] +#[manpage_begin punkshell_module_punk::ansi 0 0.2.0] #[copyright "2023"] #[titledesc {Ansi string functions}] [comment {-- Name section and table of contents description --}] #[moddesc {punk Ansi library}] [comment {-- Description at end of page heading --}] @@ -10504,7 +10504,7 @@ tcl::namespace::eval punk::ansi::ansistring { tcl::namespace::path [list ::punk::ansi ::punk::ansi::ta] tcl::namespace::ensemble create - tcl::namespace::export length trim trimleft trimright INDEX INDEXCODE INDEXCHAR RANGE INSERT COUNT COUNT2 COUNT3 VIEW VIEWCODES VIEWSTYLE INDEXABSOLUTE INDEXCOLUMNS COLUMNINDEX NEW + tcl::namespace::export length trim trimleft trimright INDEX INDEXCODE INDEXCHAR RANGE TRUNCATE INSERT COUNT COUNT2 COUNT3 VIEW VIEWCODES VIEWSTYLE INDEXABSOLUTE INDEXCOLUMNS COLUMNINDEX NEW #todo - expose _splits_ methods so caller can work efficiently with the splits themselves #we need to consider whether these can be agnostic towards splits from split_codes vs split_codes_single @@ -11581,12 +11581,22 @@ tcl::namespace::eval punk::ansi::ansistring { @id -id ::punk::ansi::ansistring::RANGE @cmd -name punk::ansi::ansistring::RANGE\ -summary\ - "Substring between grapheme positions, with ANSI codes preserved."\ + "Substring between grapheme positions, with SGR styling re-applied."\ -help\ "Return the substring of string between grapheme positions startindex - and endindex inclusive (positions as for ansistring INDEX), including - the ANSI codes in effect for those graphemes. - end-relative index expressions are supported." + and endindex inclusive (positions as for ansistring INDEX), with the + ANSI SGR codes in effect for those graphemes re-applied at each + plain-text run - so the range renders as the original did. + The result is style-equivalent but not byte-identical: code stacks + are re-emitted in merged (canonicalized) form, only SGR codes are + carried (other sequence types - cursor movement, OSC, charset + shifts - are not re-emitted), and codes after the last requested + grapheme are not included (the SGR state after the whole string is + retrievable with ansistring INDEXCODE end+1). + end-relative index expressions are supported. An index beyond the + string clamps to the nearest end; if both indices fall beyond the + same end, or startindex resolves past endindex, an empty string is + returned (as for tcl string range)." @values -min 3 -max 3 string -type string startindex -type indexexpression @@ -11596,7 +11606,7 @@ tcl::namespace::eval punk::ansi::ansistring { proc RANGE {string startindex endindex} { #*** !doctools #[call [fun RANGE] [arg string] [arg startindex] [arg endindex]] - #[para]Returns the substring of string between the character positions startindex and endindex, inclusive, where the character positions are determined by INDEX. The returned substring will include any ANSI codes that are in effect for those characters. + #[para]Returns the substring of string between the character positions startindex and endindex, inclusive, where the character positions are determined by INDEX. The ANSI SGR codes in effect for those characters are re-applied at each plain-text run in merged (canonicalized) form - style-equivalent but not byte-identical to the original; non-SGR codes and codes after the last requested character are not re-emitted (see INDEXCODE end+1 for trailing SGR state). #TODO - special case processing for end+n and equivalents to return trailing ANSI codes as well. #(similar to INDEXCODE) @@ -11697,6 +11707,85 @@ tcl::namespace::eval punk::ansi::ansistring { return $rangeresult } + namespace eval argdoc { + lappend PUNKARGS [list { + @id -id ::punk::ansi::ansistring::TRUNCATE + @cmd -name punk::ansi::ansistring::TRUNCATE\ + -summary\ + "Truncate to a maximum grapheme count, ANSI-aware, with an ellipsis marker."\ + -help\ + "Return string unchanged (byte-identical) if its grapheme count + (as for ansistring COUNT) is at most maxgraphemes - otherwise + return the leading graphemes with the ellipsis marker appended, + such that the result's total grapheme count is maxgraphemes. + The kept prefix is extracted as for ansistring RANGE, so SGR + codes in effect over the kept graphemes are re-applied in + merged (canonicalized) form - and when that prefix carries any + ANSI codes an SGR reset is appended before the marker so the + marker and any following text render unstyled. + The marker participates in the budget: the prefix keeps + maxgraphemes minus the marker's grapheme count. If the marker + alone meets or exceeds the budget, the result is the marker + itself truncated to maxgraphemes. maxgraphemes 0 returns an + empty string. + Truncation counts graphemes, not display columns - wide (e.g + CJK) graphemes count 1 each, as for COUNT." + @opts + -ellipsis -type string -default "..." -help\ + "Marker appended when truncation occurs. + May itself contain ANSI codes; its grapheme count is charged + against maxgraphemes." + @values -min 2 -max 2 + string -type string + maxgraphemes -type integer -help\ + "Maximum grapheme count of the returned string (>= 0)." + } ] + } + proc TRUNCATE {args} { + #*** !doctools + #[call [fun TRUNCATE] [opt "-ellipsis marker"] [arg string] [arg maxgraphemes]] + #[para]Returns string unchanged (byte-identical) if its grapheme count (as for COUNT) is at most maxgraphemes - otherwise returns the leading graphemes with the marker (default ...) appended, such that the result's total grapheme count equals maxgraphemes. + #[para]The kept prefix is extracted as for RANGE (merged SGR re-application); when that prefix carries ANSI codes an SGR reset precedes the marker so the marker and any following text render unstyled. The marker's grapheme count is charged against maxgraphemes; a marker that alone meets or exceeds the budget is itself truncated to maxgraphemes. Grapheme-count based, not display-column based. + set ellipsis "..." + set arglist $args + while {[llength $arglist] > 2} { + switch -- [lindex $arglist 0] { + -ellipsis { + set ellipsis [lindex $arglist 1] + set arglist [lrange $arglist 2 end] + } + default { + error "ansistring TRUNCATE unknown option '[lindex $arglist 0]'. Known options: -ellipsis" + } + } + } + if {[llength $arglist] != 2} { + error "ansistring TRUNCATE requires: ?-ellipsis marker? string maxgraphemes" + } + lassign $arglist string maxgraphemes + if {![tcl::string::is integer -strict $maxgraphemes] || $maxgraphemes < 0} { + error "ansistring TRUNCATE maxgraphemes must be an integer >= 0. Received '$maxgraphemes'" + } + if {$maxgraphemes == 0} { + return "" + } + if {[COUNT $string] <= $maxgraphemes} { + return $string + } + set keep [expr {$maxgraphemes - [COUNT $ellipsis]}] + if {$keep <= 0} { + #marker alone meets/exceeds the budget - return as much of the marker as fits + return [RANGE $ellipsis 0 [expr {$maxgraphemes - 1}]] + } + set prefix [RANGE $string 0 [expr {$keep - 1}]] + if {[tcl::string::first \x1b $prefix] >= 0} { + #the prefix carries ANSI codes (RANGE re-applies SGR stacks with no trailing + #reset) - close styling so the marker and any following text render unstyled + append prefix "\x1b\[0m" + } + return $prefix$ellipsis + } + namespace eval argdoc { lappend PUNKARGS [list { @id -id ::punk::ansi::ansistring::INSERT @@ -13641,7 +13730,7 @@ namespace eval ::punk::args::register { ## Ready package provide punk::ansi [tcl::namespace::eval punk::ansi { variable version - set version 0.1.3 + set version 0.2.0 }] return diff --git a/src/bootsupport/modules/punk/args-0.17.0.tm b/src/bootsupport/modules/punk/args-0.18.0.tm similarity index 97% rename from src/bootsupport/modules/punk/args-0.17.0.tm rename to src/bootsupport/modules/punk/args-0.18.0.tm index b5389f54..9671277b 100644 --- a/src/bootsupport/modules/punk/args-0.17.0.tm +++ b/src/bootsupport/modules/punk/args-0.18.0.tm @@ -8,7 +8,7 @@ # (C) 2024 # # @@ Meta Begin -# Application punk::args 0.17.0 +# Application punk::args 0.18.0 # Meta platform tcl # Meta license # @@ Meta End @@ -18,7 +18,7 @@ # doctools header # ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ #*** !doctools -#[manpage_begin punkshell_module_punk::args 0 0.17.0] +#[manpage_begin punkshell_module_punk::args 0 0.18.0] #[copyright "2024"] #[titledesc {args parsing}] [comment {-- Name section and table of contents description --}] #[moddesc {args to nested dict of opts and values}] [comment {-- Description at end of page heading --}] @@ -810,6 +810,13 @@ tcl::namespace::eval punk::args { which are unreachable from any repl - so the hint can name that consumer's own navigation instead. Callers of usage/arg_error may override with their -formhint option. + -reportvaluewidth + Display cap (in graphemes) for the Value column of + punk::args::parse_report's landing table when a + successful parse of this definition is rendered. + 0 means no cap. Callers of parse_report may override + with its -valuewidth option; with neither present the + built-in default (48) applies. %B%@leaders%N% ?opt val...? (used for leading args that come before switches/opts) directive-options: @@ -8103,6 +8110,453 @@ tcl::namespace::eval punk::args { return [private::parse_status_build $spec_dict $fid -ok 1 -status valid -scheme info -parsedargs $r -formstatus $formstatus] } + lappend PUNKARGS [list { + @id -id ::punk::args::parse_report + @cmd -name punk::args::parse_report\ + -summary\ + "Landing report for a successful parse - each value-holding argument with its source and value."\ + -help\ + "Render a compact landing table for a SUCCESSFUL parse - one row + per argument holding a value after the parse (received, or filled + from its -default), in declaration order by section (leaders, then + options, then values), with columns Argument, Source and Value. + This is the success-side sibling of the arg_error usage table: + where arg_error diagnoses a rejected line, parse_report shows + where each word of an accepted line landed - options consumed, + values attributed to the argument that took them (including a + flag-like word consumed as a VALUE by position), -multiple + aggregations, and received distinguished from defaulted via the + Source column (received / received xN / default). Arguments + holding no value (absent optionals with no -default) get no row. + The first argument is either the result dict of a prior successful + punk::args::parse (its id key must resolve to a registered + definition), or a list of words to parse followed by 'withid' and + the definition id. In the words form the parse is performed first, + and a validation failure raises the same error punk::args::parse + would - only successful parses have a landing report (see + arg_error/parse_status for failure presentation). + Value cells are display-elided to fit -valuewidth: strings are + rendered one-line (control characters as visible substitutes, as + for ansistring VIEW) and char-capped with a trailing '...' plus + ' (len N)'; -multiple aggregations and -type list values are + element-capped with '(+N more)'; -type dict values are pair-capped + with '(+N pairs)'. Elision is display-only and always marked - the + -return dict form is never elided." + @form -form parsed + @leaders -min 1 -max 1 + parsedresult -type dict -help\ + "Result dict of a prior successful punk::args::parse + (keys: leaders opts values received solos multis id form)" + + @form -form withid + @leaders -min 1 -max 1 + arglist -type list -help\ + "Words to parse - supplied as a single list" + + @form -form {parsed withid} + @opts -prefix 0 + -scheme -default info -choices {nocolour info error} + -return -default table -choices {table tableobject string dict} -choicelabels { + table "text of framed landing table" + tableobject "table object cmd" + string "plain lines, no table layout" + dict "machine form - never elided" + } + -title -type string -default "Received" -help\ + "Title for the landing table frame." + -valuewidth -type integer -help\ + "Display cap (graphemes) for the Value column - 0 means no cap. + When not supplied: a definition-level '@cmd -reportvaluewidth' + applies, else the built-in default 48. Deliberately never derived + from console width - report output is deterministic (and + pipe-safe) for a given parse and configuration. Elision markers + stay intact, so cells can exceed very small caps by the marker + width." + -caller -type string -default "" -help\ + "Caller attribution for the validation failure message when the + words form's parse fails (see punk::args::parse -caller)." + + @form -form parsed + @values -min 0 -max 0 + + @form -form withid + @values -min 2 -max 2 + withid -type literal(withid) -help\ + "The literal value 'withid'" + id -type string -help\ + "id of punk::args definition for a command" + }] + proc parse_report {args} { + #G-151 annotated success render. Hand-rolled argument processing in the style of + #parse/parse_status (self-parse against the documented definition only on the + #unhappy path - so a report render never recurses through a second live parse). + if {[llength $args] < 1} { + punk::args::parse $args withid ::punk::args::parse_report + error "punk::args::parse_report - internal argument processing mismatch for '$args'" + } + set payload [lindex $args 0] + set rest [lrange $args 1 end] + set opts [dict create {*}{ + -scheme info + -return table + -title Received + -caller {} + }] + #-valuewidth deliberately absent from the defaults: absence selects the + #cascade (caller option > @cmd -reportvaluewidth > built-in 48) + set tail [list] + for {set i 0} {$i < [llength $rest]} {incr i} { + set k [lindex $rest $i] + if {$k eq "withid"} { + set tail [lrange $rest $i end] + break + } + switch -- $k { + -scheme - -return - -title - -valuewidth - -caller { + if {$i + 1 >= [llength $rest]} { + #missing option value - the documented definition raises + punk::args::parse $args withid ::punk::args::parse_report + error "punk::args::parse_report - internal argument processing mismatch for '$args'" + } + incr i + dict set opts $k [lindex $rest $i] + } + default { + #unknown option or stray word - the documented definition raises + punk::args::parse $args withid ::punk::args::parse_report + error "punk::args::parse_report - internal argument processing mismatch for '$args'" + } + } + } + switch -- [dict get $opts -return] { + table - tableobject - string - dict {} + default { + punk::args::parse $args withid ::punk::args::parse_report + error "punk::args::parse_report - internal argument processing mismatch for '$args'" + } + } + set returntype [dict get $opts -return] + switch -- [dict get $opts -scheme] { + nocolour - nocolor {set scheme nocolour} + info {set scheme info} + error {set scheme error} + default { + punk::args::parse $args withid ::punk::args::parse_report + error "punk::args::parse_report - internal argument processing mismatch for '$args'" + } + } + if {[llength $tail]} { + #words form - payload is the words, tail is: withid + if {[llength $tail] != 2} { + punk::args::parse $args withid ::punk::args::parse_report + error "punk::args::parse_report - internal argument processing mismatch for '$args'" + } + set id [lindex $tail 1] + if {[real_id $id] eq ""} { + error "punk::args::parse_report - no such id: $id" + } + set parseopts [list] + if {[dict get $opts -caller] ne ""} { + lappend parseopts -caller [dict get $opts -caller] + } + #a rejected line raises the same validation error punk::args::parse would - + #only successful parses have a landing report + set parsedresult [punk::args::parse $payload {*}$parseopts withid $id] + } else { + set parsedresult $payload + if {[catch {tcl::dict::size $parsedresult}] || ![tcl::dict::exists $parsedresult received]} { + error "punk::args::parse_report - first argument is not a punk::args::parse result dict (and no 'withid ' tail was supplied)" + } + } + + #resolve the definition and the displayed form from the parse result + set docid [Dict_getdef $parsedresult id ""] + set real [real_id $docid] + if {$real eq ""} { + error "punk::args::parse_report - cannot resolve a registered definition from the parse result's id '$docid' - use the words form: parse_report withid " + } + set spec_dict [get_spec $real] + #G-046: expand display-deferred field content before display use (as arg_error does) + if {[tcl::dict::exists $spec_dict DISPLAY_DEFERRED] && [tcl::dict::size [tcl::dict::get $spec_dict DISPLAY_DEFERRED]]} { + set spec_dict [private::expand_display_fields $spec_dict] + } + set fid [Dict_getdef $parsedresult form 0] + if {![tcl::dict::exists $spec_dict FORMS $fid]} { + error "punk::args::parse_report - parse result names form '$fid' which is not present in definition '$real'" + } + set ARG_INFO [tcl::dict::get $spec_dict FORMS $fid ARG_INFO] + + #-valuewidth cascade: caller option > @cmd -reportvaluewidth > built-in 48 + if {[tcl::dict::exists $opts -valuewidth]} { + set valuewidth [tcl::dict::get $opts -valuewidth] + } else { + set valuewidth [Dict_getdef $spec_dict cmd_info -reportvaluewidth 48] + } + if {![tcl::string::is integer -strict $valuewidth] || $valuewidth < 0} { + error "punk::args::parse_report -valuewidth must be an integer >= 0 (0 means no cap). Received '$valuewidth'" + } + + #per-argument landing data - parse_status_build inserts argstatus entries in + #declaration-section order (leaders, opts, values), which is the row order + set PSTAT [private::parse_status_build $spec_dict $fid -ok 1 -status valid -scheme info -parsedargs $parsedresult] + set report_rows [tcl::dict::create] + tcl::dict::for {argname AST} [tcl::dict::get $PSTAT argstatus] { + set class [tcl::dict::get $AST class] + set received [tcl::dict::get $AST received] + set hasvalue [tcl::dict::get $AST hasvalue] + set value [tcl::dict::get $AST value] + if {!$hasvalue} { + #bridge the storage-key folds the build's direct ARG_INFO-name lookup + #misses: -parsekey renames the storage key; aliased optionsets store + #under their last |-member (the finalopts fold in get_dict). G-084 owns + #the storage-key model - this is display-level bridging only. + switch -- $class { + leader {set sectionkey leaders} + option {set sectionkey opts} + default {set sectionkey values} + } + set storekey [Dict_getdef $ARG_INFO $argname -parsekey ""] + if {$storekey eq "" && [tcl::string::first | $argname] >= 0} { + set storekey [lindex [split $argname |] end] + } + if {$storekey ne "" && [tcl::dict::exists $parsedresult $sectionkey $storekey]} { + set hasvalue 1 + set value [tcl::dict::get $parsedresult $sectionkey $storekey] + } + } + if {!$hasvalue && $received == 0} { + continue ;#absent optional with no default-in-effect - no row + } + set is_multiple 0 + if {[tcl::dict::exists $ARG_INFO $argname -multiple] && [tcl::dict::get $ARG_INFO $argname -multiple]} { + set is_multiple 1 + } + tcl::dict::set report_rows $argname [tcl::dict::create\ + class $class\ + source [expr {$received > 0 ? "received" : "default"}]\ + received $received\ + positions [tcl::dict::get $AST positions]\ + multiple $is_multiple\ + hasvalue $hasvalue\ + value $value\ + ] + } + if {$returntype eq "dict"} { + #machine form - never elided + return [tcl::dict::create id $real form $fid rows $report_rows] + } + + #display forms - colour bootstrap as for arg_error (proc-local CLR seeded from + #the shared base array, scheme overrides merged into the local copy only; + #forced reload when the colour on/off state has flipped since load) + variable arg_error_CLR + set forcereload 0 + if {[info exists arg_error_CLR(testsinglecolour)]} { + set terminal_colour_is_on [expr {[string length [a+ yellow]]}] + set error_colour_is_on [expr {[string length $arg_error_CLR(testsinglecolour)]}] + if {$terminal_colour_is_on ^ $error_colour_is_on} { + set forcereload 1 + } + } + private::argerror_load_colours $forcereload + array set CLR [array get arg_error_CLR] + switch -- $scheme { + nocolour { + variable arg_error_CLR_nocolour + array set CLR [array get arg_error_CLR_nocolour] + } + info { + variable arg_error_CLR_info + array set CLR [array get arg_error_CLR_info] + } + error { + variable arg_error_CLR_error + array set CLR [array get arg_error_CLR_error] + } + } + set RST "\x1b\[0m" + + set display_rows [list] + tcl::dict::for {argname R} $report_rows { + set received [tcl::dict::get $R received] + if {$received > 0} { + set src received + if {$received > 1} {append src " x$received"} + set src_display "$CLR(check)$src$RST" + } else { + set src default + set src_display "$CLR(parsekey_hint)$src$RST" + } + set value_display "" + if {[tcl::dict::get $R hasvalue]} { + if {[tcl::dict::get $R multiple]} { + set mode list + } else { + set typelist [Dict_getdef $ARG_INFO $argname -type string] + if {"dict" in $typelist} { + set mode dict + } elseif {"list" in $typelist} { + set mode list + } else { + set mode string + } + } + set value_display [private::report_display_value [tcl::dict::get $R value] $mode $valuewidth] + } + lappend display_rows [list $argname $src $src_display $value_display] + } + + if {![catch {package require textblock}]} { + set has_textblock 1 + } else { + set has_textblock 0 + } + if {$has_textblock && $returntype in {table tableobject}} { + set t [textblock::class::table new -title "$CLR(title)[tcl::dict::get $opts -title]$RST"] + $t add_column -headers [list Argument] + $t add_column -headers [list Source] + $t add_column -headers [list Value] + foreach row $display_rows { + lassign $row argname src src_display value_display + $t add_row [list $argname $src_display $value_display] + } + $t configure -show_hseps 0 {*}{ + -show_header 1 + } -ansibase_body $CLR(ansibase_body) {*}{ + } -ansibase_header $CLR(ansibase_header) {*}{ + } -ansiborder_header $CLR(ansiborder) {*}{ + } -ansiborder_body $CLR(ansiborder) + if {$returntype eq "tableobject"} { + return $t + } + set out [$t print] + $t destroy + return $out + } + #plain lines - the string return, and the degraded path when textblock + #is unavailable + set lines [list] + foreach row $display_rows { + lassign $row argname src src_display value_display + lappend lines "$argname ($src) = $value_display" + } + return [join $lines \n] + } + + #G-151 display-cell preparation for parse_report: single-line-ize, then type-aware + #elision with honest markers ('...' + ' (len N)' for strings, '(+N more)' for + #list/-multiple elements, '(+N pairs)' for dicts). budget is a grapheme cap for + #the cell (0 = no cap); markers stay intact, so a cell can exceed a very small + #budget by the marker width - deterministic for a given value and budget, never + #console-derived. ANSI/grapheme machinery via punk::ansi::ansistring when + #available - otherwise plain-string fallbacks (caps by chars, controls as + #backslash escapes). + proc private::report_display_value {value mode budget} { + set has_ansistring [expr {[llength [info commands ::punk::ansi::ansistring]] > 0}] + switch -- $mode { + list - dict { + if {[catch {llength $value} nelem]} { + #not a valid Tcl list - display as a plain string + set mode string + } elseif {$mode eq "dict" && $nelem % 2 != 0} { + #not a valid dict - display as a plain string + set mode string + } + } + } + switch -- $mode { + list { + set reprs [list] + foreach e $value { + lappend reprs [Report_singleline [list $e] $has_ansistring] + } + set full [join $reprs " "] + if {$budget == 0 || [Report_gcount $full $has_ansistring] <= $budget} { + return $full + } + lassign [Report_capped_elements $reprs $budget $has_ansistring] shown kept + return "[join $shown { }] (+[expr {[llength $reprs] - $kept}] more)" + } + dict { + set reprs [list] + foreach {k v} $value { + lappend reprs "[Report_singleline [list $k] $has_ansistring] [Report_singleline [list $v] $has_ansistring]" + } + set full [join $reprs " "] + if {$budget == 0 || [Report_gcount $full $has_ansistring] <= $budget} { + return $full + } + lassign [Report_capped_elements $reprs $budget $has_ansistring] shown kept + return "[join $shown { }] (+[expr {[llength $reprs] - $kept}] pairs)" + } + default { + set rawlen [tcl::string::length $value] + set disp [Report_singleline $value $has_ansistring] + if {$budget == 0 || [Report_gcount $disp $has_ansistring] <= $budget} { + return $disp + } + set lenmark " (len $rawlen)" + set cap [expr {$budget - [tcl::string::length $lenmark]}] + if {$cap < 1} {set cap 1} + return [Report_truncate $disp $cap $has_ansistring]$lenmark + } + } + } + #single-line-ize for report display: linefeed/tab/cr etc as visible substitutes. + #ansistring VIEW also renders any embedded ANSI escapes visibly - neutralizing + #them so a value cannot disturb the table layout. -sp 0: real spaces stay spaces. + proc private::Report_singleline {str has_ansistring} { + if {$has_ansistring} { + return [punk::ansi::ansistring VIEW -lf 1 -vt 1 -sp 0 $str] + } + return [tcl::string::map [list \r\n {\r\n} \n {\n} \r {\r} \t {\t} \x1b {\e}] $str] + } + proc private::Report_gcount {str has_ansistring} { + if {$has_ansistring} { + return [punk::ansi::ansistring COUNT $str] + } + return [tcl::string::length $str] + } + proc private::Report_truncate {str cap has_ansistring} { + if {$has_ansistring} { + return [punk::ansi::ansistring TRUNCATE $str $cap] + } + if {[tcl::string::length $str] <= $cap} { + return $str + } + if {$cap <= 3} { + return [tcl::string::range "..." 0 [expr {$cap - 1}]] + } + return "[tcl::string::range $str 0 [expr {$cap - 4}]]..." + } + #whole-element capping for list/dict display: keep as many whole element reprs as + #fit a budget that reserves nominal room for the trailing count marker; always + #keep at least one (char-capped if it alone overflows). Returns {shown kept}. + proc private::Report_capped_elements {reprs budget has_ansistring} { + set reserve 12 + set avail [expr {$budget - $reserve}] + if {$avail < 1} {set avail 1} + set shown [list] + set used 0 + foreach repr $reprs { + if {[llength $shown] == 0} { + if {[Report_gcount $repr $has_ansistring] > $avail} { + set repr [Report_truncate $repr $avail $has_ansistring] + } + lappend shown $repr + set used [Report_gcount $repr $has_ansistring] + continue + } + set rlen [Report_gcount $repr $has_ansistring] + if {$used + 1 + $rlen > $avail} { + break + } + lappend shown $repr + incr used [expr {1 + $rlen}] + } + return [list $shown [llength $shown]] + } + #return number of values we can assign to cater for variable length clauses such as: # {"elseif" expr "?then?" body} #review - efficiency? each time we call this - we are looking ahead at the same info @@ -14627,7 +15081,7 @@ package provide punk::args [tcl::namespace::eval punk::args { tcl::namespace::path {::punk::args::lib ::punk::args::system} variable pkg punk::args variable version - set version 0.17.0 + set version 0.18.0 }] return diff --git a/src/bootsupport/modules/punk/mix/templates-0.2.0.tm b/src/bootsupport/modules/punk/mix/templates-0.2.0.tm index 4dfdbed0..9e9fbaaf 100644 Binary files a/src/bootsupport/modules/punk/mix/templates-0.2.0.tm and b/src/bootsupport/modules/punk/mix/templates-0.2.0.tm differ diff --git a/src/vfs/_vfscommon.vfs/lib/app-punkscript/pkgIndex.tcl b/src/vfs/_vfscommon.vfs/lib/app-punkscript/pkgIndex.tcl index f731b9c1..d210d2d5 100644 --- a/src/vfs/_vfscommon.vfs/lib/app-punkscript/pkgIndex.tcl +++ b/src/vfs/_vfscommon.vfs/lib/app-punkscript/pkgIndex.tcl @@ -1 +1,9 @@ -package ifneeded app-punkscript 1.1 [list source [file join $dir punkscript.tcl]] +package ifneeded app-punkscript 1.3 [list source [file join $dir punkscript.tcl]] +#G-169: docs-only load of the same file - the execution gate in punkscript.tcl +#returns before the app body, so requiring the docs never runs a script. Used +#by the launcher's help path (guarded lazy require in punkboot_core.tcl). +package ifneeded app-punkscript-docs 1.3 [list ::apply {{dir} { + namespace eval ::app::punkscript {variable docs_only 1} + source [file join $dir punkscript.tcl] + namespace eval ::app::punkscript {variable docs_only 0} +}} $dir] diff --git a/src/vfs/_vfscommon.vfs/lib/app-punkscript/punkscript.tcl b/src/vfs/_vfscommon.vfs/lib/app-punkscript/punkscript.tcl index ee579c25..7fb60a20 100644 --- a/src/vfs/_vfscommon.vfs/lib/app-punkscript/punkscript.tcl +++ b/src/vfs/_vfscommon.vfs/lib/app-punkscript/punkscript.tcl @@ -1,4 +1,127 @@ -package provide app-punkscript 1.1 +#G-169: launch-subcommand documentation lives WITH the parsing code it describes +#(co-location kills the doc-drift class): this file carries the +#(script)::punkexe::script punk::args definition and registers it inertly - a +#plain namespace-variable write plus a membership-guarded registration lappend. +#No package requires, no punk::args dependency, no definition evaluation here +#(the G-030 degradation doctrine; punk::args evaluates the definition lazily on +#the first help/usage query naming the id - the docpackages mechanism). +#Two load contexts share this file (see pkgIndex.tcl): +# package require app-punkscript the app: docs section, then the script +# runner body below +# package require app-punkscript-docs docs only - the launcher's help path +# (guarded lazy require in +# punkboot_core.tcl launcher_show_help): +# the execution gate below returns before +# the app body, so requiring the docs +# NEVER runs a script +#The docs section is idempotent (set + guarded lappend): a docs-then-app +#require sequence re-sources this file without duplicate registrations. +namespace eval ::app::punkscript::argdoc { + variable PUNKARGS + #single definer for (script)::punkexe::script (G-169 handover from the + #punkexe moduledoc, which no longer defines it). The G-168 selection-sound + #shape is contract - guarded by the args-tier punkexemodel.test / + #formcheck.test pins: the file form fences scriptfile against exactly '-e' + #(with the ./-e message), the oneliner form declares '-e' as a required + #solo flag so a bare '-e' verdicts viable-incomplete. + #NOTE: the braced definition below keeps the punkexe moduledoc's internal + #indentation (directives at 12, continuations at 16/18) - braced -help + #values carry their leading whitespace into the render. (The G-169 + #handover moved the text byte-identical; the first deliberate post- + #handover divergence is G-170's -formhint template below.) + set PUNKARGS [list [list { + @id -id (script)::punkexe::script + #-formhint (G-170, replacing the interim suppression): the hint + #names real navigation - ' help script ' narrows + #the launcher's render to that form (exact form name, or decimal + #ordinal; a file literally named like a form stays dry-runnable as + #./oneliner - the ./-e reachability rule). Deliberately NO trailing + #ellipsis (display-hint honesty): words after the form word are not + #a continuation of the narrowed render - they flip the tail into + #the dry-run interpretation. + @cmd -name "punkexe script" -& + -summary -& + "Run a script non-interactively in the punk script environment." -& + -formhint " help script %formname%" -& + -help -& + "Run a Tcl script and exit with an honest exit code + (app-punkscript): script errors exit nonzero and the launch + plumbing emits nothing of its own on stdout/stderr, so + exec-style callers see only the script's output. The default + punk module/alias environment is loaded (unlike 'tclsh'). + + The script comes from the file argument, from piped stdin when + no argument is given (terminal stdin without a script argument + is a usage error - no interactive fallback), or via scriptlib + resolution when the argument has the form 'lib:name' (resolved + from the scriptlib locations associated with the executable, + including extensionless and shebang'd scripts). + + A first argument of exactly '-e' runs the SECOND argument as a + Tcl one-liner (G-077): non-interactive, ::argv0 is '-e', + arguments after the script text land in ::argv, and a + non-empty final result is echoed to stdout (the same one-shot + eval ergonomics as the stdin form). '-e' with no following + script argument is a usage error - never an interactive + fall-through, and never stock tclsh's -e misparse (argv-swallow + plus stdin read). The form works with or without piped stdin + present; stdin is read only if the one-liner itself reads it. + A file literally named '-e' stays reachable as './-e'. The + same one-liner is available at the top level as + ' -e