From 1eb9dc690bd56ebc52f5de007e1285ac6c1e0cd7 Mon Sep 17 00:00:00 2001 From: Julian Noble Date: Sat, 18 Jul 2026 17:28:40 +1000 Subject: [PATCH] punk::path 0.3.0: extensionless tcl scripts via lib: (first-line identification) scriptlib_resolve now lets an extensionless (or unknown-extension) lib: call match a file named exactly when its first lines identify it as tcl: a leading '# tcl' comment (prefix, case-insensitive), a shebang naming tclsh/tclkit/wish, or the portable sh-trampoline ('#!/bin/sh' with a commented 'exec tclsh' continuation - detected within the first 4 lines). New internal punk::path::system::scriptfile_is_tcl does the bounded sniff (512 bytes, UTF-8 BOM and CRLF tolerated, directories/unreadable files rejected). When both and .tcl exist, the spelling matching the call wins, checked per scriptlib location so kit-internal directory precedence is never crossed by a spelling preference. .kit joins the known extensions: lib:.kit previously had .tcl appended (searched .kit.tcl), which made the shell subcommand's do_tclkit dispatch arms unreachable - lib: kit dispatch is now restored. Result dict reworked: scripttype (tcl/kit/py/pl/ps1/sh or empty) + notes keys added, scriptname is now the matched relative name, and matches require file isfile. app-punkshell and app_shellrun lib: handling refactored onto the shared resolver (previously independent inline copies of the policy), fixing two silent-failure bugs in that copy: no message at all when a scriptlib dir existed but lacked the script, and the error listing showing /scriptlib rather than the /app/scriptlib actually checked. app-punkscript's post-resolve gate now uses scripttype and its not-found report includes the identification skip notes. The unused has_globchars vestige is dropped. Fixtures scriptlib/_punktest/{extless,dual,dual.tcl,shebang_sh,notatcl} and 5 new scriptexec.test cases (extensionless resolve, both precedence directions, trampoline shebang + argv, non-tcl skip note on not-found); suite passes 17/17 against a freshly built punk902z kit. Sniff helper unit checks pass under tclsh 9.0.3 and 8.6. punkshell project 0.13.0 -> 0.14.0. Assisted-by: harness=claude; primary-model=claude-fable-5; api-location=anthropic.com --- CHANGELOG.md | 4 + punkproject.toml | 2 +- scriptlib/_punktest/README.md | 10 ++ scriptlib/_punktest/dual | 8 + scriptlib/_punktest/dual.tcl | 5 + scriptlib/_punktest/extless | 8 + scriptlib/_punktest/notatcl | 7 + scriptlib/_punktest/shebang_sh | 10 ++ src/lib/AGENTS.md | 2 +- src/lib/app-punkscript/punkscript.tcl | 10 +- src/lib/app-punkshell/punkshell.tcl | 105 ++++--------- src/lib/app_shellrun/app_shellrun.tcl | 105 ++++--------- src/modules/punk/path-999999.0a1.0.tm | 141 +++++++++++++++--- src/modules/punk/path-buildversion.txt | 3 +- src/tests/shell/AGENTS.md | 2 +- .../shell/testsuites/punkexe/scriptexec.test | 59 +++++++- 16 files changed, 294 insertions(+), 187 deletions(-) create mode 100644 scriptlib/_punktest/dual create mode 100644 scriptlib/_punktest/dual.tcl create mode 100644 scriptlib/_punktest/extless create mode 100644 scriptlib/_punktest/notatcl create mode 100644 scriptlib/_punktest/shebang_sh diff --git a/CHANGELOG.md b/CHANGELOG.md index ab2d4832..28ca23d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ The latest `## [X.Y.Z]` header must match the `version` field in `punkproject.to Entries are newest-first; one bullet per notable change. See the root `AGENTS.md` "Project Versioning" section for the bump policy. +## [0.14.0] - 2026-07-18 + +- `lib:` scriptlib scripts may now be extensionless: an extensionless (or unknown-extension) `lib:` call also matches a file named exactly `` when its first lines identify it as tcl - a leading `# tcl` comment, a shebang naming tclsh/tclkit/wish, or the portable sh-trampoline (`#!/bin/sh` with a commented `exec tclsh` continuation). When both `` and `.tcl` exist, the spelling matching the call wins, checked per scriptlib location so kit-internal precedence is unaffected. Implemented in punk::path 0.3.0 (`scriptlib_resolve` + new internal `scriptfile_is_tcl`; result dict gains `scripttype`/`notes`, not-found errors list skip notes); `.kit` is now a known extension so `lib:.kit` resolves as itself (previously it wrongly searched `.kit.tcl`, making the shell subcommand's kit dispatch unreachable - now restored). The `shell` subcommand's `lib:` handling (app-punkshell, app_shellrun) is refactored onto the shared resolver, fixing two silent-failure bugs in its former inline copy (no message when a scriptlib dir existed but lacked the script; wrong kit-internal path in the error listing). Test fixtures + coverage added under `scriptlib/_punktest/` and `src/tests/shell/testsuites/punkexe/scriptexec.test`. + ## [0.13.0] - 2026-07-18 - punk::ns 0.9.0: cmdhelp (`i`) gains `-return text` - plain-text argument documentation for machine/LLM consumption: no ANSI codes, no table layout, and the argument section of every command form rendered in a single output under a common header (`i -return text after` shows all six forms), with G-041 form selection still honoured when argument words are supplied. No advisory-parse error preamble (use `-return dict` for parse status). diff --git a/punkproject.toml b/punkproject.toml index 6d512ff5..8d55d47f 100644 --- a/punkproject.toml +++ b/punkproject.toml @@ -1,4 +1,4 @@ [project] name = "punkshell" -version = "0.13.0" +version = "0.14.0" license = "BSD-2-Clause" diff --git a/scriptlib/_punktest/README.md b/scriptlib/_punktest/README.md index a37992a5..b5fd8b59 100644 --- a/scriptlib/_punktest/README.md +++ b/scriptlib/_punktest/README.md @@ -16,6 +16,16 @@ mechanism and the `script` subcommand's Tk main-loop behaviour against Fixtures: - `echo.tcl` — emits a marker plus its `::argv0`/`::argv`; used for `lib:` resolution, subpath resolution, and file-form argument passing. +- `extless` — extensionless, first line `# tcl`; proves extensionless-tcl + matching (punk::path 0.3.0 first-line identification). +- `dual` / `dual.tcl` — precedence pair with distinct markers; the spelling + matching the call must win (`lib:_punktest/dual` runs `dual`, + `lib:_punktest/dual.tcl` runs `dual.tcl`). +- `shebang_sh` — extensionless, portable sh-trampoline shebang (`#!/bin/sh` + with commented `exec tclsh` continuation); proves the identification check + accepts the classic cross-platform idiom. +- `notatcl` — extensionless with a python shebang and no `.tcl` sibling; must + NOT resolve, and the not-found output must note the identification skip. - `tk_countdown.tcl ` — Tk window that exits 0 from an `after` callback; proves the `script` subcommand services a registered Tk main loop (the callback runs only if the event loop is serviced). diff --git a/scriptlib/_punktest/dual b/scriptlib/_punktest/dual new file mode 100644 index 00000000..c26dc6e3 --- /dev/null +++ b/scriptlib/_punktest/dual @@ -0,0 +1,8 @@ +# tcl +#PUNKTEST FIXTURE - owned by src/tests/shell/testsuites/punkexe/scriptexec.test +#Do not hand-edit: the suite asserts on the exact marker/output below. +#Precedence fixture pair with dual.tcl: an extensionless call ('lib:_punktest/dual') +#must select THIS file; the explicit call ('lib:_punktest/dual.tcl') must select +#the sibling. The markers differ so the suite can tell which one ran. +puts "PUNKTEST_DUAL=bare" +flush stdout diff --git a/scriptlib/_punktest/dual.tcl b/scriptlib/_punktest/dual.tcl new file mode 100644 index 00000000..2cfa9eed --- /dev/null +++ b/scriptlib/_punktest/dual.tcl @@ -0,0 +1,5 @@ +#PUNKTEST FIXTURE - owned by src/tests/shell/testsuites/punkexe/scriptexec.test +#Do not hand-edit: the suite asserts on the exact marker/output below. +#Precedence fixture pair with the extensionless 'dual': see that file's header. +puts "PUNKTEST_DUAL=dottcl" +flush stdout diff --git a/scriptlib/_punktest/extless b/scriptlib/_punktest/extless new file mode 100644 index 00000000..3686a373 --- /dev/null +++ b/scriptlib/_punktest/extless @@ -0,0 +1,8 @@ +# tcl +#PUNKTEST FIXTURE - owned by src/tests/shell/testsuites/punkexe/scriptexec.test +#Do not hand-edit: the suite asserts on the exact marker/output below. +#Extensionless fixture: first line is the '# tcl' identification marker so that +#'lib:_punktest/extless' resolves via punk::path::scriptlib_resolve's +#extensionless-tcl matching (there is no extless.tcl sibling). +puts "PUNKTEST_EXTLESS argv0=[file tail $::argv0] argv=$::argv" +flush stdout diff --git a/scriptlib/_punktest/notatcl b/scriptlib/_punktest/notatcl new file mode 100644 index 00000000..f849e589 --- /dev/null +++ b/scriptlib/_punktest/notatcl @@ -0,0 +1,7 @@ +#!/usr/bin/env python +#PUNKTEST FIXTURE - owned by src/tests/shell/testsuites/punkexe/scriptexec.test +#Do not hand-edit: the suite asserts on the exact behaviour below. +#Extensionless NON-tcl fixture (python shebang, no .tcl sibling): an attempted +#'lib:_punktest/notatcl' must NOT resolve - the suite asserts a not-found failure +#whose output notes that this file was skipped by the tcl identification check. +print("PUNKTEST_NOTATCL should never execute via lib:") diff --git a/scriptlib/_punktest/shebang_sh b/scriptlib/_punktest/shebang_sh new file mode 100644 index 00000000..a4a53b86 --- /dev/null +++ b/scriptlib/_punktest/shebang_sh @@ -0,0 +1,10 @@ +#!/bin/sh +# \ +exec tclsh "$0" ${1+"$@"} +#PUNKTEST FIXTURE - owned by src/tests/shell/testsuites/punkexe/scriptexec.test +#Do not hand-edit: the suite asserts on the exact marker/output below. +#Extensionless fixture using the portable sh-trampoline shebang (line 1 names sh, +#not tcl - the commented 'exec tclsh' continuation identifies it): proves the +#scriptlib_resolve identification check accepts the classic cross-platform idiom. +puts "PUNKTEST_SHEBANG_SH argv0=[file tail $::argv0] argv=$::argv" +flush stdout diff --git a/src/lib/AGENTS.md b/src/lib/AGENTS.md index e879c437..e5a1b34c 100644 --- a/src/lib/AGENTS.md +++ b/src/lib/AGENTS.md @@ -16,7 +16,7 @@ Source of truth for all editable `pkgIndex.tcl`-based library packages. These ar - Libraries here include: - `app-punk/` — Punk REPL app entry (`app-punk::repl`) - `app-punkshell/` — Punkshell app entry (`app-punkshell`); on eof of its input channel the `env(PUNK_PIPE_EOF)` policy (`exit`|`interactive`|unset=console heuristic) decides between terminating and reopening the console for an interactive repl — automated callers should set `exit` or end piped input with an explicit `exit` - - `app-punkscript/` — lean one-shot script runner behind the punk executable `script` subcommand (G-015): runs a file, a `lib:` scriptlib script (resolved via `punk::path::scriptlib_resolve`), or piped stdin in the default punk shell module/alias environment with honest exit codes; no shellfilter stacks or logging side effects, never falls into an interactive shell — the reliable path for automated/agent callers (prefer this over piping into `shell`); stdin form echoes the script's final result when non-empty; GUI parity with `tclsh` — a script leaving a registered Tk main loop is serviced until its window closes (so `script app.tcl` keeps a Tk app alive), while console scripts and errored scripts exit immediately + - `app-punkscript/` — lean one-shot script runner behind the punk executable `script` subcommand (G-015): runs a file, a `lib:` scriptlib script (resolved via `punk::path::scriptlib_resolve`; an extensionless call also matches an extensionless file whose first lines identify it as tcl - `# tcl` comment or tcl shebang incl. the sh-trampoline - with the called spelling winning when both spellings exist), or piped stdin in the default punk shell module/alias environment with honest exit codes; no shellfilter stacks or logging side effects, never falls into an interactive shell — the reliable path for automated/agent callers (prefer this over piping into `shell`); stdin form echoes the script's final result when non-empty; GUI parity with `tclsh` — a script leaving a registered Tk main loop is serviced until its window closes (so `script app.tcl` keeps a Tk app alive), while console scripts and errored scripts exit immediately - `app-shellspy/` — ShellSpy app entry (`app-shellspy`) - `app_shell/` — Shell app helpers (`app_shell`) - `app_shellrun/` — Shell run helpers (`app_shellrun`) diff --git a/src/lib/app-punkscript/punkscript.tcl b/src/lib/app-punkscript/punkscript.tcl index 3f38604b..16f1043f 100644 --- a/src/lib/app-punkscript/punkscript.tcl +++ b/src/lib/app-punkscript/punkscript.tcl @@ -81,6 +81,9 @@ apply {{} { #Resolution policy is shared with the shell subcommand's scheme via #punk::path::scriptlib_resolve (vfs-internal scriptlib wins and is #not externally overridable, then scriptlib dirs relative to the exe). + #An extensionless call may also match an extensionless tcl file whose + #first lines identify it as tcl ('# tcl' comment or tcl shebang); + #the spelling matching the call wins when both spellings exist. set libname [string trimleft [string range $scriptname [string first : $scriptname]+1 end] :] package require punk::path if {[catch {punk::path::scriptlib_resolve $libname} resolved]} { @@ -93,10 +96,13 @@ apply {{} { foreach c [dict get $resolved candidates] { puts stderr " $c" } + foreach n [dict get $resolved notes] { + puts stderr " note: $n" + } exit 1 } - if {[string tolower [file extension $scriptpath]] ne ".tcl"} { - puts stderr "punk script: only .tcl scripts are supported via lib: in the script subcommand (resolved: $scriptpath)" + if {[dict get $resolved scripttype] ne "tcl"} { + puts stderr "punk script: only tcl scripts are supported via lib: in the script subcommand (resolved: $scriptpath)" exit 1 } set scriptpath [file normalize $scriptpath] diff --git a/src/lib/app-punkshell/punkshell.tcl b/src/lib/app-punkshell/punkshell.tcl index 097b29e1..7f2734f7 100644 --- a/src/lib/app-punkshell/punkshell.tcl +++ b/src/lib/app-punkshell/punkshell.tcl @@ -303,92 +303,41 @@ dict with prevglobal {} switch -glob -nocase -- $script_or_kit { lib:* { set exitinfo {} - #scriptlib - #There may be one or more colons after lib + #scriptlib script: lib: (one or more colons after lib), with or + #without .tcl extension - an extensionless call may also match an + #extensionless tcl file whose first lines identify it as tcl. + #Resolution policy is shared with the 'script' subcommand via + #punk::path::scriptlib_resolve (vfs-internal scriptlib wins and is + #not externally overridable, then scriptlib dirs relative to the exe). set cposn [string first : $script_or_kit] - set script_or_kit [string trimleft [string range $script_or_kit $cposn+1 end] :] - if {[file pathtype $script_or_kit] eq "relative"} { - set has_globchars [regexp {[*?]} $script_or_kit] ;#basic globs only? - - - set exepath [file dirname [file normalize [file join [info nameofexecutable] ___]]] ;#symlink resolve - review should we resolve scriptlib relative to a symlink too? - set kit_libdir "" ;#metakit or zipkit libdir - set known_extensions [list .tcl .py .pl .ps1 .sh] ;#review - set ext [file extension $script_or_kit] - if {[string tolower $ext] ni $known_extensions} { - #only .tcl scripts allowed to be called extensionlessly - set scriptname $script_or_kit.tcl - } else { - set scriptname $script_or_kit - } - set lower_ext [string tolower [file extension $scriptname]] - if {$lower_ext in {.tcl .kit}} { - set has_zipfs_command [expr {[info commands ::tcl::zipfs::root] ne ""}] - set kit_base "" - if {$has_zipfs_command && [file exists [tcl::zipfs::root]]} { - set kit_base [tcl::zipfs::root] - } elseif {[file type $exepath] eq "directory"} { - set kit_base $exepath + set libname [string trimleft [string range $script_or_kit $cposn+1 end] :] + package require punk::path + if {[catch {punk::path::scriptlib_resolve $libname} resolved]} { + #covers the non-relative-path rejection + puts stderr "punkshell: $resolved" + } else { + set scriptpath [dict get $resolved path] + if {$scriptpath eq ""} { + puts stderr "script '[dict get $resolved scriptname]' not found in vfs or in scriptlib folders relative to the executable. Locations checked:" + foreach c [dict get $resolved candidates] { + puts stderr " $c" } - if {$has_zipfs_command && [file exists $kit_base/app/scriptlib]} { - set kit_libdir $kit_base/app/scriptlib - } elseif {[file exists $exepath/scriptlib]} { - set kit_libdir $exepath/scriptlib + foreach n [dict get $resolved notes] { + puts stderr " note: $n" } - - #partly for performance benefit - we don't allow overriding of vfs internal scripts. - #Only additional scripts can be provided by the bin/scriptlib or ../bin/scriptlib folders - if {$kit_libdir ne "" && [file exists $kit_libdir/$scriptname]} { - switch -- $lower_ext { - .tcl { - set exitinfo [punkshell::do_script $kit_libdir/$scriptname {*}$arglist] - } - .kit { - set exitinfo [punkshell::do_tclkit $kit_libdir/$scriptname "no_repl" {*}$arglist] - } - } - } else { - #fallback to external filesystem - set exedir [file dirname $exepath] - set bin_scripts [file join $exedir scriptlib] - set binsibling_scripts [file join [file dirname $exedir] scriptlib] - set script_check_paths [list] - if {[file exists $bin_scripts]} { - lappend script_check_paths $bin_scripts/$scriptname + } else { + switch -- [dict get $resolved scripttype] { + tcl { + set exitinfo [punkshell::do_script $scriptpath {*}$arglist] } - if {[file exists $binsibling_scripts]} { - lappend script_check_paths $binsibling_scripts/$scriptname + kit { + set exitinfo [punkshell::do_tclkit $scriptpath "no_repl" {*}$arglist] } - if {[llength $script_check_paths]} { - foreach check_path $script_check_paths { - if {[file exists $check_path]} { - switch -- $lower_ext { - .tcl { - set exitinfo [punkshell::do_script $check_path {*}$arglist] - } - .kit { - set exitinfo [punkshell::do_tclkit $check_path "no_repl" {*}$arglist] - } - } - break - } - } - } else { - puts stderr "script $script_or_kit not found in vfs or in filesystem relative to $exedir" - puts stderr "valid locations:" - if {$kit_base ne ""} { - puts stderr " $kit_base/scriptlib/$scriptname" - } - puts stderr " $bin_scripts/$scriptname" - puts stderr " $binsibling_scripts/$scriptname" + default { + puts stderr "No current support for extension [file extension $scriptpath] via lib: (resolved: $scriptpath)" } } - } else { - puts stderr "No current support for extension [file extension $scriptname]" } - - } else { - puts stderr "Path supplied to lib: must be a relative path" } } *.tcl { diff --git a/src/lib/app_shellrun/app_shellrun.tcl b/src/lib/app_shellrun/app_shellrun.tcl index 99280ec2..5a0749d0 100644 --- a/src/lib/app_shellrun/app_shellrun.tcl +++ b/src/lib/app_shellrun/app_shellrun.tcl @@ -250,92 +250,41 @@ dict with prevglobal {} switch -glob -nocase -- $script_or_kit { lib:* { set exitinfo {} - #scriptlib - #There may be one or more colons after lib + #scriptlib script: lib: (one or more colons after lib), with or + #without .tcl extension - an extensionless call may also match an + #extensionless tcl file whose first lines identify it as tcl. + #Resolution policy is shared with the 'script' subcommand via + #punk::path::scriptlib_resolve (vfs-internal scriptlib wins and is + #not externally overridable, then scriptlib dirs relative to the exe). set cposn [string first : $script_or_kit] - set script_or_kit [string trimleft [string range $script_or_kit $cposn+1 end] :] - if {[file pathtype $script_or_kit] eq "relative"} { - set has_globchars [regexp {[*?]} $script_or_kit] ;#basic globs only? - - - set exepath [file dirname [file normalize [file join [info nameofexecutable] ___]]] ;#symlink resolve - review should we resolve scriptlib relative to a symlink too? - set kit_libdir "" ;#metakit or zipkit libdir - set known_extensions [list .tcl .py .pl .ps1 .sh] ;#review - set ext [file extension $script_or_kit] - if {[string tolower $ext] ni $known_extensions} { - #only .tcl scripts allowed to be called extensionlessly - set scriptname $script_or_kit.tcl - } else { - set scriptname $script_or_kit - } - set lower_ext [string tolower [file extension $scriptname]] - if {$lower_ext in {.tcl .kit}} { - set has_zipfs_command [expr {[info commands ::tcl::zipfs::root] ne ""}] - set kit_base "" - if {$has_zipfs_command && [file exists [tcl::zipfs::root]]} { - set kit_base [tcl::zipfs::root] - } elseif {[file type $exepath] eq "directory"} { - set kit_base $exepath + set libname [string trimleft [string range $script_or_kit $cposn+1 end] :] + package require punk::path + if {[catch {punk::path::scriptlib_resolve $libname} resolved]} { + #covers the non-relative-path rejection + puts stderr "punkshell: $resolved" + } else { + set scriptpath [dict get $resolved path] + if {$scriptpath eq ""} { + puts stderr "script '[dict get $resolved scriptname]' not found in vfs or in scriptlib folders relative to the executable. Locations checked:" + foreach c [dict get $resolved candidates] { + puts stderr " $c" } - if {$has_zipfs_command && [file exists $kit_base/app/scriptlib]} { - set kit_libdir $kit_base/app/scriptlib - } elseif {[file exists $exepath/scriptlib]} { - set kit_libdir $exepath/scriptlib + foreach n [dict get $resolved notes] { + puts stderr " note: $n" } - - #partly for performance benefit - we don't allow overriding of vfs internal scripts. - #Only additional scripts can be provided by the bin/scriptlib or ../bin/scriptlib folders - if {$kit_libdir ne "" && [file exists $kit_libdir/$scriptname]} { - switch -- $lower_ext { - .tcl { - set exitinfo [punkshell::do_script $kit_libdir/$scriptname {*}$arglist] - } - .kit { - set exitinfo [punkshell::do_tclkit $kit_libdir/$scriptname "no_repl" {*}$arglist] - } - } - } else { - #fallback to external filesystem - set exedir [file dirname $exepath] - set bin_scripts [file join $exedir scriptlib] - set binsibling_scripts [file join [file dirname $exedir] scriptlib] - set script_check_paths [list] - if {[file exists $bin_scripts]} { - lappend script_check_paths $bin_scripts/$scriptname + } else { + switch -- [dict get $resolved scripttype] { + tcl { + set exitinfo [punkshell::do_script $scriptpath {*}$arglist] } - if {[file exists $binsibling_scripts]} { - lappend script_check_paths $binsibling_scripts/$scriptname + kit { + set exitinfo [punkshell::do_tclkit $scriptpath "no_repl" {*}$arglist] } - if {[llength $script_check_paths]} { - foreach check_path $script_check_paths { - if {[file exists $check_path]} { - switch -- $lower_ext { - .tcl { - set exitinfo [punkshell::do_script $check_path {*}$arglist] - } - .kit { - set exitinfo [punkshell::do_tclkit $check_path "no_repl" {*}$arglist] - } - } - break - } - } - } else { - puts stderr "script $script_or_kit not found in vfs or in filesystem relative to $exedir" - puts stderr "valid locations:" - if {$kit_base ne ""} { - puts stderr " $kit_base/scriptlib/$scriptname" - } - puts stderr " $bin_scripts/$scriptname" - puts stderr " $binsibling_scripts/$scriptname" + default { + puts stderr "No current support for extension [file extension $scriptpath] via lib: (resolved: $scriptpath)" } } - } else { - puts stderr "No current support for extension [file extension $scriptname]" } - - } else { - puts stderr "Path supplied to lib: must be a relative path" } } *.tcl { diff --git a/src/modules/punk/path-999999.0a1.0.tm b/src/modules/punk/path-999999.0a1.0.tm index 1e5e26b1..5e9c9937 100644 --- a/src/modules/punk/path-999999.0a1.0.tm +++ b/src/modules/punk/path-999999.0a1.0.tm @@ -504,15 +504,32 @@ namespace eval punk::path { an executable in /bin. 3. /scriptlib. - If the name's extension (lowercased) is not one of the known - scriptlib extensions (.tcl .py .pl .ps1 .sh), '.tcl' is - appended - only .tcl scripts may be named extensionlessly. + 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 name searched for (extension applied) + 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. + 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\ @@ -522,20 +539,26 @@ namespace eval punk::path { } proc scriptlib_resolve {name} { #shared resolution policy for the 'lib:' script prefix - used by the punk - #executable 'script' subcommand (app-punkscript); app-punkshell's shell - #subcommand encodes the same policy (todo: refactor it onto this proc). + #executable 'script' subcommand (app-punkscript) and the shell subcommand's + #lib: handling (app-punkshell, app_shellrun). if {[lindex [pathtype $name] 0] ne "relative"} { error "punk::path::scriptlib_resolve name must be a relative path (got '$name')" } - set known_extensions [list .tcl .py .pl .ps1 .sh] + set known_extensions [list .tcl .kit .py .pl .ps1 .sh] + #spellings: {relname needs_tcl_check} pairs tried in order within each + #directory - the spelling matching the way the script was called wins + #per-directory, and directory precedence (kit-internal first) is never + #crossed by a spelling preference. if {[string tolower [file extension $name]] ni $known_extensions} { - #only .tcl scripts allowed to be called extensionlessly + #extensionless (or unknown-extension) call: the exact name wins when + #the file identifies as tcl, else fall through to the .tcl spelling. + set spellings [list [list $name 1] [list $name.tcl 0]] set scriptname $name.tcl } else { + set spellings [list [list $name 0]] set scriptname $name } set exepath [file dirname [file normalize [file join [info nameofexecutable] ___]]] ;#symlink resolve - set candidates [list] #1) kit-internal scriptlib - not overridable externally set has_zipfs_command [expr {[info commands ::tcl::zipfs::root] ne ""}] set kit_base "" @@ -550,21 +573,38 @@ namespace eval punk::path { } elseif {[file exists $exepath/scriptlib]} { set kit_libdir $exepath/scriptlib } + set search_dirs [list] if {$kit_libdir ne ""} { - lappend candidates $kit_libdir/$scriptname - if {[file exists $kit_libdir/$scriptname]} { - return [dict create path $kit_libdir/$scriptname scriptname $scriptname candidates $candidates] - } + lappend search_dirs $kit_libdir } #2,3) external scriptlib folders relative to the executable location set exedir [file dirname $exepath] - foreach dir [list [file join $exedir scriptlib] [file join [file dirname $exedir] scriptlib]] { - lappend candidates [file join $dir $scriptname] - if {[file isdirectory $dir] && [file exists [file join $dir $scriptname]]} { - return [dict create path [file join $dir $scriptname] scriptname $scriptname candidates $candidates] + lappend search_dirs [file join $exedir scriptlib] [file join [file dirname $exedir] scriptlib] + + set candidates [list] + set notes [list] + foreach dir $search_dirs { + foreach spelling $spellings { + lassign $spelling relname needs_tcl_check + set fpath [file join $dir $relname] + lappend candidates $fpath + if {![file isfile $fpath]} { + #isfile (not exists) - an extensionless call must not match a directory of the same name + continue + } + if {$needs_tcl_check} { + if {![punk::path::system::scriptfile_is_tcl $fpath]} { + lappend notes "$fpath exists but its first lines do not identify it as tcl (leading '# tcl' comment or tcl shebang) - skipped" + continue + } + set scripttype tcl + } else { + set scripttype [string range [string tolower [file extension $fpath]] 1 end] + } + return [dict create path $fpath scriptname $relname scripttype $scripttype candidates $candidates notes $notes] } } - return [dict create path "" scriptname $scriptname candidates $candidates] + return [dict create path "" scriptname $scriptname scripttype "" candidates $candidates notes $notes] } proc plain {str} { @@ -1676,14 +1716,71 @@ namespace eval punk::path::system { #[subsection {Namespace punk::path::system}] #[para] Internal functions that are not part of the API - - + 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." + @leaders + fpath -type string -optional 0 -help\ + "path of the file to examine" + }] + } + proc scriptfile_is_tcl {fpath} { + if {[catch {open $fpath r} fd]} { + return 0 + } + set chunk "" + catch {chan configure $fd -encoding utf-8} + catch {set chunk [read $fd 512]} + catch {close $fd} + if {[string index $chunk 0] eq "\uFEFF"} { + #utf-8 BOM as decoded above + set chunk [string range $chunk 1 end] + } + set lines [lmap ln [lrange [split $chunk \n] 0 3] {string trimright $ln \r}] + set line1 [lindex $lines 0] + if {[string index $line1 0] ne "#"} { + return 0 + } + if {[regexp {^#!.*\m(tclsh|tclkit|wish)} $line1]} { + #shebang naming a tcl interpreter e.g #!/usr/bin/env tclsh, #!/usr/bin/tclsh9.0 + return 1 + } + if {[regexp -nocase {^#\s*tcl} $line1]} { + #'# tcl' marker comment (prefix match - '# tclscript' etc also accepted) + return 1 + } + if {[string range $line1 0 1] eq "#!"} { + #any other shebang (e.g #!/bin/sh): accept the portable sh-trampoline idiom + # #!/bin/sh + # # \ + # exec tclsh "$0" ${1+"$@"} + #(the backslash-continued comment hides the exec line from tcl but not from sh) + foreach ln [lrange $lines 1 end] { + if {[regexp {^\s*exec\s.*\m(tclsh|tclkit|wish)} $ln]} { + return 1 + } + } + } + return 0 + } } namespace eval ::punk::args::register { #use fully qualified so 8.6 doesn't find existing var in global namespace #documentation-only PUNKARGS registration - discovered lazily if/when punk::args loads - lappend ::punk::args::register::NAMESPACES ::punk::path::argdoc + lappend ::punk::args::register::NAMESPACES ::punk::path::argdoc ::punk::path::system::argdoc } # ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ diff --git a/src/modules/punk/path-buildversion.txt b/src/modules/punk/path-buildversion.txt index 70bd4b55..2ee20848 100644 --- a/src/modules/punk/path-buildversion.txt +++ b/src/modules/punk/path-buildversion.txt @@ -1,4 +1,5 @@ -0.2.2 +0.3.0 #First line must be a semantic version number #all other lines are ignored. +#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: