Browse Source

punk::path 0.5.0 + punkshell 0.49.0: subfolders1 removed, ***-tail excludes prune, glob-doc consistency, coverage 40->67; PUNKARGS -& layout

punk::path 0.5.0:
- subfolders1 REMOVED - no call sites repo-wide; subfolders is the reviewed implementation
- subfolders ***-tail -exclude-paths patterns now classify as pruning subtree rules: X/*** is handled as the equivalent pair {X/** X} - identical results (the *** descend test alone omits and prunes the boundary), walk no longer descends excluded subtrees (~250x measured on a 1110-dir excluded subtree); bare *** exclude short-circuits like bare **
- strip_prefixdepth fixed: called the nonexistent 'norm' since its 2024-08-12 copy from punk::repo::path_strip_prefixdepth (kettle::path::strip lineage) - now uses the textual punk::path::normjoin; depth-only no-content-compare contract kept; prefix-exhausts-path boundary still errors (zero-arg file join), characterised not changed
- missing punk::winpath dependency declared (normjoin calls is_dos_device_path on every input - errored in a minimal interp that required only punk::path)
- PUNKARGS consistency pass: *|**|***|? path glob syntax centralised in argdoc::PATHGLOB_SEGMENT_SYNTAX (tstr placeholder fragment) shared across pathglob_as_re/globmatchpath/subfolders/treefilenames help; new argdocs for pathglob_as_re, normjoin and strip_prefixdepth; globmatchpath summary corrected (*** added) and doctools [fun] markup removed from punk::args help; treefilenames no longer claims unsorted results (-sort and zipfs dispatch documented), -exclude-paths/-exclude-files/-include-paths typed -type list, tail globs documented as ordinary Tcl globs
- path.test 40->67: coverage for previously-untested in-use functions (relative, normjoin, pathtype, scriptlib_resolve, treefilenames_zipfs with zipfs mkzip/mount fixture) plus globmatchpath ?/-nocase variants, treefilenames -exclude-files and -sort order pins, subfolders ***-tail/pair-equivalence/guard/classifier pins, strip_prefixdepth characterisation

Docs/conventions:
- src/modules/AGENTS.md: PUNKARGS Procedure Documentation Template converted to the -& record-continuation layout; -& stated as preferred for new work (existing blocks may keep backslash continuations)
- punk::path argdoc blocks converted to the aligned -& layout
- src/modules/punk/AGENTS.md: shared glob-syntax fragment mechanism (flush-left authoring rule, resolved_def consumer caveat) + subfolders1 removal recorded

punkshell 0.49.0 (punkproject.toml + CHANGELOG.md entry).

Verified: path.test 67/67 under tclsh90; all 10 documented punk::path ids render clean on punk91 (tcl9) and punk86 (8.6); punk::LOC resolved_def consumer of treefilenames -exclude-paths intact.

Assisted-by: harness=claude; primary-model=claude-fable-5; api-location=anthropic.com
master
Julian Noble 1 week ago
parent
commit
ed17fb6c34
  1. 23
      CHANGELOG.md
  2. 2
      punkproject.toml
  3. 30
      src/modules/AGENTS.md
  4. 1
      src/modules/punk/AGENTS.md
  5. 588
      src/modules/punk/path-999999.0a1.0.tm
  6. 4
      src/modules/punk/path-buildversion.txt
  7. 405
      src/tests/modules/punk/path/testsuites/tests/path.test

23
CHANGELOG.md

@ -5,6 +5,29 @@ 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.49.0] - 2026-08-03
- punk::path 0.5.0: `subfolders1` REMOVED (superseded by `subfolders`;
no call sites existed repo-wide). `subfolders` ***-tail exclude
patterns now prune the walk - `X/***` is handled internally as the
equivalent pair `{X/** X}` - giving identical results while no longer
descending the excluded subtree (measured ~250x faster on a bulky
excluded tree); a bare `***` exclude now short-circuits to an empty
result with a warning, like bare `**`. The missing punk::winpath
dependency is now declared (normjoin errored in a minimal interp that
required only punk::path). Help consistency pass: the *|**|***|? path
glob syntax is documented once (shared argdoc text fragment) across
globmatchpath, subfolders, treefilenames and the new pathglob_as_re
argdoc; treefilenames no longer claims unsorted results and its list
options are typed. `strip_prefixdepth` FIXED: it called a nonexistent
`norm` (broken since its 2024 copy from
punk::repo::path_strip_prefixdepth) and now uses the textual
punk::path::normjoin; normjoin and strip_prefixdepth gain argdocs.
Module test suite extended 40->67 tests, now covering every
punk::path function in use in the codebase (relative, normjoin,
pathtype, scriptlib_resolve and treefilenames_zipfs had no direct
tests) plus strip_prefixdepth characterisation.
## [0.48.0] - 2026-08-03
- Launcher help + parsed subcommands (G-032, boot core 0.3.0): the kit

2
punkproject.toml

@ -1,6 +1,6 @@
[project]
name = "punkshell"
version = "0.48.0"
version = "0.49.0"
license = "BSD-2-Clause"
url = "https://www.gitea1.intx.com.au/jn/punkshell"
#packager: declared identity for published artifacts (declarative, not proof -

30
src/modules/AGENTS.md

@ -113,29 +113,31 @@ dict create {*}[
```tcl
#- comments such as this with a leading dash are descriptions for the agent of how-to/what-to to implement
#- whereas comments without the dash are either literal comments, or if angle-bracketed, descriptions of what to put in the comment.
#- within the list of documentation blocks a trailing -& is a record continuation marker.
#- The -& record-continuation marker is preferred for new work. Existing blocks may use the Tcl line-continuation mechanism instead.
namespace eval argdoc {
lappend PUNKARGS [list {
@id -id ::full::namespace::path::procedure_name
@cmd -name "full::namespace::path::procedure_name"\
-summary\
"Short single line procedure description"\
-help\
"Procedure summary without too much overlap with documented
arguments below.
Existing
- include description of return value"
@cmd -name "full::namespace::path::procedure_name" -&
-summary -&
"Short single line procedure description" -&
-help -&
"Procedure summary without too much overlap with documented
arguments below.
Existing
- include description of return value"
@leaders
arg1 -type any -optional 0 -multiple 0 -help\
arg1 -type any -optional 0 -multiple 0 -help -&
"description of mandatory leading argument arg1"
@opts
-force -type boolean
-flag1 -type none -help\
-flag1 -type none -help -&
"description of non-argument accepting flag -flag1"
-- -type none -help\
-- -type none -help -&
"End of opts marker"
@values -min 1 -max -1
arg2 -type list -optional 0 -help\
arg2 -type list -optional 0 -help -&
{Description of mandatory arg arg2
possibly spanning multiple lines, but each line should
generally be wrapped manually to keep sensibly short
@ -143,7 +145,7 @@ dict create {*}[
Help blocks may be wrapped in curly braces or double quotes.
See the punk::args module for details of acceptable structure.
}
glob -type string -optional 1 -multiple 1 -help\
glob -type string -optional 1 -multiple 1 -help -&
"Description of optional argument glob which can be repeated.
Indenting of argument -help contents should follow this example.
Note that we indent subsequent lines by 2 additional spaces,

1
src/modules/punk/AGENTS.md

@ -36,6 +36,7 @@ Source of truth for all modules under the `punk::*` namespace. This is the prima
- punk::console powershell console-mode fallback (G-106, achieved 2026-07-22): on twapi-less windows runtimes, enableRaw/disableRaw are served by a persistent pwsh/powershell named-pipe server (`punk::console::system::ps_consolemode_*` procs). Contract points: the server starts lazily on first use (module load must never spawn processes or emit noise), is a process-wide singleton via tsv `punk_console ps_server_*` under `tsv::lock`, watches the owning process pid and exits with it (no orphan cleanup needed), and is quiet by default (`PUNK_PS_CONSOLEMODE_DEBUG=1` for diagnostics both sides; the spawn redirects child stdout/stderr to NUL but stdin MUST stay inherited - the console input handle is how the server reaches the console). Script resolution: env `PUNK_PS_CONSOLEMODE_SCRIPT` -> argv0-derived -> module-dir-derived -> the embedded copy in the module; the canonical maintained script is `scriptlib/utils/pwsh/consolemode_server_async.ps1` and the embedded copy must stay in sync with it - pinned by `src/tests/modules/punk/console/testsuites/console/psfallback.test`, so edit both together (delivery is powershell `-c <text>`, deliberately not a file: kits carry nothing on disk and ExecutionPolicy cannot block it). Verification recipe: goals/archive/G-106-powershell-consolemode-fallback.md Notes.
- punk::console has a console ownership registry (G-007): `console_owner_register`/`console_owner_get`/`console_owner_forget`, tsv `punk_console_owners` keyed by canonical {in out} pair. Ownership is captured when an opunk::console instance is anchored (via `::opunk::console::lifecycle_callback`, wired by `ensure_object_integration`) and by `default_console`; an unregistered console reads as "operate locally". For `{stdin stdout}` first registration wins and only the owner's forget releases the entry. Owner liveness is validated at consult time. The `dec_has_mode`/`ansi_has_mode` caches live in tsv `punk_console_modecache`.
- punk::console terminal queries are owner-routed (G-007 choke-point brokering): `internal::get_ansi_response_payload` consults `internal::console_route_owner` after spec resolution and forwards the whole call to the console-owning thread via synchronous `thread::send` when the caller is not the owner, so queueing, raw-mode cycling and cooperative reader handling execute in the owner's context and every query proc above the choke point inherits the routing. Routing applies to the default console `{stdin stdout}` only: non-std channel names are thread-local, so an {in out} pair spec names the calling thread's own console and always operates locally (unregistered/self-owned/dead-owner likewise - single-interp behaviour is unchanged). The synchronous send relies on the owner servicing events while the caller blocks (the repl does this while a codethread runs - same property as the repl-installed vt52/colour/mode aliases, which are unaffected because a call arriving in the owner resolves to owner==self). Tests live in `src/tests/modules/punk/console/testsuites/console/ownerrouting.test`.
- punk::path glob-pattern documentation is centralised (2026-08-03, path 0.5.0): the `*|**|***|?` path glob syntax text lives in the `::punk::path::argdoc::PATHGLOB_SEGMENT_SYNTAX` variable, referenced from PUNKARGS `-help` bodies via `${$::punk::path::argdoc::PATHGLOB_SEGMENT_SYNTAX}` placeholders (author such shared text with continuation lines flush-left - substitution prepends the placeholder line's indent). Extend it rather than re-duplicating glob syntax text in punk::path definitions. External definitions may pull punk::path option defs via `punk::args::resolved_def` (`punk::LOC` pulls treefilenames' `-exclude-paths`), so option help must not depend on surrounding-command context (e.g say "see punk::path::pathglob_as_re", not "as described above"). `subfolders1` was removed at path 0.5.0 (subfolders is the only folder-listing function; older snapshot tiers still carry it until promotion) and subfolders' ***-tail excludes decompose internally to pruning subtree rules - do not reintroduce a non-pruning *** path.
- Use `punk::args::parse` with `@id` references in `argdoc` namespaces for public API procs.
- Private helpers go in `namespace eval private { ... }` blocks.
- Keep `namespace export` lists alphabetized.

588
src/modules/punk/path-999999.0a1.0.tm

@ -46,9 +46,11 @@
package require Tcl 8.6-
package require punk::args
package require punk::winpath ;#is_dos_device_path - called by normjoin on every input
#*** !doctools
#[item] [package {Tcl 8.6-}]
#[item] [package {punk::args}]
#[item] [package {punk::winpath}]
# #package require frobz
# #*** !doctools
@ -184,7 +186,41 @@ namespace eval punk::path {
#normjoin c: should theoretically return current per drive working directory on c:
# - would need to use win32 GetFullPathName to resolve this.
punk::args::define {
@id -id ::punk::path::normjoin
@cmd -name punk::path::normjoin -&
-summary -&
"Join and normalize path parts as a string operation, respecting server/scheme/volume roots." -&
-help -&
"Joins the supplied parts and simplifies . and .. segments as far as
possible, as a purely textual operation - no filesystem, vfs or
zipfs-mount knowledge is consulted (so e.g windows shortnames and
per-drive working directories are preserved rather than resolved,
and case is never normalized).
Backslashes in the parts are converted to forward slashes.
Relative results are returned with a leading ./ segment (this helps
prevent inadvertent conversion to absolute paths on rejoining) and
unresolvable leading .. segments of a relative result are preserved.
Unlike 'file normalize', a leading //server, scheme:// (e.g https://),
//./<dosdevice>, <driveletter>:/ or / is treated as a root that ..
cannot backtrack above, and //server style paths are not treated as
volumerelative on windows (the ambiguous bare //server form draws a
warning to stderr there).
A bare volume/scheme result gains a trailing slash (c: -> c:/,
//zipfs: -> //zipfs:/); with no parts at all an empty string is
returned, and an empty-string part contributes '.'.
If the final segment has a .lnk extension and punk::winlnk is
available, the windows shortcut's target is returned when resolvable
(otherwise the .lnk path itself).
Known textual-analysis edge cases (e.g d://a/b is treated as a
scheme like https://a/b) are recorded in the comment block above
this definition in the module source."
@values -min 0 -max -1
part -type string -optional 1 -multiple 1 -help -&
"path parts to join and normalize"
}
proc normjoin {args} {
#see PUNKARGS id ::punk::path::normjoin (documentation-only - variadic parts)
set args [lmap a $args {string map "\\\\ /" $a}]
set path [plainjoin {*}$args]
switch -exact -- $path {
@ -488,53 +524,53 @@ namespace eval punk::path {
namespace eval argdoc {
lappend PUNKARGS [list {
@id -id ::punk::path::scriptlib_resolve
@cmd -name "punk::path::scriptlib_resolve"\
-summary\
"Resolve a scriptlib script name to a path using the punk kit search policy"\
-help\
"Resolves a relative script name (the part after a 'lib:' prefix
in punk executable launch arguments) against the scriptlib
locations, in the same order the punk shell uses:
1. the kit vfs internal scriptlib (<zipfs-root>/app/scriptlib,
or <exedir>/scriptlib for an unwrapped kit directory).
Internal scripts are deliberately NOT overridable by
external files (integrity and performance).
2. <exedir-parent>/scriptlib - e.g <projectroot>/scriptlib for
an executable in <projectroot>/bin.
3. <exedir-grandparent>/scriptlib.
If the name's extension (lowercased) is one of the known
scriptlib extensions (.tcl .kit .py .pl .ps1 .sh), only that
exact name is searched. Otherwise two spellings are tried within
EACH location before moving to the next (so the kit-internal
precedence above is never crossed by a spelling preference):
1. the exact name as called - accepted only if the file's
first lines identify it as a tcl script: a leading
'# tcl' comment, a shebang naming tclsh/tclkit/wish, or
the portable sh-trampoline ('#!/bin/sh' followed by a
commented 'exec tclsh ...' in the next lines).
2. the name with '.tcl' appended.
An exact-name file that fails the tcl identification check is
skipped (recorded in notes) and resolution falls through.
Returns a dict with keys:
path - resolved absolute path, or empty string if not found
scriptname - the relative name that matched; when not found,
the primary name searched for
scripttype - resolved script type: the extension without its
dot (tcl, kit, py, pl, ps1, sh), 'tcl' for an
extensionless file that passed the identification
check, or empty string when not found
candidates - the locations that were (or would be) checked,
for use in error messages
notes - explanatory notes (e.g files skipped by the tcl
identification check), for use in error messages.
Raises an error if name is not a relative path."
@cmd -name "punk::path::scriptlib_resolve" -&
-summary -&
"Resolve a scriptlib script name to a path using the punk kit search policy" -&
-help -&
"Resolves a relative script name (the part after a 'lib:' prefix
in punk executable launch arguments) against the scriptlib
locations, in the same order the punk shell uses:
1. the kit vfs internal scriptlib (<zipfs-root>/app/scriptlib,
or <exedir>/scriptlib for an unwrapped kit directory).
Internal scripts are deliberately NOT overridable by
external files (integrity and performance).
2. <exedir-parent>/scriptlib - e.g <projectroot>/scriptlib for
an executable in <projectroot>/bin.
3. <exedir-grandparent>/scriptlib.
If the name's extension (lowercased) is one of the known
scriptlib extensions (.tcl .kit .py .pl .ps1 .sh), only that
exact name is searched. Otherwise two spellings are tried within
EACH location before moving to the next (so the kit-internal
precedence above is never crossed by a spelling preference):
1. the exact name as called - accepted only if the file's
first lines identify it as a tcl script: a leading
'# tcl' comment, a shebang naming tclsh/tclkit/wish, or
the portable sh-trampoline ('#!/bin/sh' followed by a
commented 'exec tclsh ...' in the next lines).
2. the name with '.tcl' appended.
An exact-name file that fails the tcl identification check is
skipped (recorded in notes) and resolution falls through.
Returns a dict with keys:
path - resolved absolute path, or empty string if not found
scriptname - the relative name that matched; when not found,
the primary name searched for
scripttype - resolved script type: the extension without its
dot (tcl, kit, py, pl, ps1, sh), 'tcl' for an
extensionless file that passed the identification
check, or empty string when not found
candidates - the locations that were (or would be) checked,
for use in error messages
notes - explanatory notes (e.g files skipped by the tcl
identification check), for use in error messages.
Raises an error if name is not a relative path."
@leaders
name -type string -optional 0 -help\
"relative script name, with or without a known extension
(any leading lib: prefix should already be stripped)"
name -type string -optional 0 -help -&
"relative script name, with or without a known extension
(any leading lib: prefix should already be stripped)"
}]
}
proc scriptlib_resolve {name} {
@ -675,14 +711,50 @@ namespace eval punk::path {
#proc filepath_dotted_dirname {path} {
#}
punk::args::define {
@id -id ::punk::path::strip_prefixdepth
@cmd -name punk::path::strip_prefixdepth -&
-summary -&
"Strip as many leading segments from path as prefix has segments (depth-based, content not compared)." -&
-help -&
"Returns path, normjoin-normalized, with its first N segments removed -
where N is the number of 'file split' segments of the normjoin-normalized
prefix. Only the prefix's DEPTH is used: its segment content is not
compared against path (a deliberate speed tradeoff inherited from
kettle::path::strip via punk::repo::path_strip_prefixdepth - callers are
expected to already know path lies under prefix).
This is the purely textual punk::path variant: normalization is
punk::path::normjoin, so relative inputs stay relative (gaining a
leading ./ segment which then counts towards depth on whichever sides
are relative). Pass path and prefix of the same pathtype (both
relative or both absolute) for intuitive results.
punk::repo::path_strip_prefixdepth is the filesystem-normalizing
variant ('file normalize' based - absolutizes relative inputs against
the current directory).
An empty prefix returns the normjoin-normalized path unchanged.
Segment counting uses 'file split', so root/volume segmentation
follows the platform's rules (e.g //server/share/a splits as
{//server/share a} on windows but {/ server share a} on unix).
If prefix has as many or more segments than path, nothing remains and
an error is raised (file join with no arguments)."
@leaders -min 2 -max 2
path -type string -help -&
"path to strip leading segments from"
prefix -type string -help -&
"path whose depth (segment count after normjoin + file split)
determines how many leading segments are removed"
}
proc strip_prefixdepth {path prefix} {
#see PUNKARGS id ::punk::path::strip_prefixdepth (documentation-only - positional args)
#deliberately the textual normjoin, not 'file normalize' - punk::repo::path_strip_prefixdepth
#is the filesystem-normalizing sibling of this proc
if {$prefix eq ""} {
return [norm $path]
return [normjoin $path]
}
return [file join {*}{
} {*}[lrange {*}{
} [file split [norm $path]] {*}{
} [llength [file split [norm $prefix]]] {*}{
} {*}[lrange {*}{
} [file split [normjoin $path]] {*}{
} [llength [file split [normjoin $prefix]]] {*}{
} end
]
]
@ -722,7 +794,54 @@ namespace eval punk::path {
# }
# return "^[join $pats ::]\$"
#}
namespace eval argdoc {
#Canonical description of the path glob syntax implemented by pathglob_as_re
#and consumed by globmatchpath, treefilenames/treefilenames_zipfs and the
#subfolders -exclude-paths patterns.
#Referenced from PUNKARGS -help fields via the tstr placeholder:
# ${$::punk::path::argdoc::PATHGLOB_SEGMENT_SYNTAX}
#Continuation lines are deliberately unindented: placeholder substitution
#prepends the placeholder line's own indentation to every line after the
#first, so indented continuations here would render double-indented.
variable PATHGLOB_SEGMENT_SYNTAX {The glob elements *, **, *** and ? may appear in any segment of a path pattern:
* matches any single segment, or any span of characters within a segment
(/usr/*/bin matches /usr/local/bin but not /usr/bin or /usr/a/b/bin;
/usr/te*t matches /usr/test and /usr/teeeet but not /usr/te/t)
** as a whole segment matches 1 or more segments
(/usr/**/bin matches /usr/x/bin and /usr/x/y/bin but not /usr/bin.
Within a segment ** also spans segments: /usr/**.txt matches .txt files
at any depth below /usr)
*** as a whole segment matches 0 or more segments
(/usr/*** matches /usr itself as well as everything below it)
? matches any single character within a segment
(/usr/te?t matches /usr/test and /usr/text but not /usr/texxt or /usr/te/t)
* and ? are treated as glob characters wherever they appear in the pattern
- escaping is not supported. All other characters, including square brackets,
match as literals (unlike Tcl glob patterns).}
}
punk::args::define {
@id -id ::punk::path::pathglob_as_re
@cmd -name punk::path::pathglob_as_re -&
-summary -&
"Return a regular expression for matching paths to a *|**|***|? path glob pattern." -&
-help -&
"Returns an anchored regular expression string for matching a path to a
glob pattern which can contain the glob elements *|**|***|? in any
segment of the path structure.
${$::punk::path::argdoc::PATHGLOB_SEGMENT_SYNTAX}
The pathglob doesn't have to contain glob characters - a pathglob
without them produces a regex matching that path exactly.
Regular expression syntax is deliberately not supported within the
pathglob string - supplied regex characters are treated as literals.
This is the engine behind punk::path::globmatchpath and the path
pattern matching of the treefilenames/subfolders family."
@leaders -min 1 -max 1
pathglob -type string -optional 0 -help -&
"path glob pattern"
}
proc pathglob_as_re {pathglob} {
#see PUNKARGS id ::punk::path::pathglob_as_re (documentation-only - single positional arg)
#*** !doctools
#[call [fun pathglob_as_re] [arg pathglob]]
#[para] Returns a regular expression for matching a path to a glob pattern which can contain glob chars *|**|***|? in any segment of the path structure
@ -799,52 +918,40 @@ namespace eval punk::path {
punk::args::define {
@id -id ::punk::path::globmatchpath
@cmd -name punk::path::globmatchpath\
-summary\
"Match path to *|**|? glob patterns"\
-help\
"Return a boolean indicating whether the path matches the specialised glob pattern.
A pattern such as /usr/*/bin will match any path that has /usr as the first segment and bin as the third segment,
with any single segment in between.
A pattern such as /usr/**/bin will match any path that has /usr as the first segment and bin as the last segment,
with 1 or more segments in between (so it will not match /usr/bin).
A pattern such as /usr/** will match any path that has /usr as the first segment, with 1 or more segments
following (so it will not match /usr itself).
A pattern such as /usr/*** will match /usr itself as well as any path below /usr
- *** as a whole segment matches zero or more segments (added 2026-07-20, G-093).
A pattern such as **/*.txt will match any path that ends with .txt, with 1 or more leading segments
(so it will not match test.txt or .txt). Use ***/*.txt to also match a bare test.txt.
A pattern such as ** will match any path.
The glob characters * and ? (and the whole-segment forms ** and ***) are the only special characters in the pathglob syntax.
- they are treated as glob characters regardless of where they appear in the pathglob string.
Note that this is different from other Tcl glob contexts where square brackets can be used.
The pathglob syntax treats other characters, including square brackets as literals.
For example, the pattern /usr/te?t will match /usr/test and /usr/text but not /usr/texxt, and the pattern /usr/te*t
will match /usr/test, /usr/teat, and /usr/teeeet but not /usr/te/t.
The pathglob syntax does not support escaping of glob characters - any glob characters in the pathglob are treated
as glob characters. For example, the pattern /usr/* will match any path that has /usr as the first segment and any
single segment as the second segment, but there is no way to specify a pattern that matches any path that has /usr
as the first segment and a literal * as the second segment.
Caller must ensure that file separator is forward slash. (e.g use file normalize on windows)
options:
-nocase 0|1 (default 0 - case sensitive)
If -nocase is not supplied - default to case sensitive *except for driveletter*
ie - the driveletter alone in paths such as c:/etc will still be case insensitive. (ie c:/ETC/* will match C:/ETC/blah but not C:/etc/blah)
Explicitly specifying -nocase 0 will require the entire case to match including the driveletter.
"
@cmd -name punk::path::globmatchpath -&
-summary -&
"Match path to *|**|***|? glob patterns" -&
-help -&
"Return a boolean indicating whether the path matches the specialised
glob pattern.
${$::punk::path::argdoc::PATHGLOB_SEGMENT_SYNTAX}
Further whole-pattern examples:
/usr/** will match any path that has /usr as the first segment and 1
or more segments following (so it will not match /usr itself).
/usr/*** will match /usr itself as well as any path below /usr.
**/*.txt will match any path that ends with .txt, with 1 or more
leading segments (so it will not match test.txt or .txt). Use
***/*.txt to also match a bare test.txt.
** will match any path.
Caller must ensure that file separator is forward slash. (e.g use
file normalize on windows)"
@leaders
pathglob -type string -help "glob pattern to match path against. See [fun pathglob_as_re] for syntax of glob patterns"
pathglob -type string -help -&
"glob pattern to match path against (syntax as described above -
punk::path::pathglob_as_re documents the underlying conversion)"
path -type string -help "path to match against glob pattern"
@opts
-nocase -type boolean -default 0 -help\
-nocase -type boolean -default 0 -help -&
"case insensitive matching (default false - case sensitive)
- except for driveletter on windows which is always case insensitive
unless -nocase 0 is explicitly specified"
- except for the driveletter on windows paths: when -nocase is not
supplied, matching is case sensitive EXCEPT for a leading
driveletter (c:/ETC/* will match C:/ETC/blah but not C:/etc/blah).
Explicitly specifying -nocase 0 requires the entire case to match
including the driveletter."
@values -min 0 -max 0
}
# -id
proc globmatchpath {pathglob path args} {
#see PUNKARGS id ::punk::path::globmatchpath (documentation-only - args parsed manually below; keep synchronized)
#*** !doctools
#[call [fun globmatchpath] [arg pathglob] [arg path] [opt {option value...}]]
#[para] Return true if the pathglob matches the path
@ -899,120 +1006,15 @@ namespace eval punk::path {
}
return $ismatch
}
punk::args::define {
@id -id ::punk::path::subfolders1
@cmd -name punk::path::subfolders1\
-summary\
"Listing of directories below supplied path."\
-help\
"List of folders below path.
The resulting list is unsorted."
@opts
-recursive -type none -help\
""
-exclude-paths -type list -default {} -help\
"list of path patterns to exclude from results.
May include * and ** path segments e.g /usr/**
A single /*/ will match any single segment in the path, and a single /**/ will match any number of segments in the path.
e.g to exclude any path with _aside as a segment in the middle: -exclude-paths **/_aside/**
i.e this would exclude /usr/_aside/etc and /usr/x/_aside/etc but not /usr/x/_aside or _aside/etc
To exclude all paths with _aside as a segment anywhere: -exclude-paths { **/_aside/** **/_aside _aside/**}
"
#todo -depth
@values -min 0 -max 1
path -type directory -optional 1 -help\
"Path of folder. If not supplied current directory is used.
This may be a relative or absolute path. Relative paths are treated as relative to current directory.
When using relative paths - the result will also be relative paths with the same relative prefix.
(e.g if path is ../test - the results will be ../test/subfolder1 ../test/subfolder2 etc)
Patterns in -exclude-paths are matched against the resulting paths
(so should be written to match the same relative prefix if path is relative)"
}
proc subfolders1 {args} {
#NOTE - this algorithm based on omit_only_patterns and prune_base_patterns was suggested by a 2026 AI model - it is apparent to this programmer that it is inadequate for the purpose.
#e.g consider subfolders1 -recursion -exclude {**/vfs/** **/src/**}
#This can still return something like c:/repo/etc/src/vfs - which should be excluded by the pattern **/src/**
#todo - review and fix properly.
set argd [punk::args::parse $args withid ::punk::path::subfolders1]
lassign [dict values $argd] leaders opts values received
set do_recursion [dict exists $received -recursive]
set exclude_paths [dict get $opts -exclude-paths]
if {"**" in $exclude_paths} {
#if ** is in exclude_paths - then we can skip all glob matching and just return empty list
#This is likely user error - so we'll be loud about it for now but will still return empty list rather than erroring.
#If user code is building exclude_paths dynamically - they can check for this case themselves and avoid the call to subfolders1 to suppress this message.
puts stderr "punk::path::subfolders1 Warning - exclude_paths contains '**' - all paths will be excluded"
return [list]
}
if {[dict exists $received path]} {
set path [dict get $values path]
} else {
set path [pwd]
}
set all_subfolders [glob -nocomplain -directory $path -types d *]
#example of expected exclude_paths pattern behaviour when recursion is enabled:
# **/dirname -> omit /x/y/dirname, but still visit /x/y/dirname/*
# **/dirname/* -> include /x/y/dirname and /x/y/dirname/a/b but omit directories that are a single level below /x/y/dirname such as /x/y/dirname/a
#c:/** - would exclude all subfolders below c: but not c: itself
# **/test/** - would exclude any path with test as a segment and all its subfolders
#- but not paths with test as a segment that is the final segment
set folders [list]
set recurse_subdirs [list]
foreach f $all_subfolders {
set include_in_results 1
set allow_recurse 1
foreach pat $exclude_paths {
set pat_parts [file split $pat] ;#note file split c:/test gives {c:/ test} but file split **/test gives {** test}
#also note that file split on windows treats forward slashes and backslashes the same.
#by using file split, we gain some flexibility in syntax of paths and patterns,
#but lose the ability to use backslashes as escapes to allow literal glob characters in path segments.
#This is almost always a non-issue on windows since * and ? are not valid in path segments there, and is rarely an issue on unix even though
# * and ? are technically valid in path segments, but it is inadvisable there anyway for compatibility with shells etc.
if {[llength $pat_parts] >= 2 && [lindex $pat_parts end] eq "**"} {
set base_pat [file join {*}[lrange $pat_parts 0 end-1]]
if {[globmatchpath $pat $f]} {
set include_in_results 0
set allow_recurse 0
} elseif {[globmatchpath $base_pat $f]} {
set allow_recurse 0
}
} elseif {[globmatchpath $pat $f]} {
set include_in_results 0
}
if {!$include_in_results && !$allow_recurse} {
break
}
}
if {$include_in_results} {
lappend folders $f
}
if {$allow_recurse} {
lappend recurse_subdirs $f
}
}
if {$do_recursion} {
foreach subdir $recurse_subdirs {
lappend folders {*}[subfolders1 -exclude-paths $exclude_paths -recursive $subdir]
}
}
return $folders
}
namespace eval subfolder_priv {
proc classify_exclude_pattern {pat} {
set parts [file split $pat]
if {[llength $parts] >= 2 && [lindex $parts end] eq "**"} {
if {[llength $parts] >= 2 && [lindex $parts end] in {** ***}} {
#a trailing *** full-matches the boundary folder too (zero segments
#below), so the descend test alone omits the boundary and prunes -
#this IS the decomposition of X/*** to the pair {X/** X}. A trailing
#** matches only below the boundary; the boundary branch then keeps
#the boundary folder while still pruning descent.
set boundary_pat [file join {*}[lrange $parts 0 end-1]]
return [dict create {*}{
} pattern $pat {*}{
@ -1107,35 +1109,52 @@ namespace eval punk::path {
punk::args::define {
@id -id ::punk::path::subfolders
@cmd -name punk::path::subfolders\
-summary\
"Listing of directories below supplied path."\
-help\
@cmd -name punk::path::subfolders -&
-summary -&
"Listing of directories below supplied path." -&
-help -&
"List of folders below path.
The resulting list is unsorted.
"
The resulting list is unsorted."
@opts
-recursive -type none -help\
""
-exclude-paths -type list -default {} -help\
"list of path patterns to exclude from results.
May include * and ** path segments e.g /usr/**
A single /*/ will match any single segment in the path, and a single /**/ will match any number of segments in the path.
e.g to exclude any path with _aside as a segment in the middle: -exclude-paths **/_aside/**
i.e this would exclude /usr/_aside/etc and /usr/x/_aside/etc but not /usr/x/_aside or _aside/etc
To exclude all paths with _aside as a segment anywhere: -exclude-paths { **/_aside/** **/_aside ./_aside/**}
"
-recursive -type none -help -&
"Recurse into subfolders, returning the whole tree of folders below
path rather than only its immediate subfolders.
Subtrees pruned by a trailing-** or trailing-*** -exclude-paths
pattern are not descended."
-exclude-paths -type list -default {} -help -&
"List of path patterns for folders to exclude from the results.
Patterns are matched against the result paths as they would be
returned (see the path argument help regarding relative paths).
${$::punk::path::argdoc::PATHGLOB_SEGMENT_SYNTAX}
The form of a matching pattern determines what is excluded:
**/_aside (plain tail) each matching folder is omitted from
the results, but its subfolders are still visited
and remain eligible
**/_aside/* as above - only folders exactly one level below an
_aside folder are omitted
**/_aside/** (tail **) the matching folder itself is KEPT, but
nothing below it is returned - and the walk does
not descend below it
**/_aside/*** (tail ***) the matching folder and everything below
it are omitted, and the walk does not descend below
it (handled internally as the equivalent pair
{**/_aside/** **/_aside})
So to exclude every folder with _aside as a segment anywhere below
an absolute base path: -exclude-paths {**/_aside/***}
(with a relative base path, also supply forms matching the same
relative prefix as the results when needed)
A pattern list containing a bare ** or *** (which would exclude
every result) is rejected with a warning and an empty list is
returned."
#todo -depth
@values -min 0 -max 1
path -type directory -optional 1 -help\
path -type directory -optional 1 -help -&
"Path of base folder. If not supplied current directory is used.
This may be a relative or absolute path. Relative paths are treated as relative to current directory.
When using relative paths - the result will also be relative paths with the same relative prefix.
(e.g if path is ../test - the results will be ../test/subfolder1 ../test/subfolder2 etc)
Patterns in -exclude-paths are matched against the resulting paths
(so should be written to match the same relative prefix if path is relative)"
This may be a relative or absolute path. Relative paths are treated as relative to current directory.
When using relative paths - the result will also be relative paths with the same relative prefix.
(e.g if path is ../test - the results will be ../test/subfolder1 ../test/subfolder2 etc)
Patterns in -exclude-paths are matched against the resulting paths
(so should be written to match the same relative prefix if path is relative)"
}
proc subfolders {args} {
@ -1143,8 +1162,9 @@ namespace eval punk::path {
lassign [dict values $argd] leaders opts values received
set do_recursion [dict exists $received -recursive]
set exclude_paths [dict get $opts -exclude-paths]
if {"**" in $exclude_paths} {
puts stderr "punk::path::subfolders Warning - exclude_paths contains '**' - all paths will be excluded"
if {"**" in $exclude_paths || "***" in $exclude_paths} {
#likely user error - be loud about it but return empty rather than erroring.
puts stderr "punk::path::subfolders Warning - exclude_paths contains a bare '**' or '***' - all paths will be excluded"
return [list]
}
if {[dict exists $received path]} {
@ -1597,22 +1617,25 @@ namespace eval punk::path {
punk::args::define {
@id -id ::punk::path::treefilenames
@cmd -name punk::path::treefilenames\
-summary\
"List of filenames below supplied path."\
-help\
@cmd -name punk::path::treefilenames -&
-summary -&
"List of filenames below supplied path." -&
-help -&
"List of filenames below path.
The resulting list is unsorted.
The path globbing syntax supports *, **, *** and ? as glob characters in any segment of the path, with the following semantics:
* matches any single segment in the path
** as a whole segment matches 1 or more segments in the path (so /usr/**/bin will match /usr/x/bin and user/x/y/bin but not /usr/bin )
*** as a whole segment matches 0 or more segments in the path (so /usr/*** will match /usr itself as well as anything below it)
? matches any single character in a single segment of the path (so /usr/te?t will match /usr/test and /usr/text but not /usr/texxt)
Results are gathered depth-first; the -sort option (default natural)
controls the ordering of files and subfolder visits within each
directory as the tree is walked.
For a -directory under //zipfs:/ the walk dispatches to the
treefilenames_zipfs implementation (also callable directly), which
applies -sort to its combined result list instead.
The path patterns of -include-paths and -exclude-paths use the
following syntax:
${$::punk::path::argdoc::PATHGLOB_SEGMENT_SYNTAX}
"
-directory -type directory -help\
-directory -type directory -help -&
"folder in which to begin recursive scan for files."
-tailbase -type string -default "" -help\
-tailbase -type string -default "" -help -&
"if supplied, only the relative path compared to the tailbase will be returned for each file.
So if tailbase is /usr and a file is found at /usr/x/y/file.txt, the returned path for that file would be x/y/file.txt.
If tailbase is not supplied, the full path to each file will be returned.
@ -1624,10 +1647,16 @@ namespace eval punk::path {
see: punk::path::relative to compute relative paths
"
-sort -type any -default natural -choices {none ascii dictionary natural}
-exclude-paths -default {} -help\
-sort -type any -default natural -choices {none ascii dictionary natural} -help -&
"Ordering of files and subfolder visits within each directory during
the walk - the combined result list is not re-sorted.
(treefilenames_zipfs instead applies the sort to its combined
result list)
natural sorting requires the natsort package."
-exclude-paths -type list -default {} -help -&
"list of directory path patterns to exclude
may include *, ** and *** path segments e.g
may include *, **, *** and ? glob elements
(path glob syntax - see punk::path::pathglob_as_re) e.g
/usr (exclude files directly within /usr; subfolders
are still walked)
/usr/* (exclude files in folders exactly one below /usr)
@ -1639,10 +1668,15 @@ namespace eval punk::path {
Patterns whose final segment is ** or *** prune the matched
subtree; other matches exclude only the files directly in the
matching folder. Exclusion wins over inclusion for the same file."
-exclude-files -default {}
-include-paths -default {**} -help\
-exclude-files -type list -default {} -help -&
"list of patterns matched against the filename portion (last segment)
of each file path - matching files are excluded.
These are ordinary Tcl glob patterns (string match) - not the path
glob syntax used by -include-paths/-exclude-paths."
-include-paths -type list -default {**} -help -&
"list of directory path patterns to include
may include *, ** and *** path segments e.g
may include *, **, *** and ? glob elements
(path glob syntax - see punk::path::pathglob_as_re) e.g
/usr (include files directly within /usr only)
/usr/* (include files in folders exactly one below /usr)
/usr/** (include files in subfolders based at /usr but not
@ -1656,9 +1690,11 @@ namespace eval punk::path {
below their match. The /usr, /usr/*, /usr/** and /usr/*** forms are
deliberately separable."
@values -min 0 -max -1 -optional 1 -type string
tailglobs -default * -multiple 1 -help\
tailglobs -default * -multiple 1 -help -&
"Patterns to match against filename portion (last segment) of each file path
within the directory tree being searched."
within the directory tree being searched.
These are ordinary Tcl glob patterns - not the path glob syntax of
-include-paths/-exclude-paths."
}
#todo - implement treefiles which acts like dirfiles but allows path globbing in the same way as punk::ns::ns/
@ -1682,28 +1718,28 @@ namespace eval punk::path {
punk::args::define {
@id -id ::punk::path::relative
@cmd -name punk::path::relative\
-summary\
"Compute the relative path from a reference path to a location path."\
-help\
"Taking two directory paths, a reference and a location, computes the path
of the location relative to the reference.
@cmd -name punk::path::relative -&
-summary -&
"Compute the relative path from a reference path to a location path." -&
-help -&
"Taking two directory paths, a reference and a location, computes the path
of the location relative to the reference.
Will return a single dot '.' if the paths are the same.
Will return a single dot '.' if the paths are the same.
Both paths must be the same type - ie both absolute or both relative.
Both paths must be the same type - ie both absolute or both relative.
Matching is case sensitive. On windows, the drive-letter component (only) is
not case sensitive, so punk::path::relative c:/etc C:/etc returns '.'.
The part following the driveletter is case sensitive, so
punk::path::relative c:/etc C:/Etc returns ../Etc.
Matching is case sensitive. On windows, the drive-letter component (only) is
not case sensitive, so punk::path::relative c:/etc C:/etc returns '.'.
The part following the driveletter is case sensitive, so
punk::path::relative c:/etc C:/Etc returns ../Etc.
On windows, if the paths are absolute and specify different volumes,
only the location will be returned."
On windows, if the paths are absolute and specify different volumes,
only the location will be returned."
@leaders -min 2 -max 2
reference -type string -help\
reference -type string -help -&
"The path from which the relative path to location is determined."
location -type string -help\
location -type string -help -&
"The location path which may be above or below the reference path."
}
#maint warning - also in punkcheck
@ -1812,20 +1848,20 @@ namespace eval punk::path::system {
namespace eval argdoc {
lappend PUNKARGS [list {
@id -id ::punk::path::system::scriptfile_is_tcl
@cmd -name "punk::path::system::scriptfile_is_tcl"\
-summary\
"Test whether a script file's first lines identify it as a tcl script"\
-help\
"Reads a small bounded chunk (512 bytes) of the file and reports 1 if
the first line is a '# tcl'-prefixed comment (case-insensitive), a
shebang naming tclsh/tclkit/wish, or any other shebang whose next
few lines contain the portable sh-trampoline 'exec tclsh ...' idiom.
A UTF-8 BOM and CRLF line endings are tolerated. Returns 0 for
unreadable files and anything not identified as tcl.
Used by punk::path::scriptlib_resolve to allow extensionless tcl
scripts to be called via the lib: prefix."
@cmd -name "punk::path::system::scriptfile_is_tcl" -&
-summary -&
"Test whether a script file's first lines identify it as a tcl script" -&
-help -&
"Reads a small bounded chunk (512 bytes) of the file and reports 1 if
the first line is a '# tcl'-prefixed comment (case-insensitive), a
shebang naming tclsh/tclkit/wish, or any other shebang whose next
few lines contain the portable sh-trampoline 'exec tclsh ...' idiom.
A UTF-8 BOM and CRLF line endings are tolerated. Returns 0 for
unreadable files and anything not identified as tcl.
Used by punk::path::scriptlib_resolve to allow extensionless tcl
scripts to be called via the lib: prefix."
@leaders
fpath -type string -optional 0 -help\
fpath -type string -optional 0 -help -&
"path of the file to examine"
}]
}

4
src/modules/punk/path-buildversion.txt

@ -1,6 +1,8 @@
0.4.0
0.5.0
#First line must be a semantic version number
#all other lines are ignored.
#0.5.0 - subfolders1 REMOVED (no call sites repo-wide; subfolders is the reviewed implementation - any external caller of the removed name must switch to subfolders); subfolders ***-tail -exclude-paths patterns now classify as pruning subtree rules (X/*** handled as the equivalent pair {X/** X}: identical results, walk no longer descends excluded subtrees - measured ~250x on a 1110-dir excluded subtree); bare *** exclude now short-circuits like bare ** (warning + empty list); punk::winpath dependency now declared (normjoin calls punk::winpath::is_dos_device_path on every input but the module never required it - normjoin errored in a minimal interp); strip_prefixdepth FIXED - its normalizer call was the nonexistent 'norm', broken since the 2024-08-12 copy from punk::repo::path_strip_prefixdepth (whose filesystem-normalizing norm exists there; kettle::path::strip lineage) - now uses the textual punk::path::normjoin (relative inputs stay relative; same depth-only no-content-compare contract; prefix depth >= path depth still errors via zero-arg file join, as punk::repo's own comments note); normjoin and strip_prefixdepth gain PUNKARGS argdocs; path.test extended 40->67 tests covering the in-use functions previously untested (relative, normjoin, pathtype, scriptlib_resolve, treefilenames_zipfs with zipfs-mount fixture) plus treefilenames -exclude-files/-sort modes, globmatchpath ?/-nocase variants, subfolders ***-tail/pair-equivalence/guard/classifier pins, and strip_prefixdepth/normjoin-edge characterisation
#0.5.0 - PUNKARGS consistency pass (same changeset): path glob syntax (*|**|***|?) centralised in argdoc::PATHGLOB_SEGMENT_SYNTAX tstr fragment shared across pathglob_as_re/globmatchpath/subfolders/treefilenames help; *** and ? documented for subfolders -exclude-paths (always supported via globmatchpath) incl per-form exclusion semantics; globmatchpath summary corrected (*** added) and doctools [fun] markup removed from punk::args help text; treefilenames help no longer claims unsorted results (-sort documented, zipfs dispatch noted), -exclude-files/tailglobs documented as ordinary Tcl globs, -type list added to -exclude-paths/-exclude-files/-include-paths; new documentation-only argdoc for pathglob_as_re
#0.4.0 - pathglob syntax: *** as a whole segment matches ZERO or more segments (X/*** = X and everything below; ***/f.txt matches f.txt at any depth incl root) - globmatchpath/pathglob_as_re/treefilenames (G-093); treefilenames include separability fix: an exact or single-segment-glob full match no longer sets allbelow (previously X or X/* dragged the whole subtree in via the deep walk; zipfs walk already separable); treefilenames exclude separability fix: only **|***-tail exclude patterns prune the subtree - other matches exclude just that folder's files and the walk continues below (both walks; previously any dir match pruned, contradicting the **/_aside vs **/_aside/** argdoc distinction); globmatchpath default-nocase driveletter rewrite now a direct regex-head rewrite (handles c:/*** glued-group form, restricts to alpha drives); treefilenames legacy bare-* include collapse removed (a * in -include-paths no longer means match-everything - it is the one-below lattice form; use **|***)
#0.3.0 - scriptlib_resolve: extensionless tcl scripts resolvable via first-line identification (new system::scriptfile_is_tcl helper - '# tcl' comment, tclsh/tclkit/wish shebang, or sh-trampoline); per-directory spelling order (exact name as called wins over .tcl-appended within each location, kit-internal dir precedence preserved); .kit added to known extensions (previously lib:x.kit wrongly searched x.kit.tcl); return dict keys reworked: scripttype (tcl/kit/py/pl/ps1/sh or empty) + notes added, scriptname now the matched relative name; directories no longer match a spelling (file isfile); now also used by app-punkshell/app_shellrun lib: handling (previously app-punkscript only)
#0.2.2 - added scriptlib_resolve: shared resolution policy for the lib:<script> prefix (kit-internal app/scriptlib first and not externally overridable, then scriptlib dirs relative to the executable); PUNKARGS argdoc namespace + registration added to the module

405
src/tests/modules/punk/path/testsuites/tests/path.test

@ -704,5 +704,410 @@ test treefilenames_rejects_internal_call_flags {Internal recursion state flags a
}\
-cleanup $treefilenames_hidden_cleanup \
-result 1
# =========================================================================
# --- coverage pass 2026-08-03 (agent): in-use punk::path functions
# (globmatchpath extras, subfolders ***-tail decomposition, relative,
# normjoin, pathtype, scriptlib_resolve, treefilenames extras,
# treefilenames_zipfs)
# =========================================================================
tcltest::testConstraint winPaths [expr {$::tcl_platform(platform) eq "windows"}]
tcltest::testConstraint haszipfs [expr {
[llength [info commands ::tcl::zipfs::mkzip]] > 0 && [llength [info commands ::tcl::zipfs::mount]] > 0
}]
test globmatchpath_question_mark {? matches exactly one character within a single segment} \
-setup $common -body {
set result [list \
[punk::path::globmatchpath /usr/te?t /usr/test] \
[punk::path::globmatchpath /usr/te?t /usr/text] \
[punk::path::globmatchpath /usr/te?t /usr/texxt] \
[punk::path::globmatchpath /usr/te?t /usr/tet] \
[punk::path::globmatchpath /usr/te?t /usr/te/t] \
]
} \
-cleanup {
} \
-result {1 1 0 0 0}
test globmatchpath_insegment_doublestar {** within a segment spans path segments} \
-setup $common -body {
set result [list \
[punk::path::globmatchpath /usr/**.txt /usr/a.txt] \
[punk::path::globmatchpath /usr/**.txt /usr/a/b/c.txt] \
[punk::path::globmatchpath /usr/**.txt /other/a.txt] \
]
} \
-cleanup {
} \
-result {1 1 0}
test globmatchpath_nocase_variants {default is case sensitive except leading driveletter; -nocase 1 whole path; explicit -nocase 0 strict} \
-setup $common -body {
set result [list \
[punk::path::globmatchpath c:/Etc/* c:/etc/x] \
[punk::path::globmatchpath C:/etc/* c:/etc/x] \
[punk::path::globmatchpath C:/etc/* c:/etc/x -nocase 0] \
[punk::path::globmatchpath c:/Etc/* c:/etc/x -nocase 1] \
[punk::path::globmatchpath /USR/x /usr/x -nocase 1] \
]
} \
-cleanup {
} \
-result {0 1 0 1 1}
test subfolders_exclude_trailing_zom {Trailing /*** omits the matching base directory and everything below it} \
-setup $subfolders_tree -body {
set result [lsort [punk::path::subfolders -recursive -exclude-paths {**/src/***} .]]
set expected [lsort [list \
[file join . $sub_tree_tail] \
[file join . $sub_tree_tail aside] \
[file join . $sub_tree_tail aside child] \
[file join . $sub_tree_tail aside child grandchild] \
[file join . $sub_tree_tail keep] \
]]
expr {$result eq $expected}
} \
-cleanup $subfolders_cleanup \
-result 1
test subfolders_exclude_zom_equals_pair {X/*** exclude equals the {X/** X} two-pattern composition} \
-setup $subfolders_tree -body {
set r_zom [lsort [punk::path::subfolders -recursive -exclude-paths {**/src/***} .]]
set r_pair [lsort [punk::path::subfolders -recursive -exclude-paths {**/src/** **/src} .]]
set result [list \
[expr {$r_zom eq $r_pair}] \
[expr {[file join . $sub_tree_tail src] ni $r_zom}] \
[expr {[file join . $sub_tree_tail src vfs] ni $r_zom}] \
]
} \
-cleanup $subfolders_cleanup \
-result {1 1 1}
test subfolders_exclude_zom_classified_subtree {***-tail exclude patterns compile to pruning subtree rules (the pair decomposition)} \
-setup $common -body {
set r_zom [punk::path::subfolder_priv::classify_exclude_pattern **/x/***]
set r_below [punk::path::subfolder_priv::classify_exclude_pattern **/x/**]
set r_exact [punk::path::subfolder_priv::classify_exclude_pattern **/x]
set r_one [punk::path::subfolder_priv::classify_exclude_pattern **/x/*]
set result [list \
[dict get $r_zom kind] [dict get $r_zom boundary_pat] [dict get $r_zom descend_pat] \
[dict get $r_below kind] \
[dict get $r_exact kind] \
[dict get $r_one kind] \
]
} \
-cleanup {
} \
-result {subtree **/x **/x/*** subtree exact child_only}
test subfolders_exclude_bare_spanning_patterns_return_empty {bare ** or *** exclude patterns short-circuit to an empty result (with stderr warning)} \
-setup $subfolders_tree -body {
set result [list \
[punk::path::subfolders -recursive -exclude-paths {**} .] \
[punk::path::subfolders -recursive -exclude-paths {***} .] \
]
} \
-cleanup $subfolders_cleanup \
-result {{} {}}
test subfolders_nonrecursive_immediate_only {Without -recursive only immediate subfolders are returned, with the caller's relative prefix} \
-setup $subfolders_tree -body {
set result [lsort [punk::path::subfolders $sub_tree_tail]]
set expected [lsort [list \
[file join $sub_tree_tail aside] \
[file join $sub_tree_tail keep] \
[file join $sub_tree_tail src] \
]]
expr {$result eq $expected}
} \
-cleanup $subfolders_cleanup \
-result 1
test relative_basic_forms {relative computes ., descend, ascend and sibling relations} \
-setup $common -body {
set result [list \
[punk::path::relative /a/b/c /a/b/c] \
[punk::path::relative /a/b /a/b/c/d] \
[punk::path::relative /a/b/c/d /a/b] \
[punk::path::relative /a/b/c /a/b/x/y] \
[punk::path::relative sub/dir sub/dir/deeper] \
[punk::path::relative a/b c/d] \
]
} \
-cleanup {
} \
-result {. c/d ../.. ../x/y deeper ../../c/d}
test relative_mixed_pathtypes_error {relative raises an error for reference/location of different pathtypes} \
-setup $common -body {
catch {punk::path::relative /abs relpath}
} \
-cleanup {
} \
-result 1
test relative_driveletter_case {windows driveletter component (only) is case insensitive; different volumes return the location} \
-constraints winPaths \
-setup $common -body {
set result [list \
[punk::path::relative c:/etc C:/etc] \
[punk::path::relative c:/etc C:/Etc] \
[punk::path::relative c:/etc d:/etc] \
]
} \
-cleanup {
} \
-result {. ../Etc D:/etc}
test normjoin_relative_and_dot_handling {normjoin keeps relative results dot-prefixed and simplifies . and .. segments} \
-setup $common -body {
set result [list \
[punk::path::normjoin a b c] \
[punk::path::normjoin ./a/./b] \
[punk::path::normjoin a//b] \
[punk::path::normjoin a/../../b] \
[punk::path::normjoin .. ..] \
]
} \
-cleanup {
} \
-result {./a/b/c ./a/b ./a/b ./../b ./../..}
test normjoin_absolute_simplification {normjoin simplifies .. within absolute paths without crossing the root} \
-setup $common -body {
set result [list \
[punk::path::normjoin /a/b .. c] \
[punk::path::normjoin /a/b/../c] \
[punk::path::normjoin /a//b/c] \
[punk::path::normjoin /x ..] \
[punk::path::normjoin c:/a/../..] \
[punk::path::normjoin c:] \
]
} \
-cleanup {
} \
-result {/a/c /a/c /a/b/c / c:/ c:/}
test normjoin_server_and_scheme_roots {normjoin treats //server and scheme:// prefixes as roots that .. cannot escape} \
-setup $common -body {
set result [list \
[punk::path::normjoin //server/share/sub ..] \
[punk::path::normjoin //server/share ..] \
[punk::path::normjoin //server ..] \
[punk::path::normjoin https://host/a/../b] \
[punk::path::normjoin //zipfs:] \
[punk::path::normjoin //zipfs:/a/..] \
[punk::path::normjoin d://a//b//c] \
]
} \
-cleanup {
} \
-result {//server/share //server/ //server/ https://host/b //zipfs:/ //zipfs:/ d://a/b/c}
test normjoin_backslash_and_mixed_root_inputs {normjoin converts backslashes and preserves embedded volume-like segments} \
-setup $common -body {
set result [list \
[punk::path::normjoin {C:\Users\x} .. y] \
[punk::path::normjoin /d:/..//vfs:/test] \
]
} \
-cleanup {
} \
-result {C:/Users/y /vfs:/test}
test normjoin_empty_and_lnk_edges {normjoin with no parts, an empty part, and an unresolvable .lnk tail} \
-setup $common -body {
set result [list \
[punk::path::normjoin] \
[punk::path::normjoin ""] \
[punk::path::normjoin a/b/c.lnk] \
]
} \
-cleanup {
} \
-result {{} . ./a/b/c.lnk}
#characterisation 2026-08-03: strip_prefixdepth's normalizer call was the nonexistent
#'norm' from its 2024-08-12 copy out of punk::repo::path_strip_prefixdepth (where a
#filesystem-normalizing norm exists) - fixed to the textual punk::path::normjoin.
test strip_prefixdepth_depth_only_semantics {strip_prefixdepth removes as many leading segments as prefix depth - prefix content is not compared} \
-setup $common -body {
set result [list \
[punk::path::strip_prefixdepth /x/a/b/c /x] \
[punk::path::strip_prefixdepth /x/a/b/c /x/a] \
[punk::path::strip_prefixdepth /x/a/b/c /different] \
[punk::path::strip_prefixdepth a/b/c a] \
[punk::path::strip_prefixdepth c:/x/a c:] \
[punk::path::strip_prefixdepth {C:\x\a\b} C:/x] \
]
} \
-cleanup {
} \
-result {a/b/c b/c a/b/c b/c x/a a/b}
test strip_prefixdepth_empty_prefix_returns_normjoined {empty prefix returns the normjoin-normalized path unchanged} \
-setup $common -body {
set result [list \
[punk::path::strip_prefixdepth a/b ""] \
[punk::path::strip_prefixdepth {C:\x\y} ""] \
]
} \
-cleanup {
} \
-result {./a/b C:/x/y}
test strip_prefixdepth_mixed_pathtypes {a relative prefix's normjoin ./ segment counts towards its depth (prefer same-pathtype arguments)} \
-setup $common -body {
#prefix a normjoins to ./a (2 file split segments) - strips 2 from {/ x a b}
punk::path::strip_prefixdepth /x/a/b a
} \
-cleanup {
} \
-result {a/b}
test strip_prefixdepth_prefix_exhausts_path {a prefix with as many or more segments than path raises the file join no-arguments error} \
-setup $common -body {
set result [list \
[catch {punk::path::strip_prefixdepth /x/a /x/a}] \
[catch {punk::path::strip_prefixdepth /x /x/a/b}] \
]
} \
-cleanup {
} \
-result {1 1}
test pathtype_classification {pathtype classifies absolute, volume, scheme and relative forms (string-based, x-platform)} \
-setup $common -body {
set result [list \
[punk::path::pathtype /usr] \
[punk::path::pathtype //server/share] \
[punk::path::pathtype c:] \
[punk::path::pathtype c:/x] \
[punk::path::pathtype ab:/x] \
[punk::path::pathtype https://host] \
[punk::path::pathtype a/b] \
[punk::path::pathtype ./x] \
[punk::path::pathtype ..] \
[punk::path::pathtype c:relative] \
[punk::path::pathtype a:b:/x] \
]
} \
-cleanup {
} \
-result {absolute absolute {absolute volume basic} {absolute volume basic} {absolute volume extended} {absolute scheme} relative relative relative relative relative}
test scriptlib_resolve_rejects_absolute {scriptlib_resolve raises an error for a non-relative name} \
-setup $common -body {
set code [catch {punk::path::scriptlib_resolve /abs/name} emsg]
list $code [string match "*must be a relative path*" $emsg]
} \
-cleanup {
} \
-result {1 1}
test scriptlib_resolve_notfound_dict_shape {scriptlib_resolve returns the documented dict shape when nothing resolves} \
-setup $common -body {
set r [punk::path::scriptlib_resolve __punk_no_such_script__]
set r2 [punk::path::scriptlib_resolve __punk_no_such_script__.ps1]
set result [list \
[lsort [dict keys $r]] \
[dict get $r path] \
[dict get $r scriptname] \
[dict get $r scripttype] \
[expr {[llength [dict get $r candidates]] > 0}] \
[dict get $r2 scriptname] \
]
} \
-cleanup {
} \
-result {{candidates notes path scriptname scripttype} {} __punk_no_such_script__.tcl {} 1 __punk_no_such_script__.ps1}
test treefilenames_exclude_files_tail_globs {-exclude-files are ordinary Tcl glob patterns matched against the file tail} \
-setup $treefilenames_tree -body {
set result [lsort [punk::path::treefilenames -sort none -directory $tf_tree_root -tailbase $tf_tree_root -exclude-files {deep* child*} *.txt]]
set expected [lsort [list \
[file join alpha subdir direct.txt] \
[file join aside aside.txt] \
[file join aside child grandchild grandchild.txt] \
[file join b other other.txt] \
[file join keep keep.txt] \
[file join src srcroot.txt] \
[file join src vfs vfs.txt] \
]]
expr {$result eq $expected}
} \
-cleanup $treefilenames_cleanup \
-result 1
variable treefilenames_sort_tree {
set tfs_prevdir [pwd]
set tfs_newbase [punk::lib::tempdir_newfolder -prefix punk_path_tf_sort]
set tfs_tree_root [file join $tfs_newbase __punk_path_tf_sort_test__]
file mkdir $tfs_tree_root
foreach fname {B.txt a.txt c.TXT} {
set fd [open [file join $tfs_tree_root $fname] w]; close $fd
}
}
variable treefilenames_sort_cleanup {
cd $tfs_prevdir
file delete -force $tfs_newbase
}
test treefilenames_sort_modes_order_within_directory {-sort ascii and -sort dictionary order entries within each directory} \
-setup $treefilenames_sort_tree -body {
set r_ascii [punk::path::treefilenames -sort ascii -directory $tfs_tree_root -tailbase $tfs_tree_root *]
set r_dict [punk::path::treefilenames -sort dictionary -directory $tfs_tree_root -tailbase $tfs_tree_root *]
list $r_ascii $r_dict
} \
-cleanup $treefilenames_sort_cleanup \
-result {{B.txt a.txt c.TXT} {a.txt B.txt c.TXT}}
variable treefilenames_zipfs_tree {
set zf_prevdir [pwd]
set zf_newbase [punk::lib::tempdir_newfolder -prefix punk_path_zipfs]
set zf_srcdir [file join $zf_newbase ziptree]
file mkdir [file join $zf_srcdir sub deep]
foreach relpath {a.txt sub/b.txt sub/deep/c.txt sub/skip.log} {
set fd [open [file join $zf_srcdir $relpath] w]
puts $fd $relpath
close $fd
}
set zf_zip [file join $zf_newbase test.zip]
tcl::zipfs::mkzip $zf_zip $zf_srcdir $zf_srcdir
set zf_mountname punkpathtest_[pid]
#tcl 9.0 argument order is (zipfile, mountpoint); tolerate the reversed
#historical order in case an older zipfs-capable runtime runs this suite
if {[catch {tcl::zipfs::mount $zf_zip $zf_mountname}]} {
tcl::zipfs::mount $zf_mountname $zf_zip
}
set zf_mount [string trimright [tcl::zipfs::root] /]/$zf_mountname
}
variable treefilenames_zipfs_cleanup {
catch {tcl::zipfs::unmount $zf_mountname}
catch {tcl::zipfs::unmount $zf_mount}
cd $zf_prevdir
file delete -force $zf_newbase
}
test treefilenames_zipfs_basic_and_autodispatch {treefilenames_zipfs lists mounted zip content; treefilenames auto-dispatches for //zipfs:/ dirs} \
-constraints haszipfs \
-setup $treefilenames_zipfs_tree -body {
set r_zipfs [lsort [punk::path::treefilenames_zipfs -sort none -directory $zf_mount -tailbase $zf_mount *.txt]]
set r_auto [lsort [punk::path::treefilenames -sort none -directory $zf_mount -tailbase $zf_mount *.txt]]
set expected [lsort [list a.txt sub/b.txt sub/deep/c.txt]]
set result [list [expr {$r_zipfs eq $expected}] [expr {$r_auto eq $expected}]]
} \
-cleanup $treefilenames_zipfs_cleanup \
-result {1 1}
test treefilenames_zipfs_exclude_zom_subtree {treefilenames_zipfs prunes ***-tail excluded subtrees} \
-constraints haszipfs \
-setup $treefilenames_zipfs_tree -body {
punk::path::treefilenames_zipfs -sort none -directory $zf_mount -tailbase $zf_mount -exclude-paths {sub/***} *.txt
} \
-cleanup $treefilenames_zipfs_cleanup \
-result {a.txt}
}
tcltest::cleanupTests ;#needed to produce test summary.
Loading…
Cancel
Save