Browse Source

auto_exec, dir listing and .lnk processing improvements - primarily on windows

master
Julian Noble 4 weeks ago
parent
commit
e1d0130b7a
  1. 1
      src/bootsupport/modules/include_modules.config
  2. 247
      src/bootsupport/modules/punk-0.1.tm
  3. 24
      src/bootsupport/modules/punk/ansi-0.1.1.tm
  4. 35
      src/bootsupport/modules/punk/args-0.2.1.tm
  5. 33
      src/bootsupport/modules/punk/args/moduledoc/tclcore-0.1.0.tm
  6. 786
      src/bootsupport/modules/punk/auto_exec-0.1.0.tm
  7. 353
      src/bootsupport/modules/punk/char-0.1.0.tm
  8. 909
      src/bootsupport/modules/punk/du-0.1.0.tm
  9. 7
      src/bootsupport/modules/punk/mix/commandset/module-0.1.0.tm
  10. 399
      src/bootsupport/modules/punk/nav/fs-0.1.0.tm
  11. 5
      src/bootsupport/modules/punk/path-0.1.0.tm
  12. 290
      src/bootsupport/modules/punk/winlnk-0.1.1.tm
  13. 41
      src/bootsupport/modules/punk/winpath-0.1.0.tm
  14. 247
      src/modules/punk-0.1.tm
  15. 2
      src/modules/punk/ansi-999999.0a1.0.tm
  16. 35
      src/modules/punk/args-999999.0a1.0.tm
  17. 19
      src/modules/punk/args/moduledoc/tclcore-999999.0a1.0.tm
  18. 786
      src/modules/punk/auto_exec-999999.0a1.0.tm
  19. 3
      src/modules/punk/auto_exec-buildversion.txt
  20. 353
      src/modules/punk/char-999999.0a1.0.tm
  21. 909
      src/modules/punk/du-999999.0a1.0.tm
  22. 7
      src/modules/punk/mix/commandset/module-999999.0a1.0.tm
  23. 399
      src/modules/punk/nav/fs-999999.0a1.0.tm
  24. 5
      src/modules/punk/path-999999.0a1.0.tm
  25. 290
      src/modules/punk/winlnk-999999.0a1.0.tm
  26. 41
      src/modules/punk/winpath-999999.0a1.0.tm
  27. 1
      src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/include_modules.config
  28. 247
      src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/punk-0.1.tm
  29. 24
      src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/punk/ansi-0.1.1.tm
  30. 35
      src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/punk/args-0.2.1.tm
  31. 33
      src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/punk/args/moduledoc/tclcore-0.1.0.tm
  32. 786
      src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/punk/auto_exec-0.1.0.tm
  33. 353
      src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/punk/char-0.1.0.tm
  34. 909
      src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/punk/du-0.1.0.tm
  35. 7
      src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/punk/mix/commandset/module-0.1.0.tm
  36. 399
      src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/punk/nav/fs-0.1.0.tm
  37. 5
      src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/punk/path-0.1.0.tm
  38. 290
      src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/punk/winlnk-0.1.1.tm
  39. 41
      src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/punk/winpath-0.1.0.tm
  40. 1
      src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/include_modules.config
  41. 247
      src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/punk-0.1.tm
  42. 24
      src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/punk/ansi-0.1.1.tm
  43. 35
      src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/punk/args-0.2.1.tm
  44. 33
      src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/punk/args/moduledoc/tclcore-0.1.0.tm
  45. 786
      src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/punk/auto_exec-0.1.0.tm
  46. 353
      src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/punk/char-0.1.0.tm
  47. 909
      src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/punk/du-0.1.0.tm
  48. 7
      src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/punk/mix/commandset/module-0.1.0.tm
  49. 399
      src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/punk/nav/fs-0.1.0.tm
  50. 5
      src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/punk/path-0.1.0.tm
  51. 290
      src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/punk/winlnk-0.1.1.tm
  52. 41
      src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/punk/winpath-0.1.0.tm
  53. 247
      src/vfs/_vfscommon.vfs/modules/punk-0.1.tm
  54. 2
      src/vfs/_vfscommon.vfs/modules/punk/ansi-0.1.1.tm
  55. 35
      src/vfs/_vfscommon.vfs/modules/punk/args-0.2.1.tm
  56. 19
      src/vfs/_vfscommon.vfs/modules/punk/args/moduledoc/tclcore-0.1.0.tm
  57. 786
      src/vfs/_vfscommon.vfs/modules/punk/auto_exec-0.1.0.tm
  58. 353
      src/vfs/_vfscommon.vfs/modules/punk/char-0.1.0.tm
  59. 909
      src/vfs/_vfscommon.vfs/modules/punk/du-0.1.0.tm
  60. 7
      src/vfs/_vfscommon.vfs/modules/punk/mix/commandset/module-0.1.0.tm
  61. 399
      src/vfs/_vfscommon.vfs/modules/punk/nav/fs-0.1.0.tm
  62. 5
      src/vfs/_vfscommon.vfs/modules/punk/path-0.1.0.tm
  63. 290
      src/vfs/_vfscommon.vfs/modules/punk/winlnk-0.1.1.tm
  64. 41
      src/vfs/_vfscommon.vfs/modules/punk/winpath-0.1.0.tm

1
src/bootsupport/modules/include_modules.config

@ -54,6 +54,7 @@ set bootsupport_modules [list\
modules punk::assertion\
modules punk::args\
modules punk::args::moduledoc::tclcore\
modules punk::auto_exec\
modules punk::cap\
modules punk::cap::handlers::caphandler\
modules punk::cap::handlers::scriptlibs\

247
src/bootsupport/modules/punk-0.1.tm

@ -75,6 +75,76 @@ namespace eval punk {
#
}
if {![interp issafe] && $::tcl_platform(platform) eq "windows"} {
#return the raw command string from the registry for the association of the given extension, without processing the placeholders such as %1 %SystemRoot% etc.
#This is because we want to process these ourselves to be able to return a proper list of command and arguments.
#Note that the resulting string can't be directly treated as a tcl list because it has double quoted segments with characters that are literals (not escaped)
#Accessing it directly as a list will cause tcl to interpret the backslashes as escapes and lose the literal meaning values such as the path.
proc extension_open_association {ext} {
#look up assoc and ftype to find associated file type and application.
#Note that the windows 'ftype' and 'assoc' commands only examine the system's data - not the user-specific data which may override the system data.
#to get the user-specific associations we need to read the registry keys.
#extensions in the registry seem to be stored lower case wnd with a leading dot.
set lext [string tolower $ext]
package require registry
set user_assoc_path [join [list HKEY_CURRENT_USER Software Microsoft Windows CurrentVersion Explorer FileExts $lext UserChoice] "\\"]
#The UserChoice subkey under this key is where the user-specific association is stored when the user has made a choice. It contains a Progid value which points to the associated file type.
#It can return something like "Python.File" or "Applications\python.exe" or even tcl_auto_file (for .tcl files associated with tclsh by a tcl installer)
#The UserChoice key may not exist if the user hasn't made an explicit choice, in which case we fall back to the system association which is stored under HKEY_CLASSES_ROOT\$ext (which also contains user-specific overrides but is more cumbersome to query for them)
if {![catch {registry get $user_assoc_path Progid} user_choice]} {
if {$user_choice ne ""} {
#examples such as Applications\python.exe and tcl_auto_file are found under HKEY_CURRENT_USER\Software\Classes
#they then have a sub-path shell\open\command which contains the command to open files of that type, which is what we need to extract the associated application.
#it is faster to query for the key directly within a catch than to retrieve keys as lists and iterate through them to find the one we want.
if {![catch {registry get [join [list HKEY_CURRENT_USER Software Classes $user_choice shell open command] "\\"] ""} raw_assoc]} {
#The command string can contain placeholders like "%1" for the file name, so we need to extract just the executable path.
#e.g .py -> "c:\Program Files\Python\python.exe" "%1"
#e.g .rb -> "C:\tools\ruby31\bin\ruby.exe" "%1" %*
# e.g .vbs -> "%SystemRoot%\System32\WScript.exe" "%1" %*
#we need to process this without Tcl interpreting the backslashes as escapes.
#we will use double quotes to determine which entries belong together as a list item for the resulting list of command and arguments.
#set assoc [string map [list \\ \\\\] $raw_assoc]
set assoc $raw_assoc
} else {
#e.g Python.File
if {![catch {registry get [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes $user_choice shell open command] "\\"] ""} raw_assoc]} {
#set assoc [string map [list \\ \\\\] $raw_assoc]
set assoc $raw_assoc
} else {
set assoc ""
}
}
} else {
#review - is it possible for Progid to be empty string? If so we should probably fall back to system association instead of returning no association.
#alternatively it could mean the user has explicitly chosen to have no association for this file type - in which case returning an empty association may be the correct behaviour.
set assoc ""
}
} else {
#fall back to system association and ftype
if {![catch {registry get [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes $lext] "\\"] ""} ftype]} {
#ftype is the file type associated with the extension, e.g "Python.File"
#we then need to look up the command associated with this file type under the same path but with the file type instead of the extension and with the additional sub-path shell\open\command
if {![catch {registry get [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes $ftype shell open command] "\\"] ""} raw_assoc]} {
#set assoc [string map [list \\ \\\\] $raw_assoc]
set assoc $raw_assoc
} else {
set assoc ""
}
} else {
set assoc ""
}
}
return $assoc
}
}
if {![interp issafe] && $has_commandstack && $::tcl_platform(platform) eq "windows"} {
#still a caching version of auto_execok - but with proper(fixed) search order
@ -113,6 +183,8 @@ namespace eval punk {
proc auto_execok_better name {
global auto_execs env tcl_platform
#for now at least, auto_execok_better is windows-specific.
package require punk::auto_exec
if {[info exists auto_execs($name)]} {
return $auto_execs($name)
@ -141,11 +213,60 @@ namespace eval punk {
}
if {[llength [file split $name]] != 1} {
#has a path
#has a path component - could be relative or absolute.
if {[string tolower [file extension $name]] eq ".lnk"} {
#special case .lnk
#todo - consider working directory or other properties of link before launching?
package require punk::winlnk
if {![catch {punk::winlnk::target $name} linktarget]} {
if {$linktarget ne ""} {
set target $linktarget
} else {
return ""
}
} else {
set target $name
}
} else {
set target $name
}
#always store $name as the key when setting auto_execs.
foreach ext $execExtensions {
set file ${name}${ext}
set file ${target}${ext}
#first execExtension is empty string - ensure we test actual file as given before we try appending extensions.
if {$ext eq ""} {
set test_ext [file extension $file]
} else {
set test_ext $ext
}
if {[file exists $file] && ![file isdirectory $file]} {
return [set auto_execs($name) [list $file]]
#look up assoc and ftype to find associated file type and application.
#Note that the windows 'ftype' and 'assoc' commands only examine the system's data - not the user-specific data which may override the system data.
#set assoc [extension_open_association $ext]
set associnfo [punk::auto_exec::shell_open_command $test_ext]
set valuetype [dict get $associnfo type]
set assoc [dict get $associnfo value]
set ftype [dict get $associnfo filetype]
if {$assoc eq ""} {
return [set auto_execs($name) [list $file]]
} else {
if {[file pathtype $target] eq "relative" && $ftype eq "InternetShortcut"} {
#special case InternetShortcut - cannot accept relative path - so we can't cache it in auto_execs if we used a relative path to launch
#if we return an empty string - the auto_exec will fail to launch this every time.
#The best we can do is return a token for the 'unknown' process to detect and re-resolve the path every time.
#This requires cooperation from 'unknown' which may not be configured to handle this token if the default 'punk' version isn't installed.
#we can't resolve using absolute path here - because we don't want to lock in a specific file for a relative path.
#e.g ::auto_execs(./link.url) = some.exe c:/desktop/link.url
#this would be wrong if the user changed directory and tried to run ./link.url again on a different file with the same name
# - as the cached path would no longer be correct.
return [set auto_execs($name) "(resolve_in_unknown_handler) punk::auto_exec absolute_path required"]
}
puts stderr "auto_execok_better: (review required) assoc $assoc for file $file ext $test_ext"
set run [punk::auto_exec::shell_command_as_tcl_list -type $valuetype $assoc $file] ;# -workingdir [pwd] vs path of script?
return [set auto_execs($name) $run]
#return [set auto_execs($name) [list $file]]
}
}
}
return ""
@ -164,11 +285,20 @@ namespace eval punk {
append path "$windir/system32;$windir/system;$windir;"
}
foreach var {PATH Path path} {
if {[info exists env($var)]} {
append path ";$env($var)"
}
# ------------------------
#Note that unlike an ordinary Tcl array - the linked ::env behaves differently.
#e.g parray ::env Path will not find ::env(PATH) and yet 'info exists env(Path)' returns true.
#similarly 'set ::env(Path) ?newval?' or any case variation can set/get the value of ::env(PATH)
#Windows environment variables are case-insensitive.
#No evidence has been seen that any version of windows; current or historic since NT; can allow differently cased versions
# of an environment variable to exist concurrently in the same environment.
#for this reason we should be able to just use PATH.
#
if {[info exists env(PATH)]} {
append path ";$env(PATH)"
}
# ------------------------
#change2
if {[file extension $name] ne "" && [string tolower [file extension $name]] in [string tolower $execExtensions]} {
@ -177,6 +307,8 @@ namespace eval punk {
set lookfor [lmap ext $execExtensions {string cat ${name} ${ext}}]
}
#puts "-->$lookfor"
foreach dir [split $path {;}] {
set dir [string trim $dir {\\}] ;#trailing slash will result in a tail such as "/python.exe"
#set dir [file normalize $dir]
@ -207,7 +339,30 @@ namespace eval punk {
foreach match [glob -nocomplain -dir $dir -tail {*}$lookfor] {
set file [file join $dir $match]
if {[file exists $file] && ![file isdirectory $file]} {
return [set auto_execs($name) [list $file]]
#set assoc [extension_open_association [file extension $file]]
#todo - cache this lookup for each extension we encounter? maybe not, as the user might like changes reflected between runs. review.
if {"windows" ne $::tcl_platform(platform)} {
return [set auto_execs($name) [list $file]]
}
set associnfo [punk::auto_exec::shell_open_command [file extension $file]]
set assoc [dict get $associnfo value]
set type [dict get $associnfo type]
if {$assoc eq ""} {
return [set auto_execs($name) [list $file]]
} else {
puts stderr "auto_execok_better: assoc $assoc for file $file with type $type"
#return [set auto_execs($name) [list $file]]
#review - our stored auto_execs doesn't have any way to capture the full assoc info such as how subsequent arguments should be processed.
#This may need handling in our Tcl shell 'unknown' function when calls are actually made to these commands
#- we may need to re-process the assoc info at that point to determine how to combine all arguments with the calling specification in the assoc string.
#The workingdir for the command may also need to be determined at that point - should it be the dir of the script being called, or the current dir of the shell?
#The main point of Tcl's auto_execs is to avoid scanning the PATH entries every time a command is called,
#but we may want to keep some of the assoc info available for processing at call time.
set run [punk::auto_exec::shell_command_as_tcl_list -type $type $assoc $file] ;# -workingdir [pwd] vs path of script?
return [set auto_execs($name) $run]
}
}
}
}
@ -5203,11 +5358,11 @@ namespace eval punk {
#These can be stacked with shellfilter and operate as OS handles - which we can't do with fifo2 etc
#
if {[string first " " $new] > 0} {
set c1 $name
} else {
set c1 $new
}
#if {[string first " " $new] > 0} {
# set c1 $name
#} else {
# set c1 $new
#}
# -- --- --- --- ---
set idlist_stdout [list]
@ -5241,6 +5396,24 @@ namespace eval punk {
} else {
set repl_runid [punk::get_repl_runid]
#set ::punk::last_run_display [list]
if {$new eq "(resolve_in_unknown_handler) punk::auto_exec absolute_path required"} {
#re-resolve.
puts "(unknown-handler): auto_execok for $name requires absolute path. Re-resolving $name with absolute path."
set ext [file extension $name]
set associnfo [punk::auto_exec::shell_open_command $ext]
set valuetype [dict get $associnfo type]
set assoc [dict get $associnfo value]
set ftype [dict get $associnfo filetype]
set fullpath [file normalize $name]
#at least for .url files - long paths (paths with multiple spaces?) can fail to run. Using the short path seems to fix this.
#This seems hacky but anyway..
set attributes [file attributes $fullpath]
if {[dict exists $attributes -shortname]} {
set fullpath [dict get $attributes -shortname]
}
set new [punk::auto_exec::shell_command_as_tcl_list -type $valuetype $assoc $fullpath]
}
set redir ">&@stdout <@stdin"
uplevel 1 [list ::catch [concat exec $redir $new [lrange $args 1 end]] ::tcl::UnknownResult ::tcl::UnknownOptions]
@ -6108,6 +6281,16 @@ namespace eval punk {
}
}
#This should be designed to be useful on all platforms.
#Case sensitivity represents a difficulty because even on a particular platform
#- different filesystems or folders may have different case sensitivity configurations.
#as a first step - we can detect windows and mac platforms and treat paths as case-insensitive, vs case-sensitive on other unix-like platforms.
#as a second step - we will consider running a test on each path to determine if the folder at the leaf level is case-sensitive or not.
#- and then use that information to determine how to treat the executables in that path.
#This may be a bit of a performance hit - so we may want to cache the results of this test for each path - and provide a way to clear the cache if needed.
#Alternatively we could just provide an option to treat all paths as case-sensitive or case-insensitive.
#Windows executable search location order:
#1. The current directory.
#2. The directories that are listed in the PATH environment variable.
@ -6203,8 +6386,12 @@ namespace eval punk {
#(This seems to occur when the pattern does *not* contain a wildcard and is probably a bug)
#We would rather report results with the actual case as they appear on the filesystem - so we try to normalize the results.
#The normalization doesn't work as expected - but can be worked around by using 'string range $e 0 end' instead of just $e - which seems to force Tcl to give us the actual case from the filesystem rather than the case as globbed for.
#(another workaround is to use a degenerate case glob e.g when looking for 'SDX.exe' to glob for '[S]DX.exe')
#(another workaround is to use a degenerate case glob e.g when looking for 'SDX.exe' to glob for '[S]DX.exe'
# but tcl's glob does not respect the case of even the character-class pattern - so this is not a reliable workaround).
#see punk::fglob for a work-in-progress glob implementation which gives us more control over case sensitivity and the case of results on windows.
set globresults [lsort -unique [glob -nocomplain -directory $pnormglob -types {f x} {*}$binglobs]]
#JJJ
set executables [list]
foreach e $globresults {
puts stderr "glob result: $e"
@ -6596,6 +6783,29 @@ namespace eval punk {
#These aliases work fine for interactive use - but the result is always a string int-rep
#interp alias {} ~ {} file join $::env(HOME) ;#HOME must be capitalized to work cross platform (lowercase home works on windows - but probably not elsewhere)
#interp alias {} ~ {} apply {args {file join $::env(HOME) $args}}
namespace eval argdoc {
punk::args::define {
@id -id ::punk::~
@cmd -name "punk::~"\
-summary\
"Return home directory"\
-help\
"Return the home directory path. With additional arguments,
return the path obtained by joining the home directory with
the supplied arguments.
usage e.g
cd [~]
(change to home directory)
.// [~] .config
(change to .config directory within home directory
and list contents.)
"
@opts
@values -min 0 -max -1
arg -type any -optional 1 -multiple 1
}
}
proc ~ {args} {
set hdir [punk::valcopy $::env(HOME)]
file pathtype $hdir
@ -7811,9 +8021,10 @@ namespace eval punk {
# -------------------------------------------------------
set title "[a+ brightgreen] Filesystem navigation: "
set cmdinfo [list]
lappend cmdinfo [list ./ "?${I}subdir${NI}?" "view/change directory"]
lappend cmdinfo [list ../ "" "go up one directory"]
lappend cmdinfo [list ./new "${I}subdir${NI}" "make new directory and switch to it"]
lappend cmdinfo [list ./ "?${I}glob${NI}?" "view/change dir, list dirs."]
lappend cmdinfo [list ../ "?${I}path${NI}" "go up one dir, then to path if given"]
lappend cmdinfo [list .// "?${I}glob${NI}?" "view/change dir, list dirs and files"]
lappend cmdinfo [list ./new "${I}subdir${NI}..." "make new dir or dirs and show status"]
lappend cmdinfo [list fcat "${I}file ?file?...${NI}" "cat file(s)"]
set t [textblock::class::table new -minwidth 80 -show_seps 0]
foreach row $cmdinfo {
@ -7859,7 +8070,7 @@ namespace eval punk {
lappend cmdinfo [list i "${I}cmd${NI} ?${I}subcommand${NI}...?" "Show usage for a command or ensemble subcommand"]
lappend cmdinfo [list s "${I}cmd${NI} ?${I}subcommand${NI}...?" "Show synopsis for a command or ensemble subcommand"]
lappend cmdinfo [list eg "${I}cmd${NI} ?${I}subcommand${NI}...?" "Show example from manpage"]
lappend cmdinfo [list corp "${I}proc${NI}" "View proc body and arguments"]
lappend cmdinfo [list corp "${I}proc${NI}" "View proc body and arguments with basic highlighting"]
set t [textblock::class::table new -minwidth 80 -show_seps 0]
foreach row $cmdinfo {
$t add_row $row

24
src/bootsupport/modules/punk/ansi-0.1.1.tm

@ -1148,7 +1148,7 @@ tcl::namespace::eval punk::ansi {
"The grepstr command can find strings in ANSI text even if there are interspersed
ANSI colour codes etc. Even if a word has different coloured/styled letters, the
regex can match the plaintext. (Search is performed on ansistripped text, and then
the matched sections are highlighted and overlayed on the original styled/colourd
the matched sections are highlighted and overlayed on the original styled/coloured
input.
If the input string has ANSI movement codes - the resultant text may not be directly
@ -3524,9 +3524,9 @@ Brightblack 100 Brightred 101 Brightgreen 102 Brightyellow 103 Brightblu
underline {lappend t 4}
underlinedefault {lappend t 59}
underextendedoff {
#Remove any existing 4:1 etc extended underline codes
#NOTE: struct::set result order can differ depending on whether tcl/critcl impl is used.
#FIXED: Using punk::lib::ldiff instead of struct::set difference for consistent ordering.
#lremove any existing 4:1 etc
#NOTE struct::set result order can differ depending on whether tcl/critcl imp used
#set e [struct::set difference $e [list 4:1 4:2 4:3 4:4 4:5]]
set e [punk::lib::ldiff $e [list 4:1 4:2 4:3 4:4 4:5]]
lappend e 4:0
}
@ -3543,8 +3543,9 @@ Brightblack 100 Brightred 101 Brightgreen 102 Brightyellow 103 Brightblu
lappend e 4:4
}
underdashed - underdash {
# FIXED: Extended codes with colon suppress normal SGR attributes when in same escape sequence
# on terminals that don't support the extended codes. Emit as separate sequence if needed.
#TODO - fix
# extended codes with colon suppress normal SGR attributes when in same escape sequence on terminal that don't support the extended codes.
# need to emit in
lappend e 4:5
}
doubleunderline {lappend t 21}
@ -4732,11 +4733,7 @@ Brightblack 100 Brightred 101 Brightgreen 102 Brightyellow 103 Brightblu
if {[string length $uri] > 2083} {
error "punk::ansi::hyperlink uri too long: limit 2083"
}
#SECURITY: Sanitize hyperlink ID to prevent injection attacks
#Current mapping: : -> . ; -> , prevents common delimiter issues
#FUTURE: Consider additional restrictions on special characters (=, &, ?, #, etc.)
#to prevent URL parameter injection or other hyperlink protocol exploits
set id [string map {: . {;} ,} $id]
set id [string map {: . {;} ,} $id] ;#avoid some likely problematic ids. TODO - review, restrict further.
set params "id=$id"
return "\x1b\]8\;$params\;$uri\x1b\\"
}
@ -6829,9 +6826,8 @@ tcl::namespace::eval punk::ansi::ta {
# -- --- --- ---
#variable re_ansi_detect1 "${re_csi_code}|${re_esc_osc1}|${re_esc_osc2}|${re_esc_osc3}|${re_standalones}|${re_ST}|${re_g0_open}|${re_g0_close}"
# -- --- --- ---
#handrafted TRIE version of above. Somewhat difficult to construct and maintain.
#FUTURE: Consider using a regexp TRIE generator that works with Tcl regexes for maintainability.
#This does make things quicker - but it's too early to finalise the detect/split regexes (e.g missing \U0090 ) - will need to be redone.
#handrafted TRIE version of above. Somewhat difficult to construct and maintain. TODO - find a regexp TRIE generator that works with Tcl regexes
#This does make things quicker - but it's too early to finalise the detect/split regexes (e.g missing \U0090 ) - will need to be redone.
#variable re_ansi_detect {(?:\x1b(?:\((?:0|B)|\[(?:[\x20-\x2f\x30-\x3f]*[\x40-\x7e])|\](?:(?:[^\007]*)\007|(?:(?!\x1b\\).)*\x1b\\)|(?:P|X|\^|_)(?:(?:(?!\x1b\\|\007).)*(?:\x1b\\|\007))|c|7|8|M|E|D|H|=|>|(?:#(?:3|4|5|6|8))))|(?:\u0090|\u0098|\u009E|\u009F)(?:(?!\u009c).)*(?:\u009c)|(?:\u009b)[\x20-\x2f\x30-\x3f]*[\x40-\x7e]|(?:\u009d)(?:[^\u009c]*)?\u009c}
#NOTE - the literal # char can cause problems in expanded syntax - even though it's within a bracketed section. \# seems to work though.

35
src/bootsupport/modules/punk/args-0.2.1.tm

@ -6148,7 +6148,7 @@ tcl::namespace::eval punk::args {
### set e [lindex $clauseval $clausecolumn]
### set e_check [lindex $clauseval_check $clausecolumn]
### #//review - we may need '?' char on windows
### if {!([tcl::string::length $e_check]>0 && ![regexp {[\"*<>\;]} $e_check])} {
### if {!([tcl::string::length $e_check]>0 && ![regexp {[\"*<>]} $e_check])} {
### #what about special file names e.g on windows NUL ?
### set msg "$argclass $argname for %caller% requires type '$type'. Received: '$e' which doesn't look like it could be a file or directory"
### return -options [list -code error -errorcode [list PUNKARGS VALIDATION [list typemismatch $type] -badarg $argname -argspecs $argspecs]] $msg
@ -6671,9 +6671,12 @@ tcl::namespace::eval punk::args {
directory -
existingfile -
existingdirectory {
#//review - we may need '?' char on windows
if {!([tcl::string::length $e_check]>0 && ![regexp {[\"*<>\;]} $e_check])} {
#what about special file names e.g on windows NUL ?
#we have to support file and directory names on all platforms - and even characters illegal on a filesystem/platform may need to be passed.
#For example a file/folder may be created with an illegal name on a platform (or mounted on it) and be mapped to another string on the filesystem
#- yet it may remain accessible to commands such as file stat etc via the string with 'illegal' characters as well as its underlying stored (mapped) name.
#NUL is almost universally problematic - so we will reject.
if {[tcl::string::length $e_check]==0 || [string first \0 $e_check] >= 0} {
set msg "$argclass $argname for %caller% requires type '$type'. Received: '$e' which doesn't look like it could be a file or directory"
lset clause_results $c_idx $a_idx [list err [list typemismatch $type] msg $msg]
continue
@ -6693,6 +6696,30 @@ tcl::namespace::eval punk::args {
}
lset clause_results $c_idx $a_idx 1
}
existingportablefile -
existingportabledirectory -
portablefile -
portabledirectory {
if {[tcl::string::length $e_check]==0 || [string first \0 $e_check] >= 0 || [punk::winpath::illegalname_test $e_check]} {
set msg "$argclass $argname for %caller% requires type '$type'. Received: '$e' which doesn't look like it could be a portable file or directory (must pass punk::winpath::illegalname_test)"
lset clause_results $c_idx $a_idx [list err [list typemismatch $type] msg $msg]
continue
}
if {$type eq "existingportablefile"} {
if {![file exists $e_check]} {
set msg "$argclass $argname for %caller% requires type '$type'. Received: '$e' which is not an existing file"
lset clause_results $c_idx $a_idx [list err [list typemismatch $type] msg $msg]
continue
}
} elseif {$type eq "existingportabledirectory"} {
if {![file isdirectory $e_check]} {
set msg "$argclass $argname for %caller% requires type '$type'. Received: '$e' which is not an existing directory"
lset clause_results $c_idx $a_idx [list err [list typemismatch $type] msg $msg]
continue
}
}
lset clause_results $c_idx $a_idx 1
}
char {
#review - char vs unicode codepoint vs grapheme?
if {[tcl::string::length $e_check] != 1} {

33
src/bootsupport/modules/punk/args/moduledoc/tclcore-0.1.0.tm

@ -4490,17 +4490,15 @@ tcl::namespace::eval punk::args::moduledoc::tclcore {
# -- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---
# -- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---
#DOCUMENTED: CLOCK_ARITHMETIC and TIME ZONES references added to help text
#TODO - add CLOCK_ARITHMETIC documentation
#TODO - TIME ZONES documentation?
lappend PUNKARGS [list {
@id -id ::tcl::clock::add
@cmd -name "Built-in: tcl::clock::add"\
-summary\
"Add an offset to timeVal in seconds (base 1970-01-01 00:00 UTC)"\
-help\
"Adds a (possibly negative) offset to a time that is expressed as an integer number of seconds.
CLOCK ARITHMETIC: Supports count_unit pairs (e.g., 1 month, 2 weeks) for flexible date arithmetic.
TIME ZONES: Use -timezone option with values like :UTC, :localtime, or location-based zones (:America/New_York).
See the clock manpage for complete CLOCK ARITHMETIC and TIME ZONES documentation."
"Adds a (possibly negative) offset to a time that is expressed as an integer number of seconds. See CLOCK ARITHMETIC for a full description."
@leaders -min 1 -max -1
timeVal -type integer|literal(now) -help\
"Time value in integer number of seconds since epoch time.
@ -5427,6 +5425,23 @@ tcl::namespace::eval punk::args::moduledoc::tclcore {
glob */
}]}
except that the first case doesn't return the trailing “/” and is more platform independent."
#todo - review eopts handling.
#The actual glob function will treat the first non-dashed argument as the first pattern, whereas
#punk::args will treat the first invalid option or non-dashed as the end of options and the start of patterns.
#This means that if you have a pattern that starts with a dash, you must use -- to mark the end of options.
#We need a way to force punk::args to have that more restrictive behaviour in this case so that the help is aligned.
#ie tcl glob requires -- whenever values start with a dash, but punk::args only requires it only when the first of multiple values
#start with a dash or when a single has a prefix-match for a flag.
#Arguably punk::args should be more restrictive and require -- whenever any value starts with a dash.
#e.g punk::args will accept "glob -nonflag" but will warn of "glob -nonflag -nonflag" as an invalid option.
#This helps provide warnings for mistyped flags, but is potentially confusing.
#In any case, the help for glob should be consistent with the actual behaviour of glob, so we need to make sure that punk::args
#is configured to require -- whenever any value starts with a dash.
#This requires either a change to the default behaviour of punk::args when {-- -type none} is used,
#or a new option to control this behaviour.
#A new option may be preferable to avoid changing the default behaviour of punk::args in other cases where it may be desirable
#to allow values starting with dashes without requiring --.
#(e.g when a command only accepts a single value and that value may start with a dash)
-- -type none -help\
"Marks the end of switches. The argument following this one will be treated as a pattern even if it
starts with a -."
@ -5760,8 +5775,8 @@ tcl::namespace::eval punk::args::moduledoc::tclcore {
@form -form configure
@values -min 0 -max -1
#FUTURE: Implement choice-parameters for better validation
#Would allow: -choiceparameters {literalprefix type} for smarter option validation
#TODO - choice-parameters
#?? -choiceparameters {literalprefix type}
optionpair\
-type {string any}\
-typesynopsis {${$I}-option value${$NI}}\
@ -5769,7 +5784,7 @@ tcl::namespace::eval punk::args::moduledoc::tclcore {
-multiple 1\
-choicerestricted 0\
-choices {{-command string} {-granularity int} {-milliseconds int} {-seconds int} {-value any}}\
-help "Option-value pairs. Valid options: -command, -granularity, -milliseconds, -seconds, -value"
-help "(todo: adjust args definition to validate optionpairs properly)"
@form -form query
@values -min 0 -max 1
@ -6943,7 +6958,7 @@ tcl::namespace::eval punk::args::moduledoc::tclcore {
@opts -type string -parsekey "" -group "NESTED LIST OPTIONS" -grouphelp\
"These options are used to search lists of lists. They may be used with any other options."
-stride -type integer -default 1 -typesynopsis strideLength -help\
-stride -type integer -default 1 -range {1 ""} -typesynopsis strideLength -help\
"If this option is specified, the list is treated as consisting of groups of ${$I}strideLength${$NI} elements and the
groups are searched by either their first element or, if the ${$B}-index${$N} option is used, by the element within
each group given by the first index passed to -index (which is then ignored by -index). The resulting

786
src/bootsupport/modules/punk/auto_exec-0.1.0.tm

@ -0,0 +1,786 @@
# -*- tcl -*-
# Maintenance Instruction: leave the 999999.xxx.x as is and use punkshell 'dev make' or bin/punkmake to update from <pkg>-buildversion.txt
# module template: shellspy/src/decktemplates/vendor/punk/modules/template_module-0.0.4.tm
#
# Please consider using a BSD or MIT style license for greatest compatibility with the Tcl ecosystem.
# Code using preferred Tcl licenses can be eligible for inclusion in Tcllib, Tklib and the punk package repository.
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
# (C) 2026
#
# @@ Meta Begin
# Application punk::auto_exec 0.1.0
# Meta platform tcl
# Meta license <unspecified>
# @@ Meta End
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
## Requirements
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
package require Tcl 8.6-
#----------------------
# registry notes
#Computer\HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\ApplicationAssociationToasts
# - associates applications/file types with extensions and protocols.
#.cp items have a shell\cplopen\command subkey instead of shell\open\command
#- but the command string has the same format and placeholders as the shell\open\command entries for other file types,
#so we can handle them in the same way as other file types when we extract the associated command string.
#----------------------
tcl::namespace::eval punk::auto_exec {
namespace eval argdoc {
variable PUNKARGS
set I "\x1b\[3m" ;# [a+ italic]
set NI "\x1b\[23m" ;# [a+ noitalic]
set B "\x1b\[1m" ;# [a+ bold]
set N "\x1b\[22m" ;# [a+ normal]
set T "\x1b\[1\;4m" ;# [a+ bold underline]
set NT "\x1b\[22\;24m\x1b\[4:0m" ;# [a+ normal nounderline]
}
variable PUNKARGS
lappend PUNKARGS [list {
@id -id "::punk::auto_exec::shell_open_command"
@cmd -name "punk::auto_exec::assoc" -help\
{Returns the raw 'open' command string associated with the file type for the given file extension,
by looking up the user-specific association in the registry and falling back to the system
association if no user-specific association is found.
The resulting command string can contain placeholders like "%1" for the file name.
e.g .py -> "c:\Program Files\Python\python.exe" "%1"
e.g .rb -> "C:\tools\ruby31\bin\ruby.exe" "%1" %*
e.g .vbs -> "%SystemRoot%\System32\WScript.exe" "%1" %*
Note that the resulting string has unescaped backslashes within double quotes, so it is
not suitable for direct execution by Tcl without further processing to handle the backslashes
and placeholders.
see 'shell_command_as_tcl_list' for processing the command string into a Tcl list of command and
arguments with placeholders substituted.
}
@opts
@values -min 0 -max 1
ext -type string -default "" -optional true -help\
"File extension to look up, e.g .txt or .py"
}]
proc shell_open_command {ext} {
#look up assoc and ftype to find associated file type and application.
#Note that the windows 'ftype' and 'assoc' commands only examine the system's data - not the user-specific data which may override the system data.
#to get the user-specific associations we need to read the registry keys.
#extensions in the registry seem to be stored lower case wnd with a leading dot.
set lext [string tolower $ext]
package require registry
set user_assoc_path [join [list HKEY_CURRENT_USER Software Microsoft Windows CurrentVersion Explorer FileExts $lext UserChoice] "\\"]
#The UserChoice subkey under this key is where the user-specific association is stored when the user has made a choice. It contains a Progid value which points to the associated file type.
#It can return something like "Python.File" or "Applications\python.exe" or even tcl_auto_file (for .tcl files associated with tclsh by a tcl installer)
set openverb "open"
#The UserChoice key may not exist if the user hasn't made an explicit choice, in which case we fall back to the system association which is stored under HKEY_CLASSES_ROOT\$ext (which also contains user-specific overrides but is more cumbersome to query for them)
if {![catch {registry get $user_assoc_path Progid} user_choice]} {
if {$user_choice ne ""} {
#examples such as Applications\python.exe and tcl_auto_file are found under HKEY_CURRENT_USER\Software\Classes
#they then have a sub-path shell\open\command which contains the command to open files of that type, which is what we need to extract the associated application.
#it is faster to query for the key directly within a catch than to retrieve keys as lists and iterate through them to find the one we want.
#special case .cpl cplfile
if {$user_choice eq "cplfile"} {
set openverb "cplopen"
}
set verbinfo [ftype $user_choice]
if {[dict exists $verbinfo $openverb]} {
set ftypeinfo [dict get $verbinfo $openverb]
return [dict set ftypeinfo filetype $user_choice]
} else {
return [dict create type "notfound" value "" scope user filetype $user_choice]
}
} else {
#review - is it possible for Progid to be empty string? If so we should probably fall back to system association instead of returning no association.
#alternatively it could mean the user has explicitly chosen to have no association for this file type - in which case returning an empty association may be the correct behaviour.
return [dict create type "empty" value "" scope user filetype ""]
}
} else {
#fall back to system association and ftype
if {![catch {registry get [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes $lext] "\\"] ""} ftype]} {
#ftype is the file type associated with the extension, e.g "Python.File"
#we then need to look up the command associated with this file type under the same path but with the file type instead of the extension and with the additional sub-path shell\open\command
#special case .cpl cplfile
if {$ftype eq "cplfile"} {
set openverb "cplopen"
}
set verbinfo [ftype $ftype]
if {[dict exists $verbinfo $openverb]} {
set ftypeinfo [dict get $verbinfo $openverb]
return [dict set ftypeinfo filetype $ftype]
} else {
return [dict create type "notfound" value "" scope system filetype $ftype]
}
} else {
return [dict create type "notfound" value "" scope "" filetype ""]
}
}
#shouldn't get here - there is a return in each branch above.
return "no-result"
}
# %1 - standard placeholder for the first file parameter.
# %L (or %l) - Long File Name form of the path.
# %* - replaces with all parameters passed to the command. (ie not including the command itself)
# %W (or %w) - working directory.
# other placeholders that we won't handle for now:
# %I (or %i) - handle to an Item ID List (IDList)
# e.g for filetype ArchiveFolder (associated with .7z, .gz, .CPIO etc) we have "%SystemRoot%\Explorer.exe" /idlist,%I,%L
# %D (or %d) - Desktop absolute parsing name of the first parameter
# (for items that don't have file system paths) will be the same as %1 for file system items.
#todo - we need to substitute other placeholders such as %SystemRoot% etc. (case insensitively)
# - but only if the type of the key is expand_sz.
#These are environment variables that can be used in the command string.
#when type is sz - only the single letter placeholders are substituted, and environment variables are not substituted.
#when type is expand_sz - the single letter placeholders are substituted, and environment variables are also substituted
#(case insensitively) - but only if they are in the form %VAR%
#A matching environment variable will take precedence over a single letter placeholder if there is a conflict,
#e.g if the string is %1% and there is an environment variable named "1" then it will be substituted instead of the %1 placeholder.
#a non-existant environment variable will not be substituted and it's wrapping % characters will not be included in the output.
#similarly a single letter placeholder that is not recognised, such as %x, will result in only the x being included in the output without the leading % character.
#todo - tests for shell_command_as_tcl_list with various combinations of placeholders, quoted and unquoted,
#and with both reg_sz and reg_expand_sz types, and with various edge cases such as missing arguments, extra arguments,
#empty arguments, unrecognized placeholders, invalid env var references etc.
lappend PUNKARGS [list {
@id -id "::punk::auto_exec::shell_command_as_tcl_list"
@cmd -name "punk::auto_exec::shell_command_as_tcl_list" -help\
{}
@opts
-type -type string -default sz -choices {sz expand_sz} -help\
"The type of the registry value containing the command string, which determines how
environment variables are substituted. sz means environment variables will not
be substituted, and expand_sz means environment variables will be substituted
if they are in the form %VAR% (case insensitively) and will take precedence over
single letter placeholders if there is a conflict.
In either case, the single letter placeholders will be substituted as follows:
%1 - standard placeholder for the first file parameter.
%L (or %l) - Long File Name form of the path.
%* - replaced with all subsequent parameters passed to the command.
(but not including the script name itself)
%W (or %w) - working directory."
-workingdir -type string -default "" -help\
"The working directory to substitute for the %W (or %w) placeholder."
@values -min 1 -max -1
commandspec -type string -help\
"The command string to process, which can contain placeholders like %1 for the file name,
and a list of arguments to substitute for the placeholders. The command string is typically
obtained from the registry for a file type association, and the arguments are typically the
file name and other parameters to substitute into the command string."
arg -type any -multiple 1 -optional 1 -help\
{One or more arguments to substitute for the placeholders in the command string.
The first argument (often a script or document path) will be substituted for %1,
the second argument will be substituted for %2, and so on. If the command string
contains a %* placeholder, then all of the arguments will be substituted for that
placeholder starting from %2.
If there are more placeholders than arguments, then the extra placeholders will be
substituted with empty string.
If missing arguments are specified in the commandspec as quoted strings, eg "%3" then
corresponding empty strings as separate arguments will be included in the output.}
}]
proc shell_command_as_tcl_list {args} {
set argd [punk::args::parse $args withid ::punk::auto_exec::shell_command_as_tcl_list]
lassign [dict values $argd] _leaders opts values received
set type [dict get $opts -type]
set workingdir [dict get $opts -workingdir]
set commandspec [dict get $values commandspec]
if {[dict exists $received arg]} {
set arglist [dict get $values arg]
} else {
set arglist [list]
}
set result [list]
set quoted [list 0] ;#track whether the current chunk is quoted or not. Only the last item is relevant.
set chars [split [string trim $commandspec] ""]
set in_quote 0
set current_chunk {}
set new_chunk 1
set got_placeholder 0
for {set i 0} {$i < [llength $chars]} {incr i} {
set char [lindex $chars $i]
if {$in_quote} {
if {$char eq "\""} {
if {$got_placeholder} {
#wasn't a valid placeholder - % not emitted.
set got_placeholder 0
}
#The windows implementation doesn't seem to close off a chunk just because it encounters a closing quote,
#so we don't do that either.
#The closing quote just affects whether the next space will terminate the chunk or not.
set in_quote 0
} else {
if {$char eq "%"} {
#we do not handle the trailing % of an env var such as %VAR% here
#- as we scan for that in the default case of the switch below.
if {$got_placeholder} {
#this is a % escaped by doubling up ie a literal % in the output
append current_chunk "%"
set got_placeholder 0
} else {
set got_placeholder 1
}
} elseif {$got_placeholder} {
if {$type eq "expand_sz"} {
#we scan for environment variables in the form %VAR% (case insensitively) and substitute them if found, taking precedence over single letter placeholders if there is a conflict.
#a non-existant environment variable will not be substituted and it's wrapping % characters will not be included in the output.
set env_var_name $char
for {set j [expr {$i+1}]} {$j < [llength $chars]} {incr j} {
set next_char [lindex $chars $j]
if {$next_char in {" " \t}} {
#end of env var name - we don't expect to see spaces within environment variable names,
#treat space as terminator indicating failure to match.
break
} elseif {$next_char eq "\""} {
#end of env var name - we don't expect to see quotes within environment variable names,
#treat quote as terminator indicating failure to match.
break
} elseif {$next_char eq "%"} {
#end of *possible* env var name
break
} else {
append env_var_name $next_char
}
}
if {$next_char eq "%"} {
#we found a closing % character, so we have a possible env var name between the two % characters.
#we will substitute the env var value if it exists,
# or the value of any single letter placeholder if there is a match,
# or the original string between the two % characters if there is no match for either an env var or a single letter placeholder.
if {[info exists ::env($env_var_name)]} {
append current_chunk $::env($env_var_name)
set got_placeholder 0
set i $j ;#advance past the env var name and closing % character for next iteration of main loop
continue
}
} else {
#we didn't find a closing % character, so this isn't a valid env var reference.
set env_var_name ""
#fall through to single % placeholder handling below,
#which will treat the first character after the % as a single letter placeholder and include the rest of the string as literal characters in the output.
}
}
switch -- $char {
1 - L - l {
append current_chunk [lindex $arglist 0]
}
2 - 3 - 4 - 5 - 6 - 7 - 8 - 9 {
append current_chunk [lindex $arglist $char-1]
}
D - d {
#review
#we don't have a way to represent an Item ID List (IDList) in our API, so we'll just substitute the same value as %1 for now,
#which is what windows seems to do for file system items that have file paths.
append current_chunk [lindex $arglist 0]
}
I - i {
#we don't have a way to represent an Item ID List (IDList) in our API, so we'll just substitute something for now.
#format seems to be 9 and 5 digits :nnnnnnnnn:nnnnn
#REVIEW! unknown consequences!
append current_chunk ":000000000:00000"
}
"*" {
append current_chunk [lrange $arglist 1 end]
}
W - w {
append current_chunk $workingdir
}
default {
if {$type eq "expand_sz"} {
#if expand_sz we would have already scanned for environment variables in the form %VAR% and substituted them if found,
#taking precedence over single letter placeholders if there is a conflict.
append current_chunk "$char" [string range $env_var_name 1 end] ;#if env_var_name is empty string then this will just append the single char after the % as a literal,
#which is the correct behaviour for unrecognized placeholders such as %x and for invalid env var references such as %NONEXISTANT%
} else {
append current_chunk $char
}
}
}
set got_placeholder 0
} else {
append current_chunk $char
}
}
} else {
#NOT in quoted string
if {$char eq "\""} {
if {$got_placeholder} {
append current_chunk "%" ;#wasn't a valid placeholder char, so we need to add the % that we stripped off back into the output
set got_placeholder 0
}
set in_quote 1
set new_chunk 0
lset quoted end 1 ;#we'll mark as quoted if any quote found in it - review
} elseif {$char in [list " " \t ]} {
#we don't expect to see tabs or other whitespace characters as separators in the command string, but we'll treat tab the same as spaces just in case.
if {$got_placeholder} {
#wasn't a valid placeholder char. The % is stripped and not included in the output.
set got_placeholder 0
}
#space terminates an unquoted chunk, so we add it to the result list and start a new chunk.
#we also need to ensure that consecutive spaces are treated as a single separator and don't result in empty items in the output list,
if {!$new_chunk} {
if {$current_chunk ne "" || $current_chunk eq "" && [lindex $quoted end]} {
#we add the current chunk to the result list if it's not empty, or if it is empty but is quoted (because in that case we want to preserve it as an empty argument).
lappend result $current_chunk
lappend quoted 0
set current_chunk {}
}
set new_chunk 1
}
for {set j [expr {$i+1}]} {$j < [llength $chars]} {incr j} {
if {[lindex $chars $j] in {" " \t}} {
incr i
} else {
break
}
}
} else {
if {$got_placeholder} {
if {$type eq "expand_sz"} {
#we scan for environment variables in the form %VAR% (case insensitively) and substitute them if found, taking precedence over single letter placeholders if there is a conflict.
#a non-existant environment variable will not be substituted and it's wrapping % characters will not be included in the output.
set env_var_name $char
for {set j [expr {$i+1}]} {$j < [llength $chars]} {incr j} {
set next_char [lindex $chars $j]
if {$next_char in {" " \t}} {
#end of env var name - we don't expect to see spaces within environment variable names,
#treat space as terminator indicating failure to match.
break
} elseif {$next_char eq "\""} {
#end of env var name - we don't expect to see quotes within environment variable names,
#treat quote as terminator indicating failure to match.
break
} elseif {$next_char eq "%"} {
#end of *possible* env var name
break
} else {
append env_var_name $next_char
}
}
if {$next_char eq "%"} {
#we found a closing % character, so we have a possible env var name between the two % characters.
#we will substitute the env var value if it exists,
# or the value of any single letter placeholder if there is a match,
# or the original string between the two % characters if there is no match for either an env var or a single letter placeholder.
if {[info exists ::env($env_var_name)]} {
append current_chunk $::env($env_var_name)
set got_placeholder 0
set i $j ;#advance past the env var name and closing % character for next iteration of main loop
continue
}
} else {
#we didn't find a closing % character, so this isn't a valid env var reference.
set env_var_name ""
#fall through to single % placeholder handling below,
#which will treat the first character after the % as a single letter placeholder and include the rest of the string as literal characters in the output.
}
}
switch -- $char {
"%" {
append current_chunk "%"
set got_placeholder 1
continue
}
1 -
L - l {
#append current_chunk [lindex $arglist 0]
set append_value [string trim [lindex $arglist 0]]
foreach ch [split $append_value ""] {
if {$ch eq " "} {
#we need to split on spaces within arguments as well, because unquoted %1 can expand to multiple arguments if the first argument contains spaces, and we need to preserve the correct splitting of arguments in that case.
#e.g if %1 is substituted with "C:\My Documents\file.txt" then we need to split that into three items in the output list: "C:\My", "Documents\file.txt"
if {$current_chunk ne ""} {
lappend result $current_chunk
lappend quoted 0
set current_chunk {}
}
set new_chunk 1
} else {
append current_chunk $ch
set new_chunk 0
}
}
}
2 - 3 - 4 - 5 - 6 - 7 - 8 - 9 {
append current_chunk [lindex $arglist $char-1]
}
D - d {
#review
#we don't have a way to represent an Item ID List (IDList) in our API, so we'll just substitute the same value as %1 for now,
#which is what windows seems to do for file system items that have file paths.
append current_chunk [lindex $arglist 0]
}
I - i {
#we don't have a way to represent an Item ID List (IDList) in our API, so we'll just substitute something for now.
#format seems to be 9 and 5 digits :nnnnnnnnn:nnnnn
#REVIEW! unknown consequences!
append current_chunk ":000000000:00000"
}
"*" {
#the window implementation of unquoted %* always seems to emit the previous chunk before the %* placeholder as a separate item
#in the command line, even if there is no space between them, so we will do the same.
#Note that a following string (quoted or not) immediately after the %* will be appended to the last item in the output list rather than being a separate item,
#which is also consistent with the microsoft implementation.
if {!$new_chunk} {
if {$current_chunk ne "" || $current_chunk eq "" && [lindex $quoted end]} {
lappend result $current_chunk
lappend quoted 0
set current_chunk {}
}
}
lappend result {*}[lrange $arglist 1 end]
lappend quoted {*}[lrepeat [llength $arglist] 0]
}
W - w {
append current_chunk $workingdir
}
default {
if {$type eq "expand_sz"} {
#if expand_sz we would have already scanned for environment variables in the form %VAR% and substituted them if found,
#taking precedence over single letter placeholders if there is a conflict.
append current_chunk "$char" [string range $env_var_name 1 end] ;#if env_var_name is empty string then this will just append the single char after the % as a literal,
#which is the correct behaviour for unrecognized placeholders such as %x and for invalid env var references such as %NONEXISTANT%
} else {
append current_chunk $char
}
}
}
set got_placeholder 0
set new_chunk 0
} else {
if {$char eq "%"} {
set got_placeholder 1
} else {
append current_chunk $char
set new_chunk 0
}
}
}
}
}
if {$current_chunk ne "" || $current_chunk eq "" && [lindex $quoted end]} {
#review - edge case - if the command string ends with a space then we would have already added the last chunk to the result list and started a new chunk, so we shouldn't add an empty chunk to the result list in that case.
#however if the last chunk was quoted and is empty because the command string ends with a placeholder that is substituted with an empty string, then we should add the empty chunk to the result list.
lappend result $current_chunk
}
return $result
}
namespace eval punk::auto_exec::system {
proc assoc_get_info {ext} {
set lext [string tolower $ext]
set result [dict create system "" user ""]
set user_assoc_path [join [list HKEY_CURRENT_USER Software Microsoft Windows CurrentVersion Explorer FileExts $lext UserChoice] "\\"]
if {![catch {registry get $user_assoc_path Progid} user_choice]} {
dict set result user $user_choice
}
if {![catch {registry get [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes $lext] "\\"] ""} ftype]} {
dict set result system $ftype
}
return $result
}
}
lappend PUNKARGS [list {
@id -id "::punk::auto_exec::assoc"
@cmd -name "punk::auto_exec::assoc"\
-summary\
"Look up the associated file type (system and user) for a file extension"\
-help\
"Get the associated file type for a file extension by looking up the user-specific
file type in the registry and falling back to the system file type if no
user-specific association is found.
Returns a dict with keys ${$I}system${$NI} and ${$I}user${$NI}.
One or more of the key values may be empty string if there is no defined
file type for the extension.
This is somewhat like the windows 'assoc' command except that the windows command
only looks up the system association and does not take into account any user-specific
overrides.
This function returns both values in the result dictionary if they are available."
@opts
@values -min 0 -max 1
ext -type string -default "" -optional true -help\
"File extension to look up, e.g .txt or .py"
}]
proc assoc {args} {
package require registry
set argd [punk::args::parse $args withid ::punk::auto_exec::assoc]
set ext [dict get $argd values ext]
if {$ext ne ""} {
return [punk::auto_exec::system::assoc_get_info $ext]
} else {
#look up all associated ftypes
set user_ftypes [registry keys {HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts} .*]
set system_ftypes [registry keys {HKEY_LOCAL_MACHINE\SOFTWARE\Classes} .*]
set all_ftypes [lsort -unique [concat $user_ftypes $system_ftypes]]
set results [list]
foreach ftype $all_ftypes {
dict set results $ftype [punk::auto_exec::system::assoc_get_info $ftype]
}
return $results
}
}
namespace eval argdoc {
lappend PUNKARGS [list {
@id -id "::punk::auto_exec::ftype"
@cmd -name "punk::auto_exec::ftype"\
-summary\
"Look up shell verb command values from windows file type."\
-help\
"Get the associated shell verb information (such as open) for a file type by looking up the user-specific
association in the registry and falling back to the system association if no
user-specific association is found.
Returns a dict of dicts with toplevel keys for each shell verb (e.g open, runas) and values that are dicts with keys
${$I}type${$NI} and ${$I}value${$NI} and ${$I}scope${$NI}, where
type is determined from the registry value type (e.g sz or expand_sz)
string is the raw command string from the registry
scope is either "user" or "system" depending on whether the value was found in the user-specific registry keys or
the system registry keys.
This is somewhat like the windows 'ftype' command except that the windows command only looks for the 'open' verb and
only looks up the system association and does not take into account any user-specific
overrides.
The file type can be looked up using the ${$B}assoc${$N} function in this package.
The command string can contain placeholders like \"%1\" for the file name, and environment variables
in the form %VAR% (case insensitive) if the registry value type is reg_expand_sz (expand_sz),
which will be substituted by the ${$B}shell_command_as_tcl_list${$N} function when processing the command
string into a Tcl list of command and arguments with placeholders substituted."
@opts
@values -min 0 -max 1
filetype -type string -default "" -optional true -help\
"File type associated with a file extension, e.g Python.File.
This can be looked up using the 'assoc' function in this package."
}]
}
#proc ftype {filetype} {
# package require registry
# if {$filetype eq "cplfile"} {
# #special case for cplfile (associated with .cpl files) which doesn't follow the usual pattern of having the command string under shell\open\command,
# #but instead has it under HKEY_LOCAL_MACHINE\SOFTWARE\Classes\cplfile\shell\cplopen\command.
# #There doesn't seem to be any user-specific override for this file type
# #- but we will check for one under HKEY_CURRENT_USER\Software\Classes\cplfile\shell\cplopen\command anyway for consistency with the way we check
# #for user-specific overrides for other file types.
# set key [join [list HKEY_CURRENT_USER Software Classes cplfile shell cplopen command] "\\"]
# } else {
# set key [join [list HKEY_CURRENT_USER Software Classes $filetype shell open command] "\\"]
# }
# if {![catch {registry get $key ""} raw_assoc]} {
# set tp [registry type $key ""]
# return [dict create open [dict create type $tp string $raw_assoc]]
# } else {
# #e.g Python.File
# if {$filetype eq "cplfile"} {
# set key [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes cplfile shell cplopen command] "\\"]
# } else {
# set key [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes $filetype shell open command] "\\"]
# }
# if {![catch {registry get $key ""} raw_assoc]} {
# set tp [registry type $key ""]
# return [dict create type $tp string $raw_assoc]
# } else {
# return [dict create type "" string ""] ;#no association found
# }
# }
#}
proc ftype {filetype} {
package require registry
set resultdict [dict create]
#e.g Python.File
set shellpath [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes $filetype shell] "\\"]
if {![catch {registry keys $shellpath *} shellverbs]} {
foreach verb $shellverbs {
set commandkey [join [list $shellpath $verb command] "\\"]
if {![catch {registry get $commandkey ""} cmdstring]} {
#registry queryies are case insensitive but some are cased differently e.g Open vs open.
#when using the verb as a key in the output dict, we need to normalize so that it is useful for lookups. We'll use lowercase for that.
set verb [string tolower $verb]
set tp [registry type $commandkey ""]
dict set resultdict $verb [dict create type $tp value $cmdstring scope system]
}
}
}
#allow user-specific verbs to be overridden.
set shellpath [join [list HKEY_CURRENT_USER Software Classes $filetype shell] "\\"]
if {![catch {registry keys $shellpath *} shellverbs]} {
foreach verb $shellverbs {
set commandkey [join [list $shellpath $verb command] "\\"]
if {![catch {registry get $commandkey ""} cmdstring]} {
set verb [string tolower $verb]
set tp [registry type $commandkey ""]
dict set resultdict $verb [dict create type $tp value $cmdstring scope user]
}
}
}
return $resultdict
}
}
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
# Secondary API namespace
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
tcl::namespace::eval punk::auto_exec::lib {
tcl::namespace::export {[a-z]*} ;# Convention: export all lowercase
tcl::namespace::path [tcl::namespace::parent]
}
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
#tcl::namespace::eval punk::auto_exec::system {
#}
# == === === === === === === === === === === === === === ===
# Sample 'about' function with punk::args documentation
# == === === === === === === === === === === === === === ===
tcl::namespace::eval punk::auto_exec {
tcl::namespace::export {[a-z]*} ;# Convention: export all lowercase
variable PUNKARGS
variable PUNKARGS_aliases
lappend PUNKARGS [list {
@id -id "(package)punk::auto_exec"
@package -name "punk::auto_exec" -help\
"Package
Description"
}]
namespace eval argdoc {
#namespace for custom argument documentation
proc package_name {} {
return punk::auto_exec
}
proc about_topics {} {
#info commands results are returned in an arbitrary order (like array keys)
set topic_funs [info commands [namespace current]::get_topic_*]
set about_topics [list]
foreach f $topic_funs {
set tail [namespace tail $f]
lappend about_topics [string range $tail [string length get_topic_] end]
}
#Adjust this function or 'default_topics' if a different order is required
return [lsort $about_topics]
}
proc default_topics {} {return [list Description *]}
# -------------------------------------------------------------
# get_topic_ functions add more to auto-include in about topics
# -------------------------------------------------------------
proc get_topic_Description {} {
punk::args::lib::tstr [string trim {
package punk::auto_exec
description to come..
} \n]
}
proc get_topic_License {} {
return "<unspecified>"
}
proc get_topic_Version {} {
return "$::punk::auto_exec::version"
}
proc get_topic_Contributors {} {
set authors {{<julian@precisium.com.au> {Julian Noble}}}
set contributors ""
foreach a $authors {
append contributors $a \n
}
if {[string index $contributors end] eq "\n"} {
set contributors [string range $contributors 0 end-1]
}
return $contributors
}
proc get_topic_custom-topic {} {
punk::args::lib::tstr -return string {
A custom
topic
etc
}
}
# -------------------------------------------------------------
}
# we re-use the argument definition from punk::args::standard_about and override some items
set overrides [dict create]
dict set overrides @id -id "::punk::auto_exec::about"
dict set overrides @cmd -name "punk::auto_exec::about"
dict set overrides @cmd -help [string trim [punk::args::lib::tstr {
About punk::auto_exec
}] \n]
dict set overrides topic -choices [list {*}[punk::auto_exec::argdoc::about_topics] *]
dict set overrides topic -choicerestricted 1
dict set overrides topic -default [punk::auto_exec::argdoc::default_topics] ;#if -default is present 'topic' will always appear in parsed 'values' dict
set newdef [punk::args::resolved_def -antiglobs -package_about_namespace -override $overrides ::punk::args::package::standard_about *]
lappend PUNKARGS [list $newdef]
proc about {args} {
package require punk::args
#standard_about accepts additional choices for topic - but we need to normalize any abbreviations to full topic name before passing on
set argd [punk::args::parse $args withid ::punk::auto_exec::about]
lassign [dict values $argd] _leaders opts values _received
punk::args::package::standard_about -package_about_namespace ::punk::auto_exec::argdoc {*}$opts {*}[dict get $values topic]
}
}
# end of sample 'about' function
# == === === === === === === === === === === === === === ===
# -----------------------------------------------------------------------------
# register namespace(s) to have PUNKARGS,PUNKARGS_aliases variables checked
# -----------------------------------------------------------------------------
# variable PUNKARGS
# variable PUNKARGS_aliases
namespace eval ::punk::args::register {
#use fully qualified so 8.6 doesn't find existing var in global namespace
lappend ::punk::args::register::NAMESPACES ::punk::auto_exec
}
# -----------------------------------------------------------------------------
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
## Ready
package provide punk::auto_exec [tcl::namespace::eval punk::auto_exec {
variable pkg punk::auto_exec
variable version
set version 0.1.0
}]
return

353
src/bootsupport/modules/punk/char-0.1.0.tm

@ -237,6 +237,88 @@ tcl::namespace::eval punk::char {
return $out
}
namespace eval argdoc {
variable PUNKARGS
lappend PUNKARGS [list {
@id -id ::punk::char::ascii2doublewide
@cmd -name punk::char::ascii2doublewide\
-summary\
"string to double-wide unicode char"\
-help\
"Convert parts of string in the ascii range 21 to 7E to corresponding chars from the unicode
'Halfwidth and Fullwidth Forms' block.
The space character (0x20) is converted to the 'IDEOGRAPHIC SPACE' character (0x3000)
Control chars and chars outside the ascii range are not converted and passed through as-is."
@values -min 1 -max 1
str -type string
}]
}
proc ascii2doublewide {str} {
#set base to \UFEE0 and add ascii value of char to get the double-wide character in the 'Halfwidth and Fullwidth Forms' block of unicode
set out ""
foreach ch [split $str ""] {
set decval [scan $ch %c]
if {$decval == 0x20} {
#space char maps to ideographic space rather than the fullwidth space char - as the fullwidth space char is often rendered as a narrow space rather than a wide one - and the ideographic space is more likely to be rendered as a wide space.
set decval 0x3000
} elseif {$decval < 0x21 || $decval > 0x7E} {
#chars outside the ascii range are not converted - pass through as-is
set decval [scan $ch %c]
} else {
set decval [expr {0xFEE0 + $decval}]
}
append out [format %c $decval]
}
return $out
}
proc doublewide2ascii {str} {
set out ""
foreach ch [split $str ""] {
set decval [scan $ch %c]
if {$decval == 0x3000} {
#ideographic space maps to ascii space char
set decval 0x20
append out \U3000
} elseif {$decval < 0xFF01 || $decval > 0xFF5E} {
#chars outside the part of the 'Halfwidth and Fullwidth Forms' block that corresponds to ascii are not converted - pass through as-is
append out $ch
} else {
set decval [expr {$decval - 0xFEE0}]
append out [format %c $decval]
}
}
return $out
}
proc ascii2NTFSPUA {str} {
#set base to \uF000 and add ascii value of char to get the PUA character in the 'Private Use Area' block of unicode
set out ""
foreach ch [split $str ""] {
set decval [scan $ch %c]
if {$decval <= 0x7F} {
append out [format %c [expr {0xF000 + $decval}]]
} else {
#chars outside the ascii range are not converted - pass through as-is
append out $ch
}
}
return $out
}
proc NTFSPUA2ascii {str} {
set out ""
foreach ch [split $str ""] {
set decval [scan $ch %c]
if {$decval >= 0xF000 && $decval <= 0xF0FF} {
set decval [expr {$decval - 0xF000}]
append out [format %c $decval]
} else {
#chars outside the 'Private Use Area' block that corresponds to ascii are not converted - pass through as-is
append out $ch
}
}
return $out
}
proc symbol {} {
tailcall page symbol
@ -2573,11 +2655,269 @@ tcl::namespace::eval punk::char {
return [tcl::string::map $map $str]
}
#todo - lookup from unicode tables
#ISO-3166-1 alpha-2 country codes to flag emojis
#These can change over time.
#todo - lookup from official sources - and update as needed
#
variable flags [dict create\
AC \U1F1E6\U1F1E8\
AD \U1F1E6\U1F1E9\
AE \U1F1E6\U1F1EA\
AF \U1F1E6\U1F1EB\
AG \U1F1E6\U1F1EC\
AI \U1F1E6\U1F1EE\
AL \U1F1E6\U1F1F1\
AM \U1F1E6\U1F1F2\
AO \U1F1E6\U1F1F4\
AQ \U1F1E6\U1F1F6\
AR \U1F1E6\U1F1F7\
AS \U1F1E6\U1F1F8\
AT \U1F1E6\U1F1F9\
AU \U1F1E6\U1F1FA\
AW \U1F1E6\U1F1FC\
AX \U1F1E6\U1F1FD\
AZ \U1F1E6\U1F1FF\
BA \U1F1E7\U1F1E6\
BB \U1F1E7\U1F1E7\
BD \U1F1E7\U1F1E9\
BE \U1F1E7\U1F1EA\
BF \U1F1E7\U1F1EB\
BG \U1F1E7\U1F1EC\
BH \U1F1E7\U1F1ED\
BI \U1F1E7\U1F1EE\
BJ \U1F1E7\U1F1EF\
BL \U1F1E7\U1F1F1\
BM \U1F1E7\U1F1F2\
BN \U1F1E7\U1F1F3\
BO \U1F1E7\U1F1F4\
BQ \U1F1E7\U1F1F6\
BR \U1F1E7\U1F1F7\
BS \U1F1E7\U1F1F8\
BT \U1F1E7\U1F1F9\
BV \U1F1E7\U1F1FB\
BW \U1F1E7\U1F1FC\
BY \U1F1E7\U1F1FE\
BZ \U1F1E7\U1F1FF\
CA \U1F1E8\U1F1E6\
CC \U1F1E8\U1F1E8\
CD \U1F1E8\U1F1E9\
CF \U1F1E8\U1F1Eb\
CG \U1F1E8\U1F1Ec\
CH \U1F1E8\U1F1ED\
CI \U1F1E8\U1F1EE\
CK \U1F1E8\U1F1F0\
CL \U1F1E8\U1F1F1\
CM \U1F1E8\U1F1F2\
CN \U1F1E8\U1F1F3\
CO \U1F1E8\U1F1F4\
CP \U1F1E8\U1F1F5\
CR \U1F1E8\U1F1F7\
CS \U1F1E8\U1F1F8\
CU \U1F1E8\U1F1FA\
CV \U1F1E8\U1F1FB\
CW \U1F1E8\U1F1FC\
CX \U1F1E8\U1F1FE\
CY \U1F1E8\U1F1FE\
CZ \U1F1E8\U1F1FF\
DE \U1F1E9\U1F1EA\
DG \U1F1E9\U1F1EC\
DJ \U1F1E9\U1F1EF\
DK \U1F1E9\U1F1F0\
DM \U1F1E9\U1F1F2\
DO \U1F1E9\U1F1F4\
DZ \U1F1E9\U1F1FF\
EA \U1F1EA\U1F1E6\
EC \U1F1EA\U1F1E8\
EE \U1F1EA\U1F1EA\
EG \U1F1EA\U1F1EC\
EH \U1F1EA\U1F1ED\
ER \U1F1EA\U1F1F7\
ES \U1F1EA\U1F1F8\
ET \U1F1EA\U1F1F9\
EU \U1F1EA\U1F1FA\
FI \U1F1EB\U1F1EE\
FJ \U1F1EB\U1F1EF\
FK \U1F1EB\U1F1F0\
FM \U1F1EB\U1F1F2\
FO \U1F1EB\U1F1F4\
FR \U1F1EB\U1F1F7\
GA \U1F1EC\U1F1E6\
GB \U1F1EC\U1F1E7\
GD \U1F1EC\U1F1E9\
GE \U1F1EC\U1F1EA\
GF \U1F1EC\U1F1EB\
GG \U1F1EC\U1F1EC\
GH \U1F1EC\U1F1ED\
GI \U1F1EC\U1F1EE\
GL \U1F1EC\U1F1F1\
GM \U1F1EC\U1F1F2\
GN \U1F1EC\U1F1F3\
GP \U1F1EC\U1F1F5\
GQ \U1F1EC\U1F1F6\
GR \U1F1EC\U1F1F7\
GS \U1F1EC\U1F1F8\
GT \U1F1EC\U1F1F9\
GU \U1F1EC\U1F1FA\
GW \U1F1EC\U1F1FC\
GY \U1F1EC\U1F1FE\
HK \U1F1ED\U1F1F0\
HM \U1F1ED\U1F1F2\
HN \U1F1ED\U1F1F3\
HR \U1F1ED\U1F1F7\
HT \U1F1ED\U1F1F9\
HU \U1F1ED\U1F1FA\
IC \U1F1EE\U1F1E8\
ID \U1F1EE\U1F1E9\
IE \U1F1EE\U1F1EA\
IL \U1F1EE\U1F1F1\
IM \U1F1EE\U1F1F2\
IN \U1F1EE\U1F1F3\
IO \U1F1EE\U1F1F4\
IQ \U1F1EE\U1F1F6\
IR \U1F1EE\U1F1F7\
IS \U1F1EE\U1F1F8\
IT \U1F1EE\U1F1F9\
JE \U1F1EF\U1F1EA\
JM \U1F1EF\U1F1F2\
JO \U1F1EF\U1F1F4\
JP \U1F1EF\U1F1F5\
KE \U1F1F0\U1F1EA\
KG \U1F1F0\U1F1EC\
KH \U1F1F0\U1F1ED\
KI \U1F1F0\U1F1EE\
KM \U1F1F0\U1F1F2\
KN \U1F1F0\U1F1F3\
KP \U1F1F0\U1F1F5\
KR \U1F1F0\U1F1F7\
KW \U1F1F0\U1F1FC\
KY \U1F1F0\U1F1FE\
KZ \U1F1F0\U1F1FF\
LA \U1F1F1\U1F1E6\
LB \U1F1F1\U1F1E7\
LC \U1F1F1\U1F1E8\
LI \U1F1F1\U1F1EE\
LK \U1F1F1\U1F1F0\
LR \U1F1F1\U1F1F7\
LS \U1F1F1\U1F1F8\
LT \U1F1F1\U1F1F9\
LU \U1F1F1\U1F1FA\
LV \U1F1F1\U1F1FB\
LY \U1F1F1\U1F1FE\
MA \U1F1F2\U1F1E6\
MC \U1F1F2\U1F1E8\
MD \U1F1F2\U1F1E9\
ME \U1F1F2\U1F1EA\
MF \U1F1F2\U1F1EB\
MG \U1F1F2\U1F1EC\
MH \U1F1F2\U1F1ED\
MK \U1F1F2\U1F1F0\
ML \U1F1F2\U1F1F1\
MM \U1F1F2\U1F1F2\
MN \U1F1F2\U1F1F3\
MO \U1F1F2\U1F1F4\
MP \U1F1F2\U1F1F5\
MQ \U1F1F2\U1F1F6\
MR \U1F1F2\U1F1F7\
MS \U1F1F2\U1F1F8\
MT \U1F1F2\U1F1F9\
MU \U1F1F2\U1F1FA\
MV \U1F1F2\U1F1FB\
MW \U1F1F2\U1F1FC\
MX \U1F1F2\U1F1FD\
MY \U1F1F2\U1F1FE\
MZ \U1F1F2\U1F1FF\
NA \U1F1F3\U1F1E6\
NC \U1F1F3\U1F1E8\
NE \U1F1F3\U1F1EA\
NF \U1F1F3\U1F1EB\
NG \U1F1F3\U1F1EC\
NI \U1F1F3\U1F1EE\
NL \U1F1F3\U1F1F1\
NO \U1F1F3\U1F1F4\
NP \U1F1F3\U1F1F5\
NR \U1F1F3\U1F1F7\
NU \U1F1F3\U1F1F8\
NZ \U1F1F3\U1F1ff\
OM \U1F1F4\U1F1F2\
PA \U1F1F5\U1F1E6\
PE \U1F1F5\U1F1EA\
PF \U1F1F5\U1F1EB\
PG \U1F1F5\U1F1EC\
PH \U1F1F5\U1F1ED\
PK \U1F1F5\U1F1ED\
PL \U1F1F5\U1F1F1\
PM \U1F1F5\U1F1F3\
PN \U1F1F5\U1F1F3\
PR \U1F1F5\U1F1F7\
PS \U1F1F5\U1F1F8\
PT \U1F1F5\U1F1F9\
PW \U1F1F5\U1F1FC\
PY \U1F1F5\U1F1FE\
QA \U1F1F6\U1F1E6\
RE \U1F1F7\U1F1EA\
RO \U1F1F7\U1F1F4\
RS \U1F1F7\U1F1F7\
RU \U1F1F7\U1F1FA\
RW \U1F1F7\U1F1FC\
SA \U1F1F8\U1F1E6\
SB \U1F1F8\U1F1E7\
SC \U1F1F8\U1F1E8\
SD \U1F1F8\U1F1E9\
SE \U1F1F8\U1F1EA\
SG \U1F1F8\U1F1EC\
SH \U1F1F8\U1F1ED\
SI \U1F1F8\U1F1EE\
SJ \U1F1F8\U1F1EF\
SK \U1F1F8\U1F1F0\
SL \U1F1F8\U1F1F1\
SM \U1F1F8\U1F1F2\
SN \U1F1F8\U1F1F3\
SO \U1F1F8\U1F1F4\
SR \U1F1F8\U1F1F7\
SS \U1F1F8\U1F1F8\
ST \U1F1F8\U1F1F9\
SV \U1F1F8\U1F1F7\
SX \U1F1F8\U1F1F8\
SY \U1F1F8\U1F1FE\
SZ \U1F1F8\U1F1FF\
TA \U1F1F9\U1F1E6\
TC \U1F1F9\U1F1E8\
TD \U1F1F9\U1F1E9\
TF \U1F1F9\U1F1EB\
TG \U1F1F9\U1F1EC\
TH \U1F1F9\U1F1ED\
TJ \U1F1F9\U1F1EF\
TK \U1F1F9\U1F1F0\
TL \U1F1F9\U1F1F1\
TM \U1F1F9\U1F1F2\
TN \U1F1F9\U1F1F3\
TO \U1F1F9\U1F1F4\
TR \U1F1F9\U1F1F7\
TT \U1F1F9\U1F1F9\
TV \U1F1F9\U1F1FB\
TW \U1F1F9\U1F1FC\
TZ \U1F1F9\U1F1FF\
UA \U1F1FA\U1F1E6\
UG \U1F1FA\U1F1EC\
UM \U1F1FA\U1F1F2\
US \U1F1FA\U1F1F8\
ZW \U1F1FF\U1F1FC
UY \U1F1FA\U1F1FE\
UZ \U1F1FA\U1F1FF\
VA \U1F1FB\U1F1E6\
VC \U1F1FB\U1F1E8\
VE \U1F1FB\U1F1EA\
VG \U1F1FB\U1F1EC\
VI \U1F1FB\U1F1EE\
VN \U1F1FB\U1F1F3\
VU \U1F1FB\U1F1FA\
WF \U1F1FC\U1F1EB\
WS \U1F1FC\U1F1F8\
XK \U1F1FD\U1F1F3\
YE \U1F1FE\U1F1EA\
YT \U1F1FE\U1F1F9\
ZA \U1F1FF\U1F1E6\
ZM \U1F1FF\U1F1F2\
ZW \U1F1FF\U1F1FC\
]
variable rflags
dict for {k v} $flags {
@ -2825,6 +3165,15 @@ tcl::namespace::eval punk::char::lib {
}
}
# -----------------------------------------------------------------------------
# register namespace(s) to have PUNKARGS,PUNKARGS_aliases variables checked
# -----------------------------------------------------------------------------
# variable PUNKARGS
# variable PUNKARGS_aliases
namespace eval ::punk::args::register {
#use fully qualified so 8.6 doesn't find existing var in global namespace
lappend ::punk::args::register::NAMESPACES ::punk::char
}
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++

909
src/bootsupport/modules/punk/du-0.1.0.tm

File diff suppressed because it is too large Load Diff

7
src/bootsupport/modules/punk/mix/commandset/module-0.1.0.tm

@ -158,8 +158,11 @@ namespace eval punk::mix::commandset::module {
If a version is specified in the module argument as well as in -version
the higher version number will be used.
"
-license -default <unspecified>
-author -default <unspecified> -multiple 1
-license -default <unspecified> -help\
"License to be mentioned in the module file. e.g BSD, MIT"
-author -default <unspecified> -multiple 1 -help\
{Author(s) of the module. Multiple authors can be specified by repeating the -author option.
e.g -author {"John Smith" "jsmith@example.com"} -author {"Jane Doe" "jane@example.org"}}
-template -default punk.module
-type -default "[lindex $moduletypes 0]" -choices {$moduletypes}
-force -default 0 -type boolean -help\

399
src/bootsupport/modules/punk/nav/fs-0.1.0.tm

@ -233,6 +233,11 @@ tcl::namespace::eval punk::nav::fs {
set args [lrange $args 1 end]
}
if {$v eq "/"} {
#directory only listing - we can optimize this by not getting file sizes and times - as these are only used for file listings
#we can't completely skip iterating over files as we want to know if there are any files in the directory - as this affects the display of links/shortcuts that point to directories - but we can skip getting sizes and times for files.
#todo?
}
if {![llength $args]} {
#ls is too slow even over a fairly low-latency network
@ -243,12 +248,12 @@ tcl::namespace::eval punk::nav::fs {
commandstack::basecall cd $VIRTUAL_CWD
}
}
set matchinfo [dirfiles_dict -searchbase $VIRTUAL_CWD -with_times {f d l} -with_sizes {f d l}]
set matchinfo [dirfiles_dict -searchbase $VIRTUAL_CWD -with_times {f d l b c p s} -with_sizes {f d l b c p s}]
} else {
if {[pwd] ne $VIRTUAL_CWD} {
commandstack::basecall cd $VIRTUAL_CWD
}
set matchinfo [dirfiles_dict -searchbase [pwd] -with_times {f d l} -with_sizes {f d l}]
set matchinfo [dirfiles_dict -searchbase [pwd] -with_times {f d l b c p s} -with_sizes {f d l b c p s}]
}
set dircount [llength [dict get $matchinfo dirs]]
set filecount [llength [dict get $matchinfo files]]
@ -621,30 +626,41 @@ tcl::namespace::eval punk::nav::fs {
punk::args::define {
@id -id ::punk::nav::fs::d/new
-nonportable -type none -help\
"Allow creation of directories which may not be portable across platforms.
Use with caution and only when you know what you are doing.
This allows creation of directories with names that may be invalid on some
platforms, or that may have special meanings on some platforms
(e.g reserved device names on windows).
If -nonportable is not supplied, then an error will be raised if any supplied
path is non-portable as defined by punk::winpath::illegalname_test.
Regardless of whether -nonportable is supplied or not, some characters are not
suitable for windows or most other platforms and will be rejected with an error.
An example of this is the null character (\0)."
@cmd -name punk::nav::fs::d/new\
-summary\
"Create directory or directories at the specified path(s)."\
-help\
"This command creates directories at the specified path(s).
If any part of the specified path does not exist, then it will be created as well.
If a specified path already exists, then it will be left as-is and no error will be raised.
A summary line is returned for each created directory, with the full path of the created
directory and a status line indicating the number of dirs and files in the directory if
it already existed (or showing 0 for both if it was just created)."
-nonportable -type none\
-help\
"Allows creation of directories which may not be portable across platforms.
Use with caution and only when you know what you are doing.
This allows creation of directories with names that may be invalid on some
platforms, or that may have special meanings on some platforms
(e.g reserved device names on windows).
If -nonportable is not supplied, then an error will be raised if any supplied
path is non-portable as defined by punk::winpath::illegalname_test.
Regardless of whether -nonportable is supplied or not, some characters are not
suitable for windows or most other platforms and will be rejected with an error.
An example of this is the null character (\0)."
@values -min 1 -max -1 -type string
path -type string -multiple 1 -help\
"Path(s) to create. Can be absolute or relative.
If any path is rejected due to -nonportable or other invalid characters,
or because a parent directory is not writable, then no directories will be created.
If any path is rejected due to -nonportable or other invalid characters,
or because a parent directory is not writable, then no directories will be created.
If a path already exists, then it will be left as-is and no error will be raised.
If a path already exists, then it will be left as-is and no error will be raised.
If despite passing the name tests or writability tests, a directory cannot be
created for some reason (e.g other filesystem error) then an error will be raised
and processing of any remaining paths will be aborted."
If despite passing the name tests or writability tests, a directory cannot be
created for some reason (e.g other filesystem error) then an error will be raised
and processing of any remaining paths will be aborted."
}
#todo - synchronize overall behaviour of d/new with that of n/new (for namespaces)
proc d/new {args} {
@ -718,7 +734,7 @@ tcl::namespace::eval punk::nav::fs {
}
#display summaries of created directories (which may have already existed) by reusing d/ to get info on them.
set query_paths [lmap v $paths $v/*]
set query_paths [lmap v $paths {string cat $v "/*"}]
d/ / {*}$query_paths
}
@ -734,6 +750,10 @@ tcl::namespace::eval punk::nav::fs {
#run a file
#review. On unix the shebang should be able to choose the interpreter.
#on windows the ::env(PATHEXT) and file associations should be able to choose the interpreter. (see punk::auto_execok_better)
#when running using ./script_or_exe.name - the windows file association should be used.
#when running using x/ script_or_exe.name - we want to be able to run scripts even if no file association exists (or it is inappropriate for execution e.g ps1 defaults to open in editor)
proc x/ {args} {
if {![llength $args]} {
set result [d/]
@ -748,11 +768,16 @@ tcl::namespace::eval punk::nav::fs {
lua [list exe lua extensions [list ".lua"]]\
perl [list exe perl extensions [list ".pl"]]\
php [list exe php extensions [list ".php"]]\
ps1 [list exe pwsh extensions [list ".ps1"]]\
]
#todo - allow cofnig to specify arguments for the executable. eg pwsh -noprofile -executionpolicy remotesigned for running powershell scripts.
#this is a bit of a hack - fix
set tcl_extensions [list ".tcl" ".tm" ".kit" ".tk"] ;#todo - load from config
set py_extensions [list ".py"]
set lua_extensions [list ".lua"]
set perl_extensions [list ".pl"]
#set lua_extensions [list ".lua"]
#set perl_extensions [list ".pl"]
#set pwsh_extensions [list ".ps1"]
set script_extensions [list]
set extension_lookup [dict create]
@ -792,14 +817,12 @@ tcl::namespace::eval punk::nav::fs {
set ::argc [llength $newargs]
set ::argv $newargs
tailcall source $path
} elseif {$extlower in $py_extensions} {
set pycmd [auto_execok python]
tailcall {*}$pycmd {*}$args
} elseif {$extlower in $script_extensions} {
set exename [dict get $scriptconfig $scriptlang exe]
set cmd [auto_execok $exename]
tailcall {*}$cmd $args
} else {
#review
set fd [open $path r]
set chunk [read $fd 4000]; close $fd
#consider any commented line near top of file containing 'tcl' as likely to be a tcl script of some sort and attempt to source it.
@ -824,7 +847,11 @@ tcl::namespace::eval punk::nav::fs {
}
}
} else {
puts stderr "No script executable known for this"
if {$scriptfile eq ""} {
puts stderr "script not found"
} else {
puts stderr "No script executable known for files with extension [file extension $scriptfile]. Ensure file has a known extension ($script_extensions) or add a commented hint in the file such as #!/usr/bin/env tclsh"
}
}
}
@ -934,13 +961,96 @@ tcl::namespace::eval punk::nav::fs {
# -searchbase is always passed through - and is only used to construct a location path if a relative searchspec was supplied
punk::args::define {
@id -id ::punk::nav::fs::dirfiles_dict
@cmd -name punk::nav::fs::dirfiles_dict
@cmd -name punk::nav::fs::dirfiles_dict\
-summary\
""\
-help\
"This command performs a directory listing of the specified location and returns the results as a dictionary
containing lists of keys such as files and directories and their properties.
The results are returned as a dictionary with the following structure:
{
location <searched location>
searchbase <searchbase supplied>
dirs {<list of directories matching search>}
vfsmounts {<list of entries that are mount points for virtual filesystems>}
links {<list of links matching search>}
linkinfo {<list of link targets for links matching search, in same order as links list, or 'na'>}
files {<list of files matching search>}
filesizes {<list of file sizes for files matching search, in same order as files list, or 'na'>}
sizes {<dictionary keyed on entry name with sub-dictionary of size information keyed with bytes>}
times {<dictionary keyed on entry name with sub-dictionary of time properties keyed with c a m>}
flaggedhidden {<dictionary keyed on entry name with value of 1 if entry is hidden or 0, for entries matching search, or 'na'>}
flaggedsystem {<dictionary keyed on entry name with value of 1 if entry is a system file or 0, for entries matching search, or 'na'>}
flaggedreadonly {<dictionary keyed on entry name with value of 1 if entry is read-only or 0, for entries matching search, or 'na'>}
altnames {<dictionary keyed on entry name with value of list of alternate names for that entry, or 'na'>}
opts {
-glob <glob pattern used for search>
-filedebug <filedebug supplied>
-patterndebug <patterndebug supplied>
-types <types supplied>
-with_sizes <with_sizes supplied>
-with_times <with_times supplied>
}
debuginfo {
<any debug info that may be useful to caller>
}
errors {
<any errors encountered during search, e.g inaccessible folders etc>
}
nonportable {<dictionary keyed on entry name with value of 1 if entry is non-portable or 0>}
underlayfiles {<list of files that are underlay files for virtual filesystem mounts matching search>}
underlayfilesizes {<list of sizes for underlay files for virtual filesystem mounts matching search, in same order as underlayfiles list, or 'na'>}
timinginfo {
<any timing info that may be useful to caller>
}
}
"
@opts -any 0
-searchbase -default ""
-tailglob -default "\uFFFF"
-searchbase -default ""
-tailglob -default "\uFFFF"
-filedebug -default 0 -type boolean
-patterndebug -default 0 -type boolean
#with_sizes & with_times must accept 0|1|f|d|l where f = files d = dirs l = links (punk::du)
-with_sizes -default "\uFFFF" -type string
-with_times -default "\uFFFF" -type string
-with_sizes -default "\uFFFF" -type list
-with_times -default "\uFFFF" -type list
-link_info -default 1 -type boolean -help "When links are included in results, also include resparseinfo if present."
-portabilitycheck -default 1 -type boolean -help\
"Perform portability checks on entry names and flag non-portable entries in results.
Non-portable entries are those that may not be portable across platforms, for example due
to containing characters that are illegal on some platforms, or reserved device names on windows.
When this option is enabled, entries that are determined to be non-portable will be listed under
the 'nonportable' key."
-types -default {} -type list\
-help "Restrict results to specified types.
This uses the same basic types and mechanism as the Tcl 'glob' command.
entry:
{f}iles {d}irs {l}inks {s}ockets {p}ipes {b}lock devices {c}haracter special devices
attributes:
hidden readonly
permissions:
r w x
Note that on windows, Tcl's glob command uses very basic heuristics to determine the permissions - it doesn't
actually check the ACLs.
For example - the test for executable permission is just whether the file has a known executable extension
and the test for readonly is just whether the file has the read-only attribute set.
On unix, the permissions are likely to be determined by checking the actual permissions of the file against
the current user's uid and groups.
When entry types are given, results matching any of those types will be returned.
When attributes or permissions are given, only results matching all of the specified attributes or permissions
will be returned.
The default (empty list) is to return all types and ignore attributes and permissions.
If just attributes or permissions are given without entry types, then the types will be filtered according to
the specified attributes or permissions but not according to the directory entry type
- so for example if just 'hidden' attribute is given, then both files and folders with the hidden attribute
will be returned.
(todo - macintosh specific type handling)
"
@values -min 0 -max -1 -type string -unnamed true
}
proc dirfiles_dict {args} {
@ -955,23 +1065,29 @@ tcl::namespace::eval punk::nav::fs {
#review - spaced paths ?
error "dirfiles_dict: multiple listing not *yet* supported"
}
set searchspec [lindex $searchspecs 0]
# -- --- --- --- --- --- ---
set opt_searchbase [dict get $opts -searchbase]
set opt_tailglob [dict get $opts -tailglob]
set opt_with_sizes [dict get $opts -with_sizes]
set opt_with_times [dict get $opts -with_times]
set opt_searchbase [dict get $opts -searchbase]
set opt_tailglob [dict get $opts -tailglob]
set opt_with_sizes [dict get $opts -with_sizes]
set opt_with_times [dict get $opts -with_times]
set opt_link_info [dict get $opts -link_info]
set opt_types [dict get $opts -types]
set opt_filedebug [dict get $opts -filedebug]
set opt_patterndebug [dict get $opts -patterndebug]
set opt_portabilitycheck [dict get $opts -portabilitycheck]
# -- --- --- --- --- --- ---
set searchspec [lindex $searchspecs 0]
#we don't want to normalize..
#for example if the user supplies ../ we want to see ../result
set is_relativesearchspec [expr {[file pathtype $searchspec] eq "relative"}]
if {$opt_searchbase eq ""} {
set searchbase .
} else {
#if {$opt_searchbase eq ""} {
# set searchbase .
#} else {
set searchbase $opt_searchbase
}
#}
switch -- $opt_tailglob {
@ -979,7 +1095,7 @@ tcl::namespace::eval punk::nav::fs {
if {$searchspec eq ""} {
set location
} else {
if {$is_relativesarchspec} {
if {$is_relativesearchspec} {
#set location [file dirname [file join $opt_searchbase $searchspec]]
set location [punk::path::normjoin $searchbase $searchspec ..]
} else {
@ -1063,7 +1179,7 @@ tcl::namespace::eval punk::nav::fs {
set ts1 [clock clicks -milliseconds]
if {$is_in_vfs} {
set listing [punk::du::lib::du_dirlisting_tclvfs $location -glob $match_contents {*}$next_opt_with_sizes {*}$next_opt_with_times]
set listing [punk::du::lib::du_dirlisting_tclvfs $location -glob $match_contents -filedebug $opt_filedebug -patterndebug $opt_patterndebug {*}$next_opt_with_sizes {*}$next_opt_with_times -types $opt_types]
} else {
set invfs ""
switch -glob -- $location {
@ -1094,20 +1210,25 @@ tcl::namespace::eval punk::nav::fs {
switch -- $invfs {
zipfs {
#relative vs absolute? review - cwd valid for //zipfs:/ ??
set listing [punk::du::lib::du_dirlisting_zipfs $location -glob $match_contents {*}$next_opt_with_sizes {*}$next_opt_with_times]
set listing [punk::du::lib::du_dirlisting_zipfs $location -glob $match_contents -filedebug $opt_filedebug -patterndebug $opt_patterndebug {*}$next_opt_with_sizes {*}$next_opt_with_times -types $opt_types]
}
cookit {
#seems to be a vfs - except it mounts on a pseudo-volume path //cookit:/
#don't use twapi
#could possibly use du_dirlisting_tclvfs REVIEW
#files and folders are all returned with the -types hidden option for glob on windows
set listing [punk::du::lib::du_dirlisting_generic $location -glob $match_contents {*}$next_opt_with_sizes {*}$next_opt_with_times]
set listing [punk::du::lib::du_dirlisting_generic $location -glob $match_contents -filedebug $opt_filedebug -patterndebug $opt_patterndebug {*}$next_opt_with_sizes {*}$next_opt_with_times -types $opt_types]
}
pseudovol {
set listing [punk::du::lib::du_dirlisting_generic $location -glob $match_contents {*}$next_opt_with_sizes {*}$next_opt_with_times]
set listing [punk::du::lib::du_dirlisting_generic $location -glob $match_contents -filedebug $opt_filedebug -patterndube $opt_patterndebug {*}$next_opt_with_sizes {*}$next_opt_with_times -types $opt_types]
}
default {
set listing [punk::du::dirlisting $location -glob $match_contents {*}$next_opt_with_sizes {*}$next_opt_with_times]
#The default call to punk::du::dirlisting will use the most appropriate mechanism for the platform and path
#- e.g twapi for windows local paths (if twapi is available), tcl glob for unix etc
#- and will be able to handle vfs paths that are visible to the filesystem as well (e.g cookit)
#- but may not be able to handle some vfs paths that aren't visible as normal files/folders to the filesystem
#(e.g if the vfs doesn't report itself as a vfs in vfs::filesystem info)
set listing [punk::du::dirlisting $location -glob $match_contents -filedebug $opt_filedebug -patterndebug $opt_patterndebug {*}$next_opt_with_sizes {*}$next_opt_with_times -link_info $opt_link_info -types $opt_types]
}
}
}
@ -1157,6 +1278,10 @@ tcl::namespace::eval punk::nav::fs {
#we add dotfiles to flaggedhidden list in case there is some other mechanism that has flagged items as hidden
if {$::tcl_platform(platform) ne "windows"} {
#lappend flaggedhidden {*}[lsearch -all -inline [list {*}$dirs {*}$files] ".*"]
#todo - fix! we want to match any file or folder with a leading dot
#- but we also want to preserve the full path in the flaggedhidden list - so we need to check the tail of each entry for leading dot, rather than just doing a glob match on the whole path.
lappend flaggedhidden {*}[tcl::prefix::all [list {*}$dirs {*}$files] .]
#e.g we can have dupes in the case where there are vfs mounted files that appear as dirs
#as we will need to do a (nat)sort as a last step - it will be faster to not sort items prematurely
@ -1205,9 +1330,11 @@ tcl::namespace::eval punk::nav::fs {
#jmn
set ts1 [clock milliseconds]
foreach nm [list {*}$dirs {*}$files] {
if {[punk::winpath::illegalname_test $nm]} {
lappend nonportable $nm
if {$opt_portabilitycheck} {
foreach nm [list {*}$dirs {*}$files] {
if {[punk::winpath::illegalname_test $nm]} {
lappend nonportable $nm
}
}
}
set ts2 [clock milliseconds]
@ -1221,6 +1348,165 @@ tcl::namespace::eval punk::nav::fs {
return [dict merge $listing $updated]
}
#replacement for tcl's glob.
#we can document with punk::args - but we don't want the overhead of full option parsing for every glob call - so we'll just do the option parsing manually within the proc.
#todo - add alias for punk::args id ::punk::nav::fs::fglob to ::glob so documentation matches.
proc fglob_parse_test1 {args} {
#tcl's glob requires a -- when arguments in positions that could be an option begin with a dash.
#This means accounting for prefixes of valid options and determining if they accept a parameter (which is allowed to begin with a dash) or not.
#Any argument that is in a possible option position and begins with a dash but isn't a valid option (or the -- itself)
#should raise an error rather than being treated as a literal glob pattern.
set valid_options [list -directory -join -nocomplain -path -tails -types]
set solo_options [list -nocomplain -join -tails]
set eopts_reached 0
set options [dict create] ;#for solos we will set the value to 1, for options that take parameters we will set the value to the parameter
set patterns [list]
for {set i 0} {$i < [llength $args]} {incr i} {
set a [lindex $args $i]
if {[string match -* $a]} {
if {$a eq "--"} {
set eopts_reached 1
set patterns [lrange $args [expr {$i + 1}] end]
break
}
#before -- we need to check if arg is a valid option or not
set full_option_name [tcl::prefix::match -error "" $valid_options $a]
if {$full_option_name ne ""} {
if {$full_option_name ni $solo_options} {
#option takes a parameter - so next arg is parameter even if it looks like an option
incr i
if {$i < [llength $args]} {
set param [lindex $args $i]
dict set options $full_option_name $param
} else {
error "fglob: option \"$a\" requires a parameter but none found"
}
} else {
dict set options $full_option_name 1
}
} else {
error "fglob: bad option \"$a\": must be [join $valid_options ", "] or --"
}
} else {
set patterns [lrange $args $i end]
break
}
}
return [dict create options $options patterns $patterns]
}
proc fglob_parse_test {args} {
#tcl's glob requires a -- when arguments in positions that could be an option begin with a dash.
#This means accounting for prefixes of valid options and determining if they accept a parameter (which is allowed to begin with a dash) or not.
#Any argument that is in a possible option position and begins with a dash but isn't a valid option (or the -- itself)
#should raise an error rather than being treated as a literal glob pattern.
#set valid_options [list -directory -join -nocomplain -path -tails -types]
#set solo_options [list -nocomplain -join -tails]
set options [dict create] ;#for solos we will set the value to 1, for options that take parameters we will set the value to the parameter
set patterns [list]
set arglen [llength $args]
for {set i 0; set j 1; set a [lindex $args 0]} {$i < $arglen} {incr j; set a [lindex $args [incr i]]} {
if {![string match -* $a]} {
#encountered non-option-like argument. All subsequent arguments should be treated as patterns even if they look like options.
return [dict create options $options patterns [lrange $args $i end]]
}
if {$a eq "--"} {
return [dict create options $options patterns [lrange $args $j end]]
}
#flag-like argument before -- we need to check if arg is a valid option or not
set full_option_name [tcl::prefix::match {-directory -join -nocomplain -path -tails -types} $a]
switch -- $full_option_name {
-directory - -path - -types {
#option takes a parameter - so next arg is parameter even if it looks like an option
if {$j < $arglen} {
dict set options $full_option_name [lindex $args $j]
incr i
incr j
} else {
error "fglob: option \"$a\" requires a parameter but none found"
}
}
default {
dict set options $full_option_name 1
}
}
}
return [dict create options $options patterns $patterns]
}
proc fglob {args} {
#tcl's glob requires a -- when arguments in positions that could be an option begin with a dash.
#This means accounting for prefixes of valid options and determining if they accept a parameter (which is allowed to begin with a dash) or not.
#Any argument that is in a possible option position and begins with a dash but isn't a valid option (or the -- itself)
#should raise an error rather than being treated as a literal glob pattern.
#set valid_options [list -directory -join -nocomplain -path -tails -types]
#set solo_options [list -nocomplain -join -tails]
set options [dict create] ;#for solos we will set the value to 1, for options that take parameters we will set the value to the parameter
set patterns [list]
set arglen [llength $args]
for {set i 0; set j 1; set a [lindex $args 0]} {$i < $arglen} {incr j; set a [lindex $args [incr i]]} {
if {![string match -* $a]} {
#encountered non-option-like argument. All subsequent arguments should be treated as patterns even if they look like options.
#return [dict create options $options patterns [lrange $args $i end]]
set patterns [lrange $args $i end]
break
}
if {$a eq "--"} {
#return [dict create options $options patterns [lrange $args $j end]]
set patterns [lrange $args $j end]
break
}
#flag-like argument before -- we need to check if arg is a valid option or not
set full_option_name [tcl::prefix::match {-directory -join -nocomplain -path -tails -types} $a]
switch -- $full_option_name {
-directory - -path - -types {
#option takes a parameter - so next arg is parameter even if it looks like an option
if {$j < $arglen} {
dict set options $full_option_name [lindex $args $j]
incr i
incr j
} else {
error "fglob: option \"$a\" requires a parameter but none found"
}
}
default {
dict set options $full_option_name 1
}
}
}
#return [dict create options $options patterns $patterns]
#todo -join
# process each glob as a directory listing.
if {[dict exists $options -directory]} {
set basedir [dict get $options -directory]
} else {
set basedir ""
}
#ignore -nocomplain - like tcl9 glob - if no results - return empty list rather than error
if {[dict exists $options -types]} {
set types [dict get $options -types]
} else {
set types {}
}
#todo - fix for multiple absolute paths supplied.
#e.g c:/cmdfiles/*.exe c:/repo/jn/shellspy/bin/*.exe
#- we would want to treat as two separate globs with different basedirs
#for common basedir - we might be better off creating a single glob pattern using the brace syntax for alternatives.
#TCL's glob returns a single list when multiple patterns supplied.
#It does not seem to sort or de-dup, or group results by type.
set results [list]
foreach pattern $patterns {
set resultd [dirfiles_dict -portabilitycheck 0 -searchbase $basedir -with_times 0 -with_sizes 0 -link_info 0 -types $types -tailglob $pattern]
#puts [showdict $resultd]
#todo - other types?
lappend results {*}[dict get $resultd dirs] {*}[dict get $resultd files] {*}[dict get $resultd links]
}
return $results
}
punk::args::define {
@id -id ::punk::nav::fs::dirfiles_dict_as_lines
-stripbase -default 0 -type boolean
@ -1257,6 +1543,8 @@ tcl::namespace::eval punk::nav::fs {
# - we may be able to provide a reasonable default for windows vs other - but a proc option is likely needed to allow caller to override the default behaviour on a call by call basis,
# and a config option may be desirable for the user to override the platform default.
# The chosen defaults based on platform may be generally ok - but then not give the desired behaviour when accessing a particular filesystem/mount
# - note also that case-insensitivity can be configured per folder on windows via the fsutil.exe utility
# unfortunately we have no simple & fast way to query the case-sensitivity of a particular folder or filesystem.
if {$::tcl_platform(platform) eq "windows"} {
#case-preserving but case-insensitive matching is the default
foreach d $list_of_dicts {
@ -1704,9 +1992,13 @@ tcl::namespace::eval punk::nav::fs::lib {
punk::args::define {
@id -id ::punk::nav::fs::lib::is_fileglob
@cmd -name punk::nav::fs::lib::is_fileglob
@cmd -name punk::nav::fs::lib::is_fileglob\
-help\
"Determine if a string is a glob pattern as recognised by the tcl 'glob' command.
This is used for example to determine whether to treat a path component as a literal
or a glob pattern when processing paths with glob patterns in them (e.g for the ./ command)."
@values -min 1 -max 1
path -type string -required true -help\
path -type string -optional 0 -help\
{String to test for being a glob pattern as recognised by the tcl 'glob' command.
If the string represents a path with multiple segments, only the final component
of the path will be tested for glob characters.
@ -1799,6 +2091,7 @@ interp alias {} vwd {} punk::nav::fs::vwd ;#return punk::nav::fs::VI
interp alias {} dirlist {} punk::nav::fs::dirlist
interp alias {} dirfiles {} punk::nav::fs::dirfiles
interp alias {} dirfiles_dict {} punk::nav::fs::dirfiles_dict
interp alias {} fglob {} punk::nav::fs::fglob
interp alias {} ./new {} punk::nav::fs::d/new
interp alias {} d/new {} punk::nav::fs::d/new

5
src/bootsupport/modules/punk/path-0.1.0.tm

@ -116,6 +116,8 @@ namespace eval punk::path {
#(sometimes //server.com used as a short form for urls - which doesn't seem too incompatible with this anyway)
# -- ---
#This is intended to be purely a string analysis - without reference to filesystem volumes or vfs or zipfs mountpoints etc
#(but this also means we won't be able to resolve windows shortnames or dos device paths - so we will preserve those as they are) - review
#(It also means we can't resolve per drive working directories on windows - so we will preserve c: as is rather than converting to absolute - review)
#
#TODO - option for caller to provide a -base below which we can't backtrack.
#This is preferable to setting policy here for example regarding forcing no trackback below //servername/share
@ -179,6 +181,9 @@ namespace eval punk::path {
#relpaths all end up with leading . - while not always the simplest form, this is ok. (helps stop inadvertent conversions to absolutes)
# Tests - TODO
# normjoin /d:/..//vfs:/test -> /vfs:/test (good - not converted to //vfs:/test)
#normjoin c: should theoretically return current per drive working directory on c:
# - would need to use win32 GetFullPathName to resolve this.
proc normjoin {args} {
set args [lmap a $args {string map "\\\\ /" $a}]
set path [plainjoin {*}$args]

290
src/bootsupport/modules/punk/winlnk-0.1.1.tm

@ -21,11 +21,11 @@
#[manpage_begin punkshell_module_punk::winlnk 0 0.1.1]
#[copyright "2024"]
#[titledesc {windows shortcut .lnk library}] [comment {-- Name section and table of contents description --}]
#[moddesc {punk::winlnk}] [comment {-- Description at end of page heading --}]
#[moddesc {punk::winlnk}] [comment {-- Description at end of page heading --}]
#[require punk::winlnk]
#[keywords module shortcut lnk parse windows crossplatform]
#[description]
#[para] Tools for reading windows shortcuts (.lnk files) on any platform
#[para] Tools for reading windows shortcuts (.lnk files) on any platform
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
@ -164,6 +164,17 @@ tcl::namespace::eval punk::winlnk {
error "punk::winlnk::Header_Has_LinkFlag error - flagname $flagname not known"
}
}
proc Header_Get_LinkFlags_as_list {contents} {
variable LinkFlags
set allflags [Header_Get_LinkFlags $contents]
set setflags {}
dict for {flagname binflag} $LinkFlags {
if {$allflags & $binflag} {
lappend setflags $flagname
}
}
return $setflags
}
#MS-SHLLINK.pdf documents the .lnk file format in detail, but here is a brief overview of the structure of a .lnk file:
#protocol revision 10.0 (November 2025) https://winprotocoldocs-bhdugrdyduf5h2e4.b02.azurefd.net/MS-SHLLINK/%5bMS-SHLLINK%5d.pdf
@ -487,6 +498,89 @@ tcl::namespace::eval punk::winlnk {
}
}
variable known_guids [dict create\
"00021400-0000-0000-C000-000000000046" {name Desktop guidtype CLSID}\
"00021401-0000-0000-C000-000000000046" {name Shortcut guidtype CLSID}\
"20D04FE0-3AEA-1069-A2D8-08002B30309D" {name "This PC" guidtype CLSID}\
"F02C1A0D-BE21-4350-88B0-7367FC96EF3C" {name "Computers and Devices" guidtype CLSID}\
"645FF040-5081-101B-9F08-00AA002F954E" {name "Recycle Bin" guidtype CLSID}\
"26EE0668-A00A-44D7-9371-BEB064C98683" {name "Control Panel" guidtype CLSID}\
]
proc guid_lookup {guid} {
# This function looks up known GUIDs and returns their associated names or descriptions.
# In a real implementation, this could query a database of known GUIDs or use a predefined mapping of common GUIDs to their meanings.
# For example, it could recognize the CLSID for the "My Computer" folder, the "Network" folder, etc., and return human-readable names for those.
#
variable known_guids
if {[dict exists $known_guids $guid]} {
return [dict get $known_guids $guid]
} else {
if {"windows" eq $::tcl_platform(platform)} {
# On Windows, we can use the registry to look up GUIDs.
#for now we will just look up CLSIDs in HKEY_CLASSES_ROOT CLSID {guid}
package require registry
set reg_path [join [list HKEY_CLASSES_ROOT CLSID "{$guid}"] "\\"]
if {![catch {registry get $reg_path ""} name]} {
return [dict create name $name guidtype "CLSID"]
}
return ""
} else {
# On non-Windows platforms, we likely won't have a way to look up the GUID.
return ""
}
}
return ""
}
#some more hints: https://github.com/libyal/libfwsi/blob/main/documentation/Windows%20Shell%20Item%20format.asciidoc
proc Parse_LinkTargetID_typehex_1F {rawcontent} {
#The structure of this ItemID type is as follows:
#Offset 0: 2 bytes - size of the ItemID (including these 2 bytes)
#Offset 2: 1 byte - type byte (0x1F for file system objects)
#Offset 3: variable length - data specific to the item (e.g. file name, attributes, etc.)
set size_field [string range $rawcontent 0 1]
binary scan $size_field su size
set type_byte [string index $rawcontent 2]
if {[format %02X [scan $type_byte %c]] ne "1F"} {
error "punk::winlnk::Parse_LinkTargetID_typehex_1F error - expected type byte 0x1F but got [format %02X [scan $type_byte %c]]"
}
set parsed [dict create]
if {$size == 20} {
#when size is 20 and type is 0x1F - the data is assumed to be a GUID.
dict set parsed ident "GUID"
dict set parsed indicator [string range $rawcontent 3 3] ;#unknown - specific to shell implementation?
#structure is 4 bytes le, 2 bytes le, 2 bytes le, 8 bytes be
#the final hex format is 8-4-4-4-12 (e.g. 00021401-0000-0000-C000-000000000046) but the endianness is different for the first three parts vs the last two parts, which is a common pattern in Windows GUID/CLSID structures
set d1 [string range $rawcontent 4 7]
set d2 [string range $rawcontent 8 9]
set d3 [string range $rawcontent 10 11]
set d4 [string range $rawcontent 12 19]
#set d1_dec [scan $d1 i val1]
binary scan $d1 i d1_dec
binary scan $d2 s d2_dec
binary scan $d3 s d3_dec
#d4 is 8 bytes treated as individual bytes, so we can scan it as 8 individual bytes
set scan [string repeat %c 8]
set fmt [string repeat %02X 8]
set val4 [scan $d4 $scan]
set guid [format "%08X-%04X-%04X-%02X%02X-%02X%02X%02X%02X%02X%02X" $d1_dec $d2_dec $d3_dec {*}$val4]
dict set parsed guid $guid
set guid_info [guid_lookup $guid]
if {[dict size $guid_info]} {
dict set parsed name [dict get $guid_info name]
dict set parsed guidtype [dict get $guid_info guidtype]
}
return $parsed
} else {
#unknown
return $parsed
}
set data [string range $rawcontent 3 [expr {$size - 1}]]
#TODO - parse the data according to the structure of the ItemID type 0x1F
return [dict create size $size type "1F" rawdata $data]
}
#some clues on the structure of the IDList content and how to parse it can be found in the analysis of CVE-2020-0729,
#which is a remote code execution vulnerability in Windows that can be exploited through specially crafted .lnk files that contain malicious IDList content.
#The analysis of this vulnerability provides insights into how the IDList content is structured and how it can be parsed to extract information about the link target and potentially execute code.
@ -498,8 +592,8 @@ tcl::namespace::eval punk::winlnk {
set offset 0
while {$offset < [string length $idlist_content]} {
if {[string length $idlist_content] - $offset < 2} break
set size_bytes [string range $idlist_content $offset [expr {$offset + 1}]] ;#size including these 2 bytes
binary scan $size_bytes su size
set size_field [string range $idlist_content $offset $offset+1] ;#size including these 2 bytes
binary scan $size_field su size
if {$size == 0} break
if {$size < 2} {
# Invalid size, abort
@ -511,15 +605,24 @@ tcl::namespace::eval punk::winlnk {
break
}
set itemid [string range $idlist_content $offset [expr {$offset + $size - 1}]]
set itemid_bytes [string range $itemid 0 1]
binary scan $itemid_bytes su itemid_size
set itemid_size_field [string range $itemid 0 1]
binary scan $itemid_size_field su itemid_size
#in *general* byte 3 of the ItemID structure can be used to determine the type of the item
#(e.g. file, folder, network location, etc.) but this is not always reliable and can vary
#based on the specific structure of the ItemID and the context in which it is used
set itemid_type_byte [string index $itemid 2]
#puts stderr "ItemID size: $itemid_size, type byte: [format %02X [scan $itemid_type_byte %c]]"
set maybe_type [format %02X [scan $itemid_type_byte %c]]
lappend result [dict create size $itemid_size type $maybe_type rawcontent $itemid]
set typedec [scan $itemid_type_byte %c]
set typehex [format %02X $typedec]
set item_dict [dict create size $itemid_size typehex $typehex typedec $typedec rawbytes $itemid viewbytes [ansistring VIEW -lf 1 $itemid]]
switch -- $typehex {
"1F" {
set parsed [Parse_LinkTargetID_typehex_1F $itemid]
dict set item_dict parsed $parsed
}
}
lappend result $item_dict
incr offset $size
}
@ -615,9 +718,91 @@ tcl::namespace::eval punk::winlnk {
return [dict create localbasepath $localbasepath commonpathsuffix $commonpathsuffix note <incomplete>]
}
proc Contents_Get_DataStrings {contents start} {
set data_strings [string range $contents $start end]
return $data_strings
proc Contents_Get_StringData_String {contents start} {
#All StringData structures have a CountCharacters field of 2 bytes that is an unsigned integer specifying the number of characters
#in the string, followed by the string characters themselves.
#The string of variable length, must not be NULL-terminated.
#(which is 2 bytes of 0x00 if the IsUnicode flag is set in the LinkFlags field, or 1 byte of 0x00 if the IsUnicode flag is not set).
set lenfield [string range $contents $start $start+1]
set r [binary scan $lenfield su count_chars] ;# su is for unsigned short in little endian order
set string_value ""
if {[Header_Has_LinkFlag $contents "IsUnicode"]} {
#string is UTF-16LE encoded
set numbytes [expr {2 * $count_chars}]
set string_bytes [string range $contents $start+2 [expr {$start + 2 + $numbytes - 1}]]
#consider using tcl encoding convertfrom utf-16le instead of manually parsing the UTF-16LE bytes - this would be more robust and handle edge cases better (e.g. surrogate pairs, non-BMP characters, etc.)
set string_value [encoding convertfrom utf-16le $string_bytes]
#for {set i 0} {$i < [string length $string_bytes]} {
# set char_bytes [string range $string_bytes $i [expr {$i + 1}]]
# set r [binary scan $char_bytes su char] ;# s for unsigned short
# append string_value [format %c $char]
# incr i 1 ;# skip the next byte since it's part of the UTF-16LE encoding
#}
} else {
set numbytes $count_chars
set string_bytes [string range $contents $start+2 [expr {$start + 2 + $numbytes - 1}]]
#string is ANSI encoded
set string_value $string_bytes
}
return [dict create string $string_value next_start [expr {$start + 2 + $numbytes}]]
}
#MS-SHLLINK StringData
#https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-shllink/17b69472-0f34-4bcf-b290-eccdb8de224b
#stored in following order - each element is optional based on the presence of corresponding flags in the LinkFlags field of the header:
# NAME_STRING (description) - flag HasName
# RELATIVE_PATH - flag HasRelativePath
# WORKING_DIR - flag HasWorkingDir
# COMMAND_LINE_ARGUMENTS - flag HasArguments
# ICON_LOCATION - flag HasIconLocation
proc Contents_Get_StringData {contents start} {
set flags_enabled [Header_Get_LinkFlags_as_list $contents]
#puts stderr "flags_enabled: $flags_enabled"
#set data_strings [string range $contents $start end]
#return $data_strings
set current_offset $start
set result {}
if {"HasName" in $flags_enabled} {
set stringinfo [Contents_Get_StringData_String $contents $current_offset]
#set current_offset [expr {$current_offset + [string length $name] + 1}] ;#+1 for null terminator
#incr current_offset [string length $name]
set current_offset [dict get $stringinfo next_start]
set name [dict get $stringinfo string]
dict set result name_string $name
}
if {"HasRelativePath" in $flags_enabled} {
set stringinfo [Contents_Get_StringData_String $contents $current_offset]
set current_offset [dict get $stringinfo next_start]
set relative_path [dict get $stringinfo string]
#set current_offset [expr {$current_offset + [string length $relative_path] + 1}] ;#+1 for null terminator
dict set result relative_path $relative_path
}
if {"HasWorkingDir" in $flags_enabled} {
set stringinfo [Contents_Get_StringData_String $contents $current_offset]
set current_offset [dict get $stringinfo next_start]
set working_dir [dict get $stringinfo string]
#set current_offset [expr {$current_offset + [string length $working_dir] + 1}] ;#+1 for null terminator
dict set result working_dir $working_dir
}
if {"HasArguments" in $flags_enabled} {
set stringinfo [Contents_Get_StringData_String $contents $current_offset]
set current_offset [dict get $stringinfo next_start]
set arguments [dict get $stringinfo string]
#set current_offset [expr {$current_offset + [string length $arguments] + 1}] ;#+1 for null terminator
dict set result command_line_arguments $arguments
}
if {"HasIconLocation" in $flags_enabled} {
set stringinfo [Contents_Get_StringData_String $contents $current_offset]
set current_offset [dict get $stringinfo next_start]
set icon_location [dict get $stringinfo string]
#set current_offset [expr {$current_offset + [string length $icon_location] + 1}] ;#+1 for null terminator
dict set result icon_location $icon_location
}
return $result
}
proc Contents_Get_Info {contents} {
@ -639,12 +824,13 @@ tcl::namespace::eval punk::winlnk {
#Working Dir: C:\repo\jn\tclmodules\tomlish\src\modules\test\#modpod-tomlish-0.1.1\suites\roundtrip\roundtrip_files
variable LinkFlags
set flags_enabled [list]
dict for {k v} $LinkFlags {
if {[Header_Has_LinkFlag $contents $k] > 0} {
lappend flags_enabled $k
}
}
set flags_enabled [Header_Get_LinkFlags_as_list $contents]
#set flags_enabled [list]
#dict for {k v} $LinkFlags {
# if {[Header_Has_LinkFlag $contents $k] > 0} {
# lappend flags_enabled $k
# }
#}
set showcommand_val [Header_Get_ShowCommand $contents]
switch -- $showcommand_val {
@ -726,19 +912,13 @@ tcl::namespace::eval punk::winlnk {
}
# ----------------------------------------------------------------------
#todo - get Data strings by parsing contents starting at $next_start
#stored in following order:
# description
# relative path
# working directory
# command line arguments
# icon location
#get StringData by parsing contents starting at $next_start
#Data strings format:
# 2 bytes: number of characters in the string
# following: The string. ASCII or UTF-16 little-endian string
set datastring_dict [Contents_Get_DataStrings $contents $next_start]
#puts stderr "next_start: $next_start"
set datastring_dict [Contents_Get_StringData $contents $next_start]
# ----------------------------------------------------------------------
@ -770,18 +950,32 @@ tcl::namespace::eval punk::winlnk {
target_type_mech $target_type_mech\
idlist $linktargetidlist\
linkinfo $linkfields\
stringdata $datastring_dict\
]
#relative_path "?"
}
namespace eval argdoc {
variable PUNKARGS
lappend PUNKARGS [list {
@id -id ::punk::winlnk::file_check_header
@cmd -name punk::winlnk::file_check_header\
-summary\
"Test if .lnk file has a valid header for a windows shortcut."\
-help\
"Check the header of the file specified in path to see if it matches the expected
structure of a windows .lnk file header.
Returns a boolean.
If an invalid path is provided or the file cannot be read, an error will be raised."
@values -min 1 -max 1
path -type string -help "Path to the .lnk file to resolve"
}]
}
proc file_check_header {path} {
#*** !doctools
#[call [fun file_check_header] [arg path] ]
#[para]Return 0|1
#[para]Determines if the .lnk file specified in path has a valid header for a windows shortcut
set c [Get_contents $path 20]
return [Contents_check_header $c]
}
namespace eval argdoc {
variable PUNKARGS
lappend PUNKARGS [list {
@ -798,10 +992,6 @@ tcl::namespace::eval punk::winlnk {
}]
}
proc resolve {path} {
#*** !doctools
#[call [fun resolve] [arg path] ]
#[para] Return a dict of info obtained by parsing the binary data in a windows .lnk file
#[para] If the .lnk header check fails, then the .lnk file probably isn't really a shortcut file and the dictionary will contain an 'error' key
set c [Get_contents $path]
if {[Contents_check_header $c]} {
return [Contents_Get_Info $c]
@ -840,6 +1030,7 @@ tcl::namespace::eval punk::winlnk {
target_type target_type\
idlist idlist/@*/@*.@*\
linkinfo linkinfo/@*.@*\
stringdata stringdata/@*.@*\
]
set info [resolve $path]
if {[dict exists $info error]} {
@ -869,11 +1060,18 @@ tcl::namespace::eval punk::winlnk {
"Return the target path of the .lnk file specified in path.
This is a convenience function that extracts the target path from the .lnk file and returns it directly,
without all the additional information that resolve provides. If the .lnk header check fails, then
the .lnk file probably isn't really a shortcut file and an error message will be returned."
the .lnk file probably isn't really a shortcut file and an error message will be returned.
Incomplete! - needs to process arguments when HasArguments link flag is set and append the arguments to the target path.
e.g for a shortcut to 'START /D ...' the target will currently just return a path to cmd.exe - which is insufficient."
@values -min 1 -max 1
path -type string -help "Path to the .lnk file to resolve"
}]
}
#consider a shortcut to the following:
# START /D ^"C:\tcl\bin^" wish.exe c:\cmdfiles\ftp.tcl"
# the target currently only returns c:/Windows/System32/cmd.exe.
proc target {path} {
#*** !doctools
#[call [fun target] [arg path] ]
@ -941,19 +1139,6 @@ tcl::namespace::eval punk::winlnk {
}
#proc sample1 {p1 n args} {
# #*** !doctools
# #[call [fun sample1] [arg p1] [arg n] [opt {option value...}]]
# #[para]Description of sample1
# #[para] Arguments:
# # [list_begin arguments]
# # [arg_def tring p1] A description of string argument p1.
# # [arg_def integer n] A description of integer argument n.
# # [list_end]
# return "ok"
#}
#*** !doctools
@ -973,13 +1158,6 @@ tcl::namespace::eval punk::winlnk::lib {
#[para] Secondary functions that are part of the API
#[list_begin definitions]
#proc utility1 {p1 args} {
# #*** !doctools
# #[call lib::[fun utility1] [arg p1] [opt {?option value...?}]]
# #[para]Description of utility1
# return 1
#}
#*** !doctools
@ -1010,7 +1188,7 @@ namespace eval ::punk::args::register {
package provide punk::winlnk [tcl::namespace::eval punk::winlnk {
variable pkg punk::winlnk
variable version
set version 0.1.1
set version 0.1.1
}]
return

41
src/bootsupport/modules/punk/winpath-0.1.0.tm

@ -136,18 +136,30 @@ namespace eval punk::winpath {
proc illegal_char_map_to_ntfs {ch} {
#windows ntfs maps illegal chars to the PUA unicode range 0xF000-0xF0FF for characters that are illegal in windows API but can exist on NTFS or samba shares etc.
#see also: https://cygwin.com/cygwin-ug-net/using-specialnames.html#pathnames-specialchars
#see also: https://stackoverflow.com/questions/76738031/unicode-private-use-characters-in-ntfs-filenames#:~:text=map_dict%20=%20%7B%200xf009:'%5C,c%20return%20(output_name%2C%20mapped)
#see also: https://stackoverflow.com/questions/76738031/unicode-private-use-characters-in-ntfs-filenames
#formula is 0xF000 + ascii value of char.
#(decimal values are 61440 + ascii value of char)
#see also punk::char::ascii2NTFSPUA
# punk::char::codetable ascii
set map [dict create \
"\"" "\uF022" \
"*" "\uF02A" \
":" "\uF03A" \
"<" "\uF03C" \
">" "\uF03E" \
":" "\uF03A" \
"\"" "\uF022" \
"/" "unknown" \
"?" "\uF03F" \
"\\" "\uF05c" \
"|" "\uF07C" \
"?" "\uF03F" \
"*" "\uF02A"]
]
dict set map "/" "\uF02F"
#ESC (\x1b) is also mapped.
dict set map \x1b "\uF01B"
#note that : is used for NTFS alternate data streams - but the character is still illegal in the main filename - so we will map it to a glyph in the PUA range for display purposes - but it will still need dos device syntax to be accessed via windows API.
if {$ch in [dict keys $map]} {
return [dict get $map $ch]
@ -412,14 +424,15 @@ namespace eval punk::winpath::system {
}
# -----------------------------------------------------------------------------
# register namespace(s) to have PUNKARGS,PUNKARGS_aliases variables checked
# -----------------------------------------------------------------------------
# variable PUNKARGS
# variable PUNKARGS_aliases
namespace eval ::punk::args::register {
#use fully qualified so 8.6 doesn't find existing var in global namespace
lappend ::punk::args::register::NAMESPACES ::punk::winpath
}
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
## Ready

247
src/modules/punk-0.1.tm

@ -75,6 +75,76 @@ namespace eval punk {
#
}
if {![interp issafe] && $::tcl_platform(platform) eq "windows"} {
#return the raw command string from the registry for the association of the given extension, without processing the placeholders such as %1 %SystemRoot% etc.
#This is because we want to process these ourselves to be able to return a proper list of command and arguments.
#Note that the resulting string can't be directly treated as a tcl list because it has double quoted segments with characters that are literals (not escaped)
#Accessing it directly as a list will cause tcl to interpret the backslashes as escapes and lose the literal meaning values such as the path.
proc extension_open_association {ext} {
#look up assoc and ftype to find associated file type and application.
#Note that the windows 'ftype' and 'assoc' commands only examine the system's data - not the user-specific data which may override the system data.
#to get the user-specific associations we need to read the registry keys.
#extensions in the registry seem to be stored lower case wnd with a leading dot.
set lext [string tolower $ext]
package require registry
set user_assoc_path [join [list HKEY_CURRENT_USER Software Microsoft Windows CurrentVersion Explorer FileExts $lext UserChoice] "\\"]
#The UserChoice subkey under this key is where the user-specific association is stored when the user has made a choice. It contains a Progid value which points to the associated file type.
#It can return something like "Python.File" or "Applications\python.exe" or even tcl_auto_file (for .tcl files associated with tclsh by a tcl installer)
#The UserChoice key may not exist if the user hasn't made an explicit choice, in which case we fall back to the system association which is stored under HKEY_CLASSES_ROOT\$ext (which also contains user-specific overrides but is more cumbersome to query for them)
if {![catch {registry get $user_assoc_path Progid} user_choice]} {
if {$user_choice ne ""} {
#examples such as Applications\python.exe and tcl_auto_file are found under HKEY_CURRENT_USER\Software\Classes
#they then have a sub-path shell\open\command which contains the command to open files of that type, which is what we need to extract the associated application.
#it is faster to query for the key directly within a catch than to retrieve keys as lists and iterate through them to find the one we want.
if {![catch {registry get [join [list HKEY_CURRENT_USER Software Classes $user_choice shell open command] "\\"] ""} raw_assoc]} {
#The command string can contain placeholders like "%1" for the file name, so we need to extract just the executable path.
#e.g .py -> "c:\Program Files\Python\python.exe" "%1"
#e.g .rb -> "C:\tools\ruby31\bin\ruby.exe" "%1" %*
# e.g .vbs -> "%SystemRoot%\System32\WScript.exe" "%1" %*
#we need to process this without Tcl interpreting the backslashes as escapes.
#we will use double quotes to determine which entries belong together as a list item for the resulting list of command and arguments.
#set assoc [string map [list \\ \\\\] $raw_assoc]
set assoc $raw_assoc
} else {
#e.g Python.File
if {![catch {registry get [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes $user_choice shell open command] "\\"] ""} raw_assoc]} {
#set assoc [string map [list \\ \\\\] $raw_assoc]
set assoc $raw_assoc
} else {
set assoc ""
}
}
} else {
#review - is it possible for Progid to be empty string? If so we should probably fall back to system association instead of returning no association.
#alternatively it could mean the user has explicitly chosen to have no association for this file type - in which case returning an empty association may be the correct behaviour.
set assoc ""
}
} else {
#fall back to system association and ftype
if {![catch {registry get [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes $lext] "\\"] ""} ftype]} {
#ftype is the file type associated with the extension, e.g "Python.File"
#we then need to look up the command associated with this file type under the same path but with the file type instead of the extension and with the additional sub-path shell\open\command
if {![catch {registry get [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes $ftype shell open command] "\\"] ""} raw_assoc]} {
#set assoc [string map [list \\ \\\\] $raw_assoc]
set assoc $raw_assoc
} else {
set assoc ""
}
} else {
set assoc ""
}
}
return $assoc
}
}
if {![interp issafe] && $has_commandstack && $::tcl_platform(platform) eq "windows"} {
#still a caching version of auto_execok - but with proper(fixed) search order
@ -113,6 +183,8 @@ namespace eval punk {
proc auto_execok_better name {
global auto_execs env tcl_platform
#for now at least, auto_execok_better is windows-specific.
package require punk::auto_exec
if {[info exists auto_execs($name)]} {
return $auto_execs($name)
@ -141,11 +213,60 @@ namespace eval punk {
}
if {[llength [file split $name]] != 1} {
#has a path
#has a path component - could be relative or absolute.
if {[string tolower [file extension $name]] eq ".lnk"} {
#special case .lnk
#todo - consider working directory or other properties of link before launching?
package require punk::winlnk
if {![catch {punk::winlnk::target $name} linktarget]} {
if {$linktarget ne ""} {
set target $linktarget
} else {
return ""
}
} else {
set target $name
}
} else {
set target $name
}
#always store $name as the key when setting auto_execs.
foreach ext $execExtensions {
set file ${name}${ext}
set file ${target}${ext}
#first execExtension is empty string - ensure we test actual file as given before we try appending extensions.
if {$ext eq ""} {
set test_ext [file extension $file]
} else {
set test_ext $ext
}
if {[file exists $file] && ![file isdirectory $file]} {
return [set auto_execs($name) [list $file]]
#look up assoc and ftype to find associated file type and application.
#Note that the windows 'ftype' and 'assoc' commands only examine the system's data - not the user-specific data which may override the system data.
#set assoc [extension_open_association $ext]
set associnfo [punk::auto_exec::shell_open_command $test_ext]
set valuetype [dict get $associnfo type]
set assoc [dict get $associnfo value]
set ftype [dict get $associnfo filetype]
if {$assoc eq ""} {
return [set auto_execs($name) [list $file]]
} else {
if {[file pathtype $target] eq "relative" && $ftype eq "InternetShortcut"} {
#special case InternetShortcut - cannot accept relative path - so we can't cache it in auto_execs if we used a relative path to launch
#if we return an empty string - the auto_exec will fail to launch this every time.
#The best we can do is return a token for the 'unknown' process to detect and re-resolve the path every time.
#This requires cooperation from 'unknown' which may not be configured to handle this token if the default 'punk' version isn't installed.
#we can't resolve using absolute path here - because we don't want to lock in a specific file for a relative path.
#e.g ::auto_execs(./link.url) = some.exe c:/desktop/link.url
#this would be wrong if the user changed directory and tried to run ./link.url again on a different file with the same name
# - as the cached path would no longer be correct.
return [set auto_execs($name) "(resolve_in_unknown_handler) punk::auto_exec absolute_path required"]
}
puts stderr "auto_execok_better: (review required) assoc $assoc for file $file ext $test_ext"
set run [punk::auto_exec::shell_command_as_tcl_list -type $valuetype $assoc $file] ;# -workingdir [pwd] vs path of script?
return [set auto_execs($name) $run]
#return [set auto_execs($name) [list $file]]
}
}
}
return ""
@ -164,11 +285,20 @@ namespace eval punk {
append path "$windir/system32;$windir/system;$windir;"
}
foreach var {PATH Path path} {
if {[info exists env($var)]} {
append path ";$env($var)"
}
# ------------------------
#Note that unlike an ordinary Tcl array - the linked ::env behaves differently.
#e.g parray ::env Path will not find ::env(PATH) and yet 'info exists env(Path)' returns true.
#similarly 'set ::env(Path) ?newval?' or any case variation can set/get the value of ::env(PATH)
#Windows environment variables are case-insensitive.
#No evidence has been seen that any version of windows; current or historic since NT; can allow differently cased versions
# of an environment variable to exist concurrently in the same environment.
#for this reason we should be able to just use PATH.
#
if {[info exists env(PATH)]} {
append path ";$env(PATH)"
}
# ------------------------
#change2
if {[file extension $name] ne "" && [string tolower [file extension $name]] in [string tolower $execExtensions]} {
@ -177,6 +307,8 @@ namespace eval punk {
set lookfor [lmap ext $execExtensions {string cat ${name} ${ext}}]
}
#puts "-->$lookfor"
foreach dir [split $path {;}] {
set dir [string trim $dir {\\}] ;#trailing slash will result in a tail such as "/python.exe"
#set dir [file normalize $dir]
@ -207,7 +339,30 @@ namespace eval punk {
foreach match [glob -nocomplain -dir $dir -tail {*}$lookfor] {
set file [file join $dir $match]
if {[file exists $file] && ![file isdirectory $file]} {
return [set auto_execs($name) [list $file]]
#set assoc [extension_open_association [file extension $file]]
#todo - cache this lookup for each extension we encounter? maybe not, as the user might like changes reflected between runs. review.
if {"windows" ne $::tcl_platform(platform)} {
return [set auto_execs($name) [list $file]]
}
set associnfo [punk::auto_exec::shell_open_command [file extension $file]]
set assoc [dict get $associnfo value]
set type [dict get $associnfo type]
if {$assoc eq ""} {
return [set auto_execs($name) [list $file]]
} else {
puts stderr "auto_execok_better: assoc $assoc for file $file with type $type"
#return [set auto_execs($name) [list $file]]
#review - our stored auto_execs doesn't have any way to capture the full assoc info such as how subsequent arguments should be processed.
#This may need handling in our Tcl shell 'unknown' function when calls are actually made to these commands
#- we may need to re-process the assoc info at that point to determine how to combine all arguments with the calling specification in the assoc string.
#The workingdir for the command may also need to be determined at that point - should it be the dir of the script being called, or the current dir of the shell?
#The main point of Tcl's auto_execs is to avoid scanning the PATH entries every time a command is called,
#but we may want to keep some of the assoc info available for processing at call time.
set run [punk::auto_exec::shell_command_as_tcl_list -type $type $assoc $file] ;# -workingdir [pwd] vs path of script?
return [set auto_execs($name) $run]
}
}
}
}
@ -5203,11 +5358,11 @@ namespace eval punk {
#These can be stacked with shellfilter and operate as OS handles - which we can't do with fifo2 etc
#
if {[string first " " $new] > 0} {
set c1 $name
} else {
set c1 $new
}
#if {[string first " " $new] > 0} {
# set c1 $name
#} else {
# set c1 $new
#}
# -- --- --- --- ---
set idlist_stdout [list]
@ -5241,6 +5396,24 @@ namespace eval punk {
} else {
set repl_runid [punk::get_repl_runid]
#set ::punk::last_run_display [list]
if {$new eq "(resolve_in_unknown_handler) punk::auto_exec absolute_path required"} {
#re-resolve.
puts "(unknown-handler): auto_execok for $name requires absolute path. Re-resolving $name with absolute path."
set ext [file extension $name]
set associnfo [punk::auto_exec::shell_open_command $ext]
set valuetype [dict get $associnfo type]
set assoc [dict get $associnfo value]
set ftype [dict get $associnfo filetype]
set fullpath [file normalize $name]
#at least for .url files - long paths (paths with multiple spaces?) can fail to run. Using the short path seems to fix this.
#This seems hacky but anyway..
set attributes [file attributes $fullpath]
if {[dict exists $attributes -shortname]} {
set fullpath [dict get $attributes -shortname]
}
set new [punk::auto_exec::shell_command_as_tcl_list -type $valuetype $assoc $fullpath]
}
set redir ">&@stdout <@stdin"
uplevel 1 [list ::catch [concat exec $redir $new [lrange $args 1 end]] ::tcl::UnknownResult ::tcl::UnknownOptions]
@ -6108,6 +6281,16 @@ namespace eval punk {
}
}
#This should be designed to be useful on all platforms.
#Case sensitivity represents a difficulty because even on a particular platform
#- different filesystems or folders may have different case sensitivity configurations.
#as a first step - we can detect windows and mac platforms and treat paths as case-insensitive, vs case-sensitive on other unix-like platforms.
#as a second step - we will consider running a test on each path to determine if the folder at the leaf level is case-sensitive or not.
#- and then use that information to determine how to treat the executables in that path.
#This may be a bit of a performance hit - so we may want to cache the results of this test for each path - and provide a way to clear the cache if needed.
#Alternatively we could just provide an option to treat all paths as case-sensitive or case-insensitive.
#Windows executable search location order:
#1. The current directory.
#2. The directories that are listed in the PATH environment variable.
@ -6203,8 +6386,12 @@ namespace eval punk {
#(This seems to occur when the pattern does *not* contain a wildcard and is probably a bug)
#We would rather report results with the actual case as they appear on the filesystem - so we try to normalize the results.
#The normalization doesn't work as expected - but can be worked around by using 'string range $e 0 end' instead of just $e - which seems to force Tcl to give us the actual case from the filesystem rather than the case as globbed for.
#(another workaround is to use a degenerate case glob e.g when looking for 'SDX.exe' to glob for '[S]DX.exe')
#(another workaround is to use a degenerate case glob e.g when looking for 'SDX.exe' to glob for '[S]DX.exe'
# but tcl's glob does not respect the case of even the character-class pattern - so this is not a reliable workaround).
#see punk::fglob for a work-in-progress glob implementation which gives us more control over case sensitivity and the case of results on windows.
set globresults [lsort -unique [glob -nocomplain -directory $pnormglob -types {f x} {*}$binglobs]]
#JJJ
set executables [list]
foreach e $globresults {
puts stderr "glob result: $e"
@ -6596,6 +6783,29 @@ namespace eval punk {
#These aliases work fine for interactive use - but the result is always a string int-rep
#interp alias {} ~ {} file join $::env(HOME) ;#HOME must be capitalized to work cross platform (lowercase home works on windows - but probably not elsewhere)
#interp alias {} ~ {} apply {args {file join $::env(HOME) $args}}
namespace eval argdoc {
punk::args::define {
@id -id ::punk::~
@cmd -name "punk::~"\
-summary\
"Return home directory"\
-help\
"Return the home directory path. With additional arguments,
return the path obtained by joining the home directory with
the supplied arguments.
usage e.g
cd [~]
(change to home directory)
.// [~] .config
(change to .config directory within home directory
and list contents.)
"
@opts
@values -min 0 -max -1
arg -type any -optional 1 -multiple 1
}
}
proc ~ {args} {
set hdir [punk::valcopy $::env(HOME)]
file pathtype $hdir
@ -7811,9 +8021,10 @@ namespace eval punk {
# -------------------------------------------------------
set title "[a+ brightgreen] Filesystem navigation: "
set cmdinfo [list]
lappend cmdinfo [list ./ "?${I}subdir${NI}?" "view/change directory"]
lappend cmdinfo [list ../ "" "go up one directory"]
lappend cmdinfo [list ./new "${I}subdir${NI}" "make new directory and switch to it"]
lappend cmdinfo [list ./ "?${I}glob${NI}?" "view/change dir, list dirs."]
lappend cmdinfo [list ../ "?${I}path${NI}" "go up one dir, then to path if given"]
lappend cmdinfo [list .// "?${I}glob${NI}?" "view/change dir, list dirs and files"]
lappend cmdinfo [list ./new "${I}subdir${NI}..." "make new dir or dirs and show status"]
lappend cmdinfo [list fcat "${I}file ?file?...${NI}" "cat file(s)"]
set t [textblock::class::table new -minwidth 80 -show_seps 0]
foreach row $cmdinfo {
@ -7859,7 +8070,7 @@ namespace eval punk {
lappend cmdinfo [list i "${I}cmd${NI} ?${I}subcommand${NI}...?" "Show usage for a command or ensemble subcommand"]
lappend cmdinfo [list s "${I}cmd${NI} ?${I}subcommand${NI}...?" "Show synopsis for a command or ensemble subcommand"]
lappend cmdinfo [list eg "${I}cmd${NI} ?${I}subcommand${NI}...?" "Show example from manpage"]
lappend cmdinfo [list corp "${I}proc${NI}" "View proc body and arguments"]
lappend cmdinfo [list corp "${I}proc${NI}" "View proc body and arguments with basic highlighting"]
set t [textblock::class::table new -minwidth 80 -show_seps 0]
foreach row $cmdinfo {
$t add_row $row

2
src/modules/punk/ansi-999999.0a1.0.tm

@ -1148,7 +1148,7 @@ tcl::namespace::eval punk::ansi {
"The grepstr command can find strings in ANSI text even if there are interspersed
ANSI colour codes etc. Even if a word has different coloured/styled letters, the
regex can match the plaintext. (Search is performed on ansistripped text, and then
the matched sections are highlighted and overlayed on the original styled/colourd
the matched sections are highlighted and overlayed on the original styled/coloured
input.
If the input string has ANSI movement codes - the resultant text may not be directly

35
src/modules/punk/args-999999.0a1.0.tm

@ -6148,7 +6148,7 @@ tcl::namespace::eval punk::args {
### set e [lindex $clauseval $clausecolumn]
### set e_check [lindex $clauseval_check $clausecolumn]
### #//review - we may need '?' char on windows
### if {!([tcl::string::length $e_check]>0 && ![regexp {[\"*<>\;]} $e_check])} {
### if {!([tcl::string::length $e_check]>0 && ![regexp {[\"*<>]} $e_check])} {
### #what about special file names e.g on windows NUL ?
### set msg "$argclass $argname for %caller% requires type '$type'. Received: '$e' which doesn't look like it could be a file or directory"
### return -options [list -code error -errorcode [list PUNKARGS VALIDATION [list typemismatch $type] -badarg $argname -argspecs $argspecs]] $msg
@ -6671,9 +6671,12 @@ tcl::namespace::eval punk::args {
directory -
existingfile -
existingdirectory {
#//review - we may need '?' char on windows
if {!([tcl::string::length $e_check]>0 && ![regexp {[\"*<>\;]} $e_check])} {
#what about special file names e.g on windows NUL ?
#we have to support file and directory names on all platforms - and even characters illegal on a filesystem/platform may need to be passed.
#For example a file/folder may be created with an illegal name on a platform (or mounted on it) and be mapped to another string on the filesystem
#- yet it may remain accessible to commands such as file stat etc via the string with 'illegal' characters as well as its underlying stored (mapped) name.
#NUL is almost universally problematic - so we will reject.
if {[tcl::string::length $e_check]==0 || [string first \0 $e_check] >= 0} {
set msg "$argclass $argname for %caller% requires type '$type'. Received: '$e' which doesn't look like it could be a file or directory"
lset clause_results $c_idx $a_idx [list err [list typemismatch $type] msg $msg]
continue
@ -6693,6 +6696,30 @@ tcl::namespace::eval punk::args {
}
lset clause_results $c_idx $a_idx 1
}
existingportablefile -
existingportabledirectory -
portablefile -
portabledirectory {
if {[tcl::string::length $e_check]==0 || [string first \0 $e_check] >= 0 || [punk::winpath::illegalname_test $e_check]} {
set msg "$argclass $argname for %caller% requires type '$type'. Received: '$e' which doesn't look like it could be a portable file or directory (must pass punk::winpath::illegalname_test)"
lset clause_results $c_idx $a_idx [list err [list typemismatch $type] msg $msg]
continue
}
if {$type eq "existingportablefile"} {
if {![file exists $e_check]} {
set msg "$argclass $argname for %caller% requires type '$type'. Received: '$e' which is not an existing file"
lset clause_results $c_idx $a_idx [list err [list typemismatch $type] msg $msg]
continue
}
} elseif {$type eq "existingportabledirectory"} {
if {![file isdirectory $e_check]} {
set msg "$argclass $argname for %caller% requires type '$type'. Received: '$e' which is not an existing directory"
lset clause_results $c_idx $a_idx [list err [list typemismatch $type] msg $msg]
continue
}
}
lset clause_results $c_idx $a_idx 1
}
char {
#review - char vs unicode codepoint vs grapheme?
if {[tcl::string::length $e_check] != 1} {

19
src/modules/punk/args/moduledoc/tclcore-999999.0a1.0.tm

@ -5425,6 +5425,23 @@ tcl::namespace::eval punk::args::moduledoc::tclcore {
glob */
}]}
except that the first case doesn't return the trailing “/” and is more platform independent."
#todo - review eopts handling.
#The actual glob function will treat the first non-dashed argument as the first pattern, whereas
#punk::args will treat the first invalid option or non-dashed as the end of options and the start of patterns.
#This means that if you have a pattern that starts with a dash, you must use -- to mark the end of options.
#We need a way to force punk::args to have that more restrictive behaviour in this case so that the help is aligned.
#ie tcl glob requires -- whenever values start with a dash, but punk::args only requires it only when the first of multiple values
#start with a dash or when a single has a prefix-match for a flag.
#Arguably punk::args should be more restrictive and require -- whenever any value starts with a dash.
#e.g punk::args will accept "glob -nonflag" but will warn of "glob -nonflag -nonflag" as an invalid option.
#This helps provide warnings for mistyped flags, but is potentially confusing.
#In any case, the help for glob should be consistent with the actual behaviour of glob, so we need to make sure that punk::args
#is configured to require -- whenever any value starts with a dash.
#This requires either a change to the default behaviour of punk::args when {-- -type none} is used,
#or a new option to control this behaviour.
#A new option may be preferable to avoid changing the default behaviour of punk::args in other cases where it may be desirable
#to allow values starting with dashes without requiring --.
#(e.g when a command only accepts a single value and that value may start with a dash)
-- -type none -help\
"Marks the end of switches. The argument following this one will be treated as a pattern even if it
starts with a -."
@ -6941,7 +6958,7 @@ tcl::namespace::eval punk::args::moduledoc::tclcore {
@opts -type string -parsekey "" -group "NESTED LIST OPTIONS" -grouphelp\
"These options are used to search lists of lists. They may be used with any other options."
-stride -type integer -default 1 -typesynopsis strideLength -help\
-stride -type integer -default 1 -range {1 ""} -typesynopsis strideLength -help\
"If this option is specified, the list is treated as consisting of groups of ${$I}strideLength${$NI} elements and the
groups are searched by either their first element or, if the ${$B}-index${$N} option is used, by the element within
each group given by the first index passed to -index (which is then ignored by -index). The resulting

786
src/modules/punk/auto_exec-999999.0a1.0.tm

@ -0,0 +1,786 @@
# -*- tcl -*-
# Maintenance Instruction: leave the 999999.xxx.x as is and use punkshell 'dev make' or bin/punkmake to update from <pkg>-buildversion.txt
# module template: shellspy/src/decktemplates/vendor/punk/modules/template_module-0.0.4.tm
#
# Please consider using a BSD or MIT style license for greatest compatibility with the Tcl ecosystem.
# Code using preferred Tcl licenses can be eligible for inclusion in Tcllib, Tklib and the punk package repository.
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
# (C) 2026
#
# @@ Meta Begin
# Application punk::auto_exec 999999.0a1.0
# Meta platform tcl
# Meta license <unspecified>
# @@ Meta End
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
## Requirements
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
package require Tcl 8.6-
#----------------------
# registry notes
#Computer\HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\ApplicationAssociationToasts
# - associates applications/file types with extensions and protocols.
#.cp items have a shell\cplopen\command subkey instead of shell\open\command
#- but the command string has the same format and placeholders as the shell\open\command entries for other file types,
#so we can handle them in the same way as other file types when we extract the associated command string.
#----------------------
tcl::namespace::eval punk::auto_exec {
namespace eval argdoc {
variable PUNKARGS
set I "\x1b\[3m" ;# [a+ italic]
set NI "\x1b\[23m" ;# [a+ noitalic]
set B "\x1b\[1m" ;# [a+ bold]
set N "\x1b\[22m" ;# [a+ normal]
set T "\x1b\[1\;4m" ;# [a+ bold underline]
set NT "\x1b\[22\;24m\x1b\[4:0m" ;# [a+ normal nounderline]
}
variable PUNKARGS
lappend PUNKARGS [list {
@id -id "::punk::auto_exec::shell_open_command"
@cmd -name "punk::auto_exec::assoc" -help\
{Returns the raw 'open' command string associated with the file type for the given file extension,
by looking up the user-specific association in the registry and falling back to the system
association if no user-specific association is found.
The resulting command string can contain placeholders like "%1" for the file name.
e.g .py -> "c:\Program Files\Python\python.exe" "%1"
e.g .rb -> "C:\tools\ruby31\bin\ruby.exe" "%1" %*
e.g .vbs -> "%SystemRoot%\System32\WScript.exe" "%1" %*
Note that the resulting string has unescaped backslashes within double quotes, so it is
not suitable for direct execution by Tcl without further processing to handle the backslashes
and placeholders.
see 'shell_command_as_tcl_list' for processing the command string into a Tcl list of command and
arguments with placeholders substituted.
}
@opts
@values -min 0 -max 1
ext -type string -default "" -optional true -help\
"File extension to look up, e.g .txt or .py"
}]
proc shell_open_command {ext} {
#look up assoc and ftype to find associated file type and application.
#Note that the windows 'ftype' and 'assoc' commands only examine the system's data - not the user-specific data which may override the system data.
#to get the user-specific associations we need to read the registry keys.
#extensions in the registry seem to be stored lower case wnd with a leading dot.
set lext [string tolower $ext]
package require registry
set user_assoc_path [join [list HKEY_CURRENT_USER Software Microsoft Windows CurrentVersion Explorer FileExts $lext UserChoice] "\\"]
#The UserChoice subkey under this key is where the user-specific association is stored when the user has made a choice. It contains a Progid value which points to the associated file type.
#It can return something like "Python.File" or "Applications\python.exe" or even tcl_auto_file (for .tcl files associated with tclsh by a tcl installer)
set openverb "open"
#The UserChoice key may not exist if the user hasn't made an explicit choice, in which case we fall back to the system association which is stored under HKEY_CLASSES_ROOT\$ext (which also contains user-specific overrides but is more cumbersome to query for them)
if {![catch {registry get $user_assoc_path Progid} user_choice]} {
if {$user_choice ne ""} {
#examples such as Applications\python.exe and tcl_auto_file are found under HKEY_CURRENT_USER\Software\Classes
#they then have a sub-path shell\open\command which contains the command to open files of that type, which is what we need to extract the associated application.
#it is faster to query for the key directly within a catch than to retrieve keys as lists and iterate through them to find the one we want.
#special case .cpl cplfile
if {$user_choice eq "cplfile"} {
set openverb "cplopen"
}
set verbinfo [ftype $user_choice]
if {[dict exists $verbinfo $openverb]} {
set ftypeinfo [dict get $verbinfo $openverb]
return [dict set ftypeinfo filetype $user_choice]
} else {
return [dict create type "notfound" value "" scope user filetype $user_choice]
}
} else {
#review - is it possible for Progid to be empty string? If so we should probably fall back to system association instead of returning no association.
#alternatively it could mean the user has explicitly chosen to have no association for this file type - in which case returning an empty association may be the correct behaviour.
return [dict create type "empty" value "" scope user filetype ""]
}
} else {
#fall back to system association and ftype
if {![catch {registry get [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes $lext] "\\"] ""} ftype]} {
#ftype is the file type associated with the extension, e.g "Python.File"
#we then need to look up the command associated with this file type under the same path but with the file type instead of the extension and with the additional sub-path shell\open\command
#special case .cpl cplfile
if {$ftype eq "cplfile"} {
set openverb "cplopen"
}
set verbinfo [ftype $ftype]
if {[dict exists $verbinfo $openverb]} {
set ftypeinfo [dict get $verbinfo $openverb]
return [dict set ftypeinfo filetype $ftype]
} else {
return [dict create type "notfound" value "" scope system filetype $ftype]
}
} else {
return [dict create type "notfound" value "" scope "" filetype ""]
}
}
#shouldn't get here - there is a return in each branch above.
return "no-result"
}
# %1 - standard placeholder for the first file parameter.
# %L (or %l) - Long File Name form of the path.
# %* - replaces with all parameters passed to the command. (ie not including the command itself)
# %W (or %w) - working directory.
# other placeholders that we won't handle for now:
# %I (or %i) - handle to an Item ID List (IDList)
# e.g for filetype ArchiveFolder (associated with .7z, .gz, .CPIO etc) we have "%SystemRoot%\Explorer.exe" /idlist,%I,%L
# %D (or %d) - Desktop absolute parsing name of the first parameter
# (for items that don't have file system paths) will be the same as %1 for file system items.
#todo - we need to substitute other placeholders such as %SystemRoot% etc. (case insensitively)
# - but only if the type of the key is expand_sz.
#These are environment variables that can be used in the command string.
#when type is sz - only the single letter placeholders are substituted, and environment variables are not substituted.
#when type is expand_sz - the single letter placeholders are substituted, and environment variables are also substituted
#(case insensitively) - but only if they are in the form %VAR%
#A matching environment variable will take precedence over a single letter placeholder if there is a conflict,
#e.g if the string is %1% and there is an environment variable named "1" then it will be substituted instead of the %1 placeholder.
#a non-existant environment variable will not be substituted and it's wrapping % characters will not be included in the output.
#similarly a single letter placeholder that is not recognised, such as %x, will result in only the x being included in the output without the leading % character.
#todo - tests for shell_command_as_tcl_list with various combinations of placeholders, quoted and unquoted,
#and with both reg_sz and reg_expand_sz types, and with various edge cases such as missing arguments, extra arguments,
#empty arguments, unrecognized placeholders, invalid env var references etc.
lappend PUNKARGS [list {
@id -id "::punk::auto_exec::shell_command_as_tcl_list"
@cmd -name "punk::auto_exec::shell_command_as_tcl_list" -help\
{}
@opts
-type -type string -default sz -choices {sz expand_sz} -help\
"The type of the registry value containing the command string, which determines how
environment variables are substituted. sz means environment variables will not
be substituted, and expand_sz means environment variables will be substituted
if they are in the form %VAR% (case insensitively) and will take precedence over
single letter placeholders if there is a conflict.
In either case, the single letter placeholders will be substituted as follows:
%1 - standard placeholder for the first file parameter.
%L (or %l) - Long File Name form of the path.
%* - replaced with all subsequent parameters passed to the command.
(but not including the script name itself)
%W (or %w) - working directory."
-workingdir -type string -default "" -help\
"The working directory to substitute for the %W (or %w) placeholder."
@values -min 1 -max -1
commandspec -type string -help\
"The command string to process, which can contain placeholders like %1 for the file name,
and a list of arguments to substitute for the placeholders. The command string is typically
obtained from the registry for a file type association, and the arguments are typically the
file name and other parameters to substitute into the command string."
arg -type any -multiple 1 -optional 1 -help\
{One or more arguments to substitute for the placeholders in the command string.
The first argument (often a script or document path) will be substituted for %1,
the second argument will be substituted for %2, and so on. If the command string
contains a %* placeholder, then all of the arguments will be substituted for that
placeholder starting from %2.
If there are more placeholders than arguments, then the extra placeholders will be
substituted with empty string.
If missing arguments are specified in the commandspec as quoted strings, eg "%3" then
corresponding empty strings as separate arguments will be included in the output.}
}]
proc shell_command_as_tcl_list {args} {
set argd [punk::args::parse $args withid ::punk::auto_exec::shell_command_as_tcl_list]
lassign [dict values $argd] _leaders opts values received
set type [dict get $opts -type]
set workingdir [dict get $opts -workingdir]
set commandspec [dict get $values commandspec]
if {[dict exists $received arg]} {
set arglist [dict get $values arg]
} else {
set arglist [list]
}
set result [list]
set quoted [list 0] ;#track whether the current chunk is quoted or not. Only the last item is relevant.
set chars [split [string trim $commandspec] ""]
set in_quote 0
set current_chunk {}
set new_chunk 1
set got_placeholder 0
for {set i 0} {$i < [llength $chars]} {incr i} {
set char [lindex $chars $i]
if {$in_quote} {
if {$char eq "\""} {
if {$got_placeholder} {
#wasn't a valid placeholder - % not emitted.
set got_placeholder 0
}
#The windows implementation doesn't seem to close off a chunk just because it encounters a closing quote,
#so we don't do that either.
#The closing quote just affects whether the next space will terminate the chunk or not.
set in_quote 0
} else {
if {$char eq "%"} {
#we do not handle the trailing % of an env var such as %VAR% here
#- as we scan for that in the default case of the switch below.
if {$got_placeholder} {
#this is a % escaped by doubling up ie a literal % in the output
append current_chunk "%"
set got_placeholder 0
} else {
set got_placeholder 1
}
} elseif {$got_placeholder} {
if {$type eq "expand_sz"} {
#we scan for environment variables in the form %VAR% (case insensitively) and substitute them if found, taking precedence over single letter placeholders if there is a conflict.
#a non-existant environment variable will not be substituted and it's wrapping % characters will not be included in the output.
set env_var_name $char
for {set j [expr {$i+1}]} {$j < [llength $chars]} {incr j} {
set next_char [lindex $chars $j]
if {$next_char in {" " \t}} {
#end of env var name - we don't expect to see spaces within environment variable names,
#treat space as terminator indicating failure to match.
break
} elseif {$next_char eq "\""} {
#end of env var name - we don't expect to see quotes within environment variable names,
#treat quote as terminator indicating failure to match.
break
} elseif {$next_char eq "%"} {
#end of *possible* env var name
break
} else {
append env_var_name $next_char
}
}
if {$next_char eq "%"} {
#we found a closing % character, so we have a possible env var name between the two % characters.
#we will substitute the env var value if it exists,
# or the value of any single letter placeholder if there is a match,
# or the original string between the two % characters if there is no match for either an env var or a single letter placeholder.
if {[info exists ::env($env_var_name)]} {
append current_chunk $::env($env_var_name)
set got_placeholder 0
set i $j ;#advance past the env var name and closing % character for next iteration of main loop
continue
}
} else {
#we didn't find a closing % character, so this isn't a valid env var reference.
set env_var_name ""
#fall through to single % placeholder handling below,
#which will treat the first character after the % as a single letter placeholder and include the rest of the string as literal characters in the output.
}
}
switch -- $char {
1 - L - l {
append current_chunk [lindex $arglist 0]
}
2 - 3 - 4 - 5 - 6 - 7 - 8 - 9 {
append current_chunk [lindex $arglist $char-1]
}
D - d {
#review
#we don't have a way to represent an Item ID List (IDList) in our API, so we'll just substitute the same value as %1 for now,
#which is what windows seems to do for file system items that have file paths.
append current_chunk [lindex $arglist 0]
}
I - i {
#we don't have a way to represent an Item ID List (IDList) in our API, so we'll just substitute something for now.
#format seems to be 9 and 5 digits :nnnnnnnnn:nnnnn
#REVIEW! unknown consequences!
append current_chunk ":000000000:00000"
}
"*" {
append current_chunk [lrange $arglist 1 end]
}
W - w {
append current_chunk $workingdir
}
default {
if {$type eq "expand_sz"} {
#if expand_sz we would have already scanned for environment variables in the form %VAR% and substituted them if found,
#taking precedence over single letter placeholders if there is a conflict.
append current_chunk "$char" [string range $env_var_name 1 end] ;#if env_var_name is empty string then this will just append the single char after the % as a literal,
#which is the correct behaviour for unrecognized placeholders such as %x and for invalid env var references such as %NONEXISTANT%
} else {
append current_chunk $char
}
}
}
set got_placeholder 0
} else {
append current_chunk $char
}
}
} else {
#NOT in quoted string
if {$char eq "\""} {
if {$got_placeholder} {
append current_chunk "%" ;#wasn't a valid placeholder char, so we need to add the % that we stripped off back into the output
set got_placeholder 0
}
set in_quote 1
set new_chunk 0
lset quoted end 1 ;#we'll mark as quoted if any quote found in it - review
} elseif {$char in [list " " \t ]} {
#we don't expect to see tabs or other whitespace characters as separators in the command string, but we'll treat tab the same as spaces just in case.
if {$got_placeholder} {
#wasn't a valid placeholder char. The % is stripped and not included in the output.
set got_placeholder 0
}
#space terminates an unquoted chunk, so we add it to the result list and start a new chunk.
#we also need to ensure that consecutive spaces are treated as a single separator and don't result in empty items in the output list,
if {!$new_chunk} {
if {$current_chunk ne "" || $current_chunk eq "" && [lindex $quoted end]} {
#we add the current chunk to the result list if it's not empty, or if it is empty but is quoted (because in that case we want to preserve it as an empty argument).
lappend result $current_chunk
lappend quoted 0
set current_chunk {}
}
set new_chunk 1
}
for {set j [expr {$i+1}]} {$j < [llength $chars]} {incr j} {
if {[lindex $chars $j] in {" " \t}} {
incr i
} else {
break
}
}
} else {
if {$got_placeholder} {
if {$type eq "expand_sz"} {
#we scan for environment variables in the form %VAR% (case insensitively) and substitute them if found, taking precedence over single letter placeholders if there is a conflict.
#a non-existant environment variable will not be substituted and it's wrapping % characters will not be included in the output.
set env_var_name $char
for {set j [expr {$i+1}]} {$j < [llength $chars]} {incr j} {
set next_char [lindex $chars $j]
if {$next_char in {" " \t}} {
#end of env var name - we don't expect to see spaces within environment variable names,
#treat space as terminator indicating failure to match.
break
} elseif {$next_char eq "\""} {
#end of env var name - we don't expect to see quotes within environment variable names,
#treat quote as terminator indicating failure to match.
break
} elseif {$next_char eq "%"} {
#end of *possible* env var name
break
} else {
append env_var_name $next_char
}
}
if {$next_char eq "%"} {
#we found a closing % character, so we have a possible env var name between the two % characters.
#we will substitute the env var value if it exists,
# or the value of any single letter placeholder if there is a match,
# or the original string between the two % characters if there is no match for either an env var or a single letter placeholder.
if {[info exists ::env($env_var_name)]} {
append current_chunk $::env($env_var_name)
set got_placeholder 0
set i $j ;#advance past the env var name and closing % character for next iteration of main loop
continue
}
} else {
#we didn't find a closing % character, so this isn't a valid env var reference.
set env_var_name ""
#fall through to single % placeholder handling below,
#which will treat the first character after the % as a single letter placeholder and include the rest of the string as literal characters in the output.
}
}
switch -- $char {
"%" {
append current_chunk "%"
set got_placeholder 1
continue
}
1 -
L - l {
#append current_chunk [lindex $arglist 0]
set append_value [string trim [lindex $arglist 0]]
foreach ch [split $append_value ""] {
if {$ch eq " "} {
#we need to split on spaces within arguments as well, because unquoted %1 can expand to multiple arguments if the first argument contains spaces, and we need to preserve the correct splitting of arguments in that case.
#e.g if %1 is substituted with "C:\My Documents\file.txt" then we need to split that into three items in the output list: "C:\My", "Documents\file.txt"
if {$current_chunk ne ""} {
lappend result $current_chunk
lappend quoted 0
set current_chunk {}
}
set new_chunk 1
} else {
append current_chunk $ch
set new_chunk 0
}
}
}
2 - 3 - 4 - 5 - 6 - 7 - 8 - 9 {
append current_chunk [lindex $arglist $char-1]
}
D - d {
#review
#we don't have a way to represent an Item ID List (IDList) in our API, so we'll just substitute the same value as %1 for now,
#which is what windows seems to do for file system items that have file paths.
append current_chunk [lindex $arglist 0]
}
I - i {
#we don't have a way to represent an Item ID List (IDList) in our API, so we'll just substitute something for now.
#format seems to be 9 and 5 digits :nnnnnnnnn:nnnnn
#REVIEW! unknown consequences!
append current_chunk ":000000000:00000"
}
"*" {
#the window implementation of unquoted %* always seems to emit the previous chunk before the %* placeholder as a separate item
#in the command line, even if there is no space between them, so we will do the same.
#Note that a following string (quoted or not) immediately after the %* will be appended to the last item in the output list rather than being a separate item,
#which is also consistent with the microsoft implementation.
if {!$new_chunk} {
if {$current_chunk ne "" || $current_chunk eq "" && [lindex $quoted end]} {
lappend result $current_chunk
lappend quoted 0
set current_chunk {}
}
}
lappend result {*}[lrange $arglist 1 end]
lappend quoted {*}[lrepeat [llength $arglist] 0]
}
W - w {
append current_chunk $workingdir
}
default {
if {$type eq "expand_sz"} {
#if expand_sz we would have already scanned for environment variables in the form %VAR% and substituted them if found,
#taking precedence over single letter placeholders if there is a conflict.
append current_chunk "$char" [string range $env_var_name 1 end] ;#if env_var_name is empty string then this will just append the single char after the % as a literal,
#which is the correct behaviour for unrecognized placeholders such as %x and for invalid env var references such as %NONEXISTANT%
} else {
append current_chunk $char
}
}
}
set got_placeholder 0
set new_chunk 0
} else {
if {$char eq "%"} {
set got_placeholder 1
} else {
append current_chunk $char
set new_chunk 0
}
}
}
}
}
if {$current_chunk ne "" || $current_chunk eq "" && [lindex $quoted end]} {
#review - edge case - if the command string ends with a space then we would have already added the last chunk to the result list and started a new chunk, so we shouldn't add an empty chunk to the result list in that case.
#however if the last chunk was quoted and is empty because the command string ends with a placeholder that is substituted with an empty string, then we should add the empty chunk to the result list.
lappend result $current_chunk
}
return $result
}
namespace eval punk::auto_exec::system {
proc assoc_get_info {ext} {
set lext [string tolower $ext]
set result [dict create system "" user ""]
set user_assoc_path [join [list HKEY_CURRENT_USER Software Microsoft Windows CurrentVersion Explorer FileExts $lext UserChoice] "\\"]
if {![catch {registry get $user_assoc_path Progid} user_choice]} {
dict set result user $user_choice
}
if {![catch {registry get [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes $lext] "\\"] ""} ftype]} {
dict set result system $ftype
}
return $result
}
}
lappend PUNKARGS [list {
@id -id "::punk::auto_exec::assoc"
@cmd -name "punk::auto_exec::assoc"\
-summary\
"Look up the associated file type (system and user) for a file extension"\
-help\
"Get the associated file type for a file extension by looking up the user-specific
file type in the registry and falling back to the system file type if no
user-specific association is found.
Returns a dict with keys ${$I}system${$NI} and ${$I}user${$NI}.
One or more of the key values may be empty string if there is no defined
file type for the extension.
This is somewhat like the windows 'assoc' command except that the windows command
only looks up the system association and does not take into account any user-specific
overrides.
This function returns both values in the result dictionary if they are available."
@opts
@values -min 0 -max 1
ext -type string -default "" -optional true -help\
"File extension to look up, e.g .txt or .py"
}]
proc assoc {args} {
package require registry
set argd [punk::args::parse $args withid ::punk::auto_exec::assoc]
set ext [dict get $argd values ext]
if {$ext ne ""} {
return [punk::auto_exec::system::assoc_get_info $ext]
} else {
#look up all associated ftypes
set user_ftypes [registry keys {HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts} .*]
set system_ftypes [registry keys {HKEY_LOCAL_MACHINE\SOFTWARE\Classes} .*]
set all_ftypes [lsort -unique [concat $user_ftypes $system_ftypes]]
set results [list]
foreach ftype $all_ftypes {
dict set results $ftype [punk::auto_exec::system::assoc_get_info $ftype]
}
return $results
}
}
namespace eval argdoc {
lappend PUNKARGS [list {
@id -id "::punk::auto_exec::ftype"
@cmd -name "punk::auto_exec::ftype"\
-summary\
"Look up shell verb command values from windows file type."\
-help\
"Get the associated shell verb information (such as open) for a file type by looking up the user-specific
association in the registry and falling back to the system association if no
user-specific association is found.
Returns a dict of dicts with toplevel keys for each shell verb (e.g open, runas) and values that are dicts with keys
${$I}type${$NI} and ${$I}value${$NI} and ${$I}scope${$NI}, where
type is determined from the registry value type (e.g sz or expand_sz)
string is the raw command string from the registry
scope is either "user" or "system" depending on whether the value was found in the user-specific registry keys or
the system registry keys.
This is somewhat like the windows 'ftype' command except that the windows command only looks for the 'open' verb and
only looks up the system association and does not take into account any user-specific
overrides.
The file type can be looked up using the ${$B}assoc${$N} function in this package.
The command string can contain placeholders like \"%1\" for the file name, and environment variables
in the form %VAR% (case insensitive) if the registry value type is reg_expand_sz (expand_sz),
which will be substituted by the ${$B}shell_command_as_tcl_list${$N} function when processing the command
string into a Tcl list of command and arguments with placeholders substituted."
@opts
@values -min 0 -max 1
filetype -type string -default "" -optional true -help\
"File type associated with a file extension, e.g Python.File.
This can be looked up using the 'assoc' function in this package."
}]
}
#proc ftype {filetype} {
# package require registry
# if {$filetype eq "cplfile"} {
# #special case for cplfile (associated with .cpl files) which doesn't follow the usual pattern of having the command string under shell\open\command,
# #but instead has it under HKEY_LOCAL_MACHINE\SOFTWARE\Classes\cplfile\shell\cplopen\command.
# #There doesn't seem to be any user-specific override for this file type
# #- but we will check for one under HKEY_CURRENT_USER\Software\Classes\cplfile\shell\cplopen\command anyway for consistency with the way we check
# #for user-specific overrides for other file types.
# set key [join [list HKEY_CURRENT_USER Software Classes cplfile shell cplopen command] "\\"]
# } else {
# set key [join [list HKEY_CURRENT_USER Software Classes $filetype shell open command] "\\"]
# }
# if {![catch {registry get $key ""} raw_assoc]} {
# set tp [registry type $key ""]
# return [dict create open [dict create type $tp string $raw_assoc]]
# } else {
# #e.g Python.File
# if {$filetype eq "cplfile"} {
# set key [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes cplfile shell cplopen command] "\\"]
# } else {
# set key [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes $filetype shell open command] "\\"]
# }
# if {![catch {registry get $key ""} raw_assoc]} {
# set tp [registry type $key ""]
# return [dict create type $tp string $raw_assoc]
# } else {
# return [dict create type "" string ""] ;#no association found
# }
# }
#}
proc ftype {filetype} {
package require registry
set resultdict [dict create]
#e.g Python.File
set shellpath [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes $filetype shell] "\\"]
if {![catch {registry keys $shellpath *} shellverbs]} {
foreach verb $shellverbs {
set commandkey [join [list $shellpath $verb command] "\\"]
if {![catch {registry get $commandkey ""} cmdstring]} {
#registry queryies are case insensitive but some are cased differently e.g Open vs open.
#when using the verb as a key in the output dict, we need to normalize so that it is useful for lookups. We'll use lowercase for that.
set verb [string tolower $verb]
set tp [registry type $commandkey ""]
dict set resultdict $verb [dict create type $tp value $cmdstring scope system]
}
}
}
#allow user-specific verbs to be overridden.
set shellpath [join [list HKEY_CURRENT_USER Software Classes $filetype shell] "\\"]
if {![catch {registry keys $shellpath *} shellverbs]} {
foreach verb $shellverbs {
set commandkey [join [list $shellpath $verb command] "\\"]
if {![catch {registry get $commandkey ""} cmdstring]} {
set verb [string tolower $verb]
set tp [registry type $commandkey ""]
dict set resultdict $verb [dict create type $tp value $cmdstring scope user]
}
}
}
return $resultdict
}
}
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
# Secondary API namespace
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
tcl::namespace::eval punk::auto_exec::lib {
tcl::namespace::export {[a-z]*} ;# Convention: export all lowercase
tcl::namespace::path [tcl::namespace::parent]
}
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
#tcl::namespace::eval punk::auto_exec::system {
#}
# == === === === === === === === === === === === === === ===
# Sample 'about' function with punk::args documentation
# == === === === === === === === === === === === === === ===
tcl::namespace::eval punk::auto_exec {
tcl::namespace::export {[a-z]*} ;# Convention: export all lowercase
variable PUNKARGS
variable PUNKARGS_aliases
lappend PUNKARGS [list {
@id -id "(package)punk::auto_exec"
@package -name "punk::auto_exec" -help\
"Package
Description"
}]
namespace eval argdoc {
#namespace for custom argument documentation
proc package_name {} {
return punk::auto_exec
}
proc about_topics {} {
#info commands results are returned in an arbitrary order (like array keys)
set topic_funs [info commands [namespace current]::get_topic_*]
set about_topics [list]
foreach f $topic_funs {
set tail [namespace tail $f]
lappend about_topics [string range $tail [string length get_topic_] end]
}
#Adjust this function or 'default_topics' if a different order is required
return [lsort $about_topics]
}
proc default_topics {} {return [list Description *]}
# -------------------------------------------------------------
# get_topic_ functions add more to auto-include in about topics
# -------------------------------------------------------------
proc get_topic_Description {} {
punk::args::lib::tstr [string trim {
package punk::auto_exec
description to come..
} \n]
}
proc get_topic_License {} {
return "<unspecified>"
}
proc get_topic_Version {} {
return "$::punk::auto_exec::version"
}
proc get_topic_Contributors {} {
set authors {{<julian@precisium.com.au> {Julian Noble}}}
set contributors ""
foreach a $authors {
append contributors $a \n
}
if {[string index $contributors end] eq "\n"} {
set contributors [string range $contributors 0 end-1]
}
return $contributors
}
proc get_topic_custom-topic {} {
punk::args::lib::tstr -return string {
A custom
topic
etc
}
}
# -------------------------------------------------------------
}
# we re-use the argument definition from punk::args::standard_about and override some items
set overrides [dict create]
dict set overrides @id -id "::punk::auto_exec::about"
dict set overrides @cmd -name "punk::auto_exec::about"
dict set overrides @cmd -help [string trim [punk::args::lib::tstr {
About punk::auto_exec
}] \n]
dict set overrides topic -choices [list {*}[punk::auto_exec::argdoc::about_topics] *]
dict set overrides topic -choicerestricted 1
dict set overrides topic -default [punk::auto_exec::argdoc::default_topics] ;#if -default is present 'topic' will always appear in parsed 'values' dict
set newdef [punk::args::resolved_def -antiglobs -package_about_namespace -override $overrides ::punk::args::package::standard_about *]
lappend PUNKARGS [list $newdef]
proc about {args} {
package require punk::args
#standard_about accepts additional choices for topic - but we need to normalize any abbreviations to full topic name before passing on
set argd [punk::args::parse $args withid ::punk::auto_exec::about]
lassign [dict values $argd] _leaders opts values _received
punk::args::package::standard_about -package_about_namespace ::punk::auto_exec::argdoc {*}$opts {*}[dict get $values topic]
}
}
# end of sample 'about' function
# == === === === === === === === === === === === === === ===
# -----------------------------------------------------------------------------
# register namespace(s) to have PUNKARGS,PUNKARGS_aliases variables checked
# -----------------------------------------------------------------------------
# variable PUNKARGS
# variable PUNKARGS_aliases
namespace eval ::punk::args::register {
#use fully qualified so 8.6 doesn't find existing var in global namespace
lappend ::punk::args::register::NAMESPACES ::punk::auto_exec
}
# -----------------------------------------------------------------------------
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
## Ready
package provide punk::auto_exec [tcl::namespace::eval punk::auto_exec {
variable pkg punk::auto_exec
variable version
set version 999999.0a1.0
}]
return

3
src/modules/punk/auto_exec-buildversion.txt

@ -0,0 +1,3 @@
0.1.0
#First line must be a semantic version number
#all other lines are ignored.

353
src/modules/punk/char-999999.0a1.0.tm

@ -237,6 +237,88 @@ tcl::namespace::eval punk::char {
return $out
}
namespace eval argdoc {
variable PUNKARGS
lappend PUNKARGS [list {
@id -id ::punk::char::ascii2doublewide
@cmd -name punk::char::ascii2doublewide\
-summary\
"string to double-wide unicode char"\
-help\
"Convert parts of string in the ascii range 21 to 7E to corresponding chars from the unicode
'Halfwidth and Fullwidth Forms' block.
The space character (0x20) is converted to the 'IDEOGRAPHIC SPACE' character (0x3000)
Control chars and chars outside the ascii range are not converted and passed through as-is."
@values -min 1 -max 1
str -type string
}]
}
proc ascii2doublewide {str} {
#set base to \UFEE0 and add ascii value of char to get the double-wide character in the 'Halfwidth and Fullwidth Forms' block of unicode
set out ""
foreach ch [split $str ""] {
set decval [scan $ch %c]
if {$decval == 0x20} {
#space char maps to ideographic space rather than the fullwidth space char - as the fullwidth space char is often rendered as a narrow space rather than a wide one - and the ideographic space is more likely to be rendered as a wide space.
set decval 0x3000
} elseif {$decval < 0x21 || $decval > 0x7E} {
#chars outside the ascii range are not converted - pass through as-is
set decval [scan $ch %c]
} else {
set decval [expr {0xFEE0 + $decval}]
}
append out [format %c $decval]
}
return $out
}
proc doublewide2ascii {str} {
set out ""
foreach ch [split $str ""] {
set decval [scan $ch %c]
if {$decval == 0x3000} {
#ideographic space maps to ascii space char
set decval 0x20
append out \U3000
} elseif {$decval < 0xFF01 || $decval > 0xFF5E} {
#chars outside the part of the 'Halfwidth and Fullwidth Forms' block that corresponds to ascii are not converted - pass through as-is
append out $ch
} else {
set decval [expr {$decval - 0xFEE0}]
append out [format %c $decval]
}
}
return $out
}
proc ascii2NTFSPUA {str} {
#set base to \uF000 and add ascii value of char to get the PUA character in the 'Private Use Area' block of unicode
set out ""
foreach ch [split $str ""] {
set decval [scan $ch %c]
if {$decval <= 0x7F} {
append out [format %c [expr {0xF000 + $decval}]]
} else {
#chars outside the ascii range are not converted - pass through as-is
append out $ch
}
}
return $out
}
proc NTFSPUA2ascii {str} {
set out ""
foreach ch [split $str ""] {
set decval [scan $ch %c]
if {$decval >= 0xF000 && $decval <= 0xF0FF} {
set decval [expr {$decval - 0xF000}]
append out [format %c $decval]
} else {
#chars outside the 'Private Use Area' block that corresponds to ascii are not converted - pass through as-is
append out $ch
}
}
return $out
}
proc symbol {} {
tailcall page symbol
@ -2573,11 +2655,269 @@ tcl::namespace::eval punk::char {
return [tcl::string::map $map $str]
}
#todo - lookup from unicode tables
#ISO-3166-1 alpha-2 country codes to flag emojis
#These can change over time.
#todo - lookup from official sources - and update as needed
#
variable flags [dict create\
AC \U1F1E6\U1F1E8\
AD \U1F1E6\U1F1E9\
AE \U1F1E6\U1F1EA\
AF \U1F1E6\U1F1EB\
AG \U1F1E6\U1F1EC\
AI \U1F1E6\U1F1EE\
AL \U1F1E6\U1F1F1\
AM \U1F1E6\U1F1F2\
AO \U1F1E6\U1F1F4\
AQ \U1F1E6\U1F1F6\
AR \U1F1E6\U1F1F7\
AS \U1F1E6\U1F1F8\
AT \U1F1E6\U1F1F9\
AU \U1F1E6\U1F1FA\
AW \U1F1E6\U1F1FC\
AX \U1F1E6\U1F1FD\
AZ \U1F1E6\U1F1FF\
BA \U1F1E7\U1F1E6\
BB \U1F1E7\U1F1E7\
BD \U1F1E7\U1F1E9\
BE \U1F1E7\U1F1EA\
BF \U1F1E7\U1F1EB\
BG \U1F1E7\U1F1EC\
BH \U1F1E7\U1F1ED\
BI \U1F1E7\U1F1EE\
BJ \U1F1E7\U1F1EF\
BL \U1F1E7\U1F1F1\
BM \U1F1E7\U1F1F2\
BN \U1F1E7\U1F1F3\
BO \U1F1E7\U1F1F4\
BQ \U1F1E7\U1F1F6\
BR \U1F1E7\U1F1F7\
BS \U1F1E7\U1F1F8\
BT \U1F1E7\U1F1F9\
BV \U1F1E7\U1F1FB\
BW \U1F1E7\U1F1FC\
BY \U1F1E7\U1F1FE\
BZ \U1F1E7\U1F1FF\
CA \U1F1E8\U1F1E6\
CC \U1F1E8\U1F1E8\
CD \U1F1E8\U1F1E9\
CF \U1F1E8\U1F1Eb\
CG \U1F1E8\U1F1Ec\
CH \U1F1E8\U1F1ED\
CI \U1F1E8\U1F1EE\
CK \U1F1E8\U1F1F0\
CL \U1F1E8\U1F1F1\
CM \U1F1E8\U1F1F2\
CN \U1F1E8\U1F1F3\
CO \U1F1E8\U1F1F4\
CP \U1F1E8\U1F1F5\
CR \U1F1E8\U1F1F7\
CS \U1F1E8\U1F1F8\
CU \U1F1E8\U1F1FA\
CV \U1F1E8\U1F1FB\
CW \U1F1E8\U1F1FC\
CX \U1F1E8\U1F1FE\
CY \U1F1E8\U1F1FE\
CZ \U1F1E8\U1F1FF\
DE \U1F1E9\U1F1EA\
DG \U1F1E9\U1F1EC\
DJ \U1F1E9\U1F1EF\
DK \U1F1E9\U1F1F0\
DM \U1F1E9\U1F1F2\
DO \U1F1E9\U1F1F4\
DZ \U1F1E9\U1F1FF\
EA \U1F1EA\U1F1E6\
EC \U1F1EA\U1F1E8\
EE \U1F1EA\U1F1EA\
EG \U1F1EA\U1F1EC\
EH \U1F1EA\U1F1ED\
ER \U1F1EA\U1F1F7\
ES \U1F1EA\U1F1F8\
ET \U1F1EA\U1F1F9\
EU \U1F1EA\U1F1FA\
FI \U1F1EB\U1F1EE\
FJ \U1F1EB\U1F1EF\
FK \U1F1EB\U1F1F0\
FM \U1F1EB\U1F1F2\
FO \U1F1EB\U1F1F4\
FR \U1F1EB\U1F1F7\
GA \U1F1EC\U1F1E6\
GB \U1F1EC\U1F1E7\
GD \U1F1EC\U1F1E9\
GE \U1F1EC\U1F1EA\
GF \U1F1EC\U1F1EB\
GG \U1F1EC\U1F1EC\
GH \U1F1EC\U1F1ED\
GI \U1F1EC\U1F1EE\
GL \U1F1EC\U1F1F1\
GM \U1F1EC\U1F1F2\
GN \U1F1EC\U1F1F3\
GP \U1F1EC\U1F1F5\
GQ \U1F1EC\U1F1F6\
GR \U1F1EC\U1F1F7\
GS \U1F1EC\U1F1F8\
GT \U1F1EC\U1F1F9\
GU \U1F1EC\U1F1FA\
GW \U1F1EC\U1F1FC\
GY \U1F1EC\U1F1FE\
HK \U1F1ED\U1F1F0\
HM \U1F1ED\U1F1F2\
HN \U1F1ED\U1F1F3\
HR \U1F1ED\U1F1F7\
HT \U1F1ED\U1F1F9\
HU \U1F1ED\U1F1FA\
IC \U1F1EE\U1F1E8\
ID \U1F1EE\U1F1E9\
IE \U1F1EE\U1F1EA\
IL \U1F1EE\U1F1F1\
IM \U1F1EE\U1F1F2\
IN \U1F1EE\U1F1F3\
IO \U1F1EE\U1F1F4\
IQ \U1F1EE\U1F1F6\
IR \U1F1EE\U1F1F7\
IS \U1F1EE\U1F1F8\
IT \U1F1EE\U1F1F9\
JE \U1F1EF\U1F1EA\
JM \U1F1EF\U1F1F2\
JO \U1F1EF\U1F1F4\
JP \U1F1EF\U1F1F5\
KE \U1F1F0\U1F1EA\
KG \U1F1F0\U1F1EC\
KH \U1F1F0\U1F1ED\
KI \U1F1F0\U1F1EE\
KM \U1F1F0\U1F1F2\
KN \U1F1F0\U1F1F3\
KP \U1F1F0\U1F1F5\
KR \U1F1F0\U1F1F7\
KW \U1F1F0\U1F1FC\
KY \U1F1F0\U1F1FE\
KZ \U1F1F0\U1F1FF\
LA \U1F1F1\U1F1E6\
LB \U1F1F1\U1F1E7\
LC \U1F1F1\U1F1E8\
LI \U1F1F1\U1F1EE\
LK \U1F1F1\U1F1F0\
LR \U1F1F1\U1F1F7\
LS \U1F1F1\U1F1F8\
LT \U1F1F1\U1F1F9\
LU \U1F1F1\U1F1FA\
LV \U1F1F1\U1F1FB\
LY \U1F1F1\U1F1FE\
MA \U1F1F2\U1F1E6\
MC \U1F1F2\U1F1E8\
MD \U1F1F2\U1F1E9\
ME \U1F1F2\U1F1EA\
MF \U1F1F2\U1F1EB\
MG \U1F1F2\U1F1EC\
MH \U1F1F2\U1F1ED\
MK \U1F1F2\U1F1F0\
ML \U1F1F2\U1F1F1\
MM \U1F1F2\U1F1F2\
MN \U1F1F2\U1F1F3\
MO \U1F1F2\U1F1F4\
MP \U1F1F2\U1F1F5\
MQ \U1F1F2\U1F1F6\
MR \U1F1F2\U1F1F7\
MS \U1F1F2\U1F1F8\
MT \U1F1F2\U1F1F9\
MU \U1F1F2\U1F1FA\
MV \U1F1F2\U1F1FB\
MW \U1F1F2\U1F1FC\
MX \U1F1F2\U1F1FD\
MY \U1F1F2\U1F1FE\
MZ \U1F1F2\U1F1FF\
NA \U1F1F3\U1F1E6\
NC \U1F1F3\U1F1E8\
NE \U1F1F3\U1F1EA\
NF \U1F1F3\U1F1EB\
NG \U1F1F3\U1F1EC\
NI \U1F1F3\U1F1EE\
NL \U1F1F3\U1F1F1\
NO \U1F1F3\U1F1F4\
NP \U1F1F3\U1F1F5\
NR \U1F1F3\U1F1F7\
NU \U1F1F3\U1F1F8\
NZ \U1F1F3\U1F1ff\
OM \U1F1F4\U1F1F2\
PA \U1F1F5\U1F1E6\
PE \U1F1F5\U1F1EA\
PF \U1F1F5\U1F1EB\
PG \U1F1F5\U1F1EC\
PH \U1F1F5\U1F1ED\
PK \U1F1F5\U1F1ED\
PL \U1F1F5\U1F1F1\
PM \U1F1F5\U1F1F3\
PN \U1F1F5\U1F1F3\
PR \U1F1F5\U1F1F7\
PS \U1F1F5\U1F1F8\
PT \U1F1F5\U1F1F9\
PW \U1F1F5\U1F1FC\
PY \U1F1F5\U1F1FE\
QA \U1F1F6\U1F1E6\
RE \U1F1F7\U1F1EA\
RO \U1F1F7\U1F1F4\
RS \U1F1F7\U1F1F7\
RU \U1F1F7\U1F1FA\
RW \U1F1F7\U1F1FC\
SA \U1F1F8\U1F1E6\
SB \U1F1F8\U1F1E7\
SC \U1F1F8\U1F1E8\
SD \U1F1F8\U1F1E9\
SE \U1F1F8\U1F1EA\
SG \U1F1F8\U1F1EC\
SH \U1F1F8\U1F1ED\
SI \U1F1F8\U1F1EE\
SJ \U1F1F8\U1F1EF\
SK \U1F1F8\U1F1F0\
SL \U1F1F8\U1F1F1\
SM \U1F1F8\U1F1F2\
SN \U1F1F8\U1F1F3\
SO \U1F1F8\U1F1F4\
SR \U1F1F8\U1F1F7\
SS \U1F1F8\U1F1F8\
ST \U1F1F8\U1F1F9\
SV \U1F1F8\U1F1F7\
SX \U1F1F8\U1F1F8\
SY \U1F1F8\U1F1FE\
SZ \U1F1F8\U1F1FF\
TA \U1F1F9\U1F1E6\
TC \U1F1F9\U1F1E8\
TD \U1F1F9\U1F1E9\
TF \U1F1F9\U1F1EB\
TG \U1F1F9\U1F1EC\
TH \U1F1F9\U1F1ED\
TJ \U1F1F9\U1F1EF\
TK \U1F1F9\U1F1F0\
TL \U1F1F9\U1F1F1\
TM \U1F1F9\U1F1F2\
TN \U1F1F9\U1F1F3\
TO \U1F1F9\U1F1F4\
TR \U1F1F9\U1F1F7\
TT \U1F1F9\U1F1F9\
TV \U1F1F9\U1F1FB\
TW \U1F1F9\U1F1FC\
TZ \U1F1F9\U1F1FF\
UA \U1F1FA\U1F1E6\
UG \U1F1FA\U1F1EC\
UM \U1F1FA\U1F1F2\
US \U1F1FA\U1F1F8\
ZW \U1F1FF\U1F1FC
UY \U1F1FA\U1F1FE\
UZ \U1F1FA\U1F1FF\
VA \U1F1FB\U1F1E6\
VC \U1F1FB\U1F1E8\
VE \U1F1FB\U1F1EA\
VG \U1F1FB\U1F1EC\
VI \U1F1FB\U1F1EE\
VN \U1F1FB\U1F1F3\
VU \U1F1FB\U1F1FA\
WF \U1F1FC\U1F1EB\
WS \U1F1FC\U1F1F8\
XK \U1F1FD\U1F1F3\
YE \U1F1FE\U1F1EA\
YT \U1F1FE\U1F1F9\
ZA \U1F1FF\U1F1E6\
ZM \U1F1FF\U1F1F2\
ZW \U1F1FF\U1F1FC\
]
variable rflags
dict for {k v} $flags {
@ -2825,6 +3165,15 @@ tcl::namespace::eval punk::char::lib {
}
}
# -----------------------------------------------------------------------------
# register namespace(s) to have PUNKARGS,PUNKARGS_aliases variables checked
# -----------------------------------------------------------------------------
# variable PUNKARGS
# variable PUNKARGS_aliases
namespace eval ::punk::args::register {
#use fully qualified so 8.6 doesn't find existing var in global namespace
lappend ::punk::args::register::NAMESPACES ::punk::char
}
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++

909
src/modules/punk/du-999999.0a1.0.tm

File diff suppressed because it is too large Load Diff

7
src/modules/punk/mix/commandset/module-999999.0a1.0.tm

@ -158,8 +158,11 @@ namespace eval punk::mix::commandset::module {
If a version is specified in the module argument as well as in -version
the higher version number will be used.
"
-license -default <unspecified>
-author -default <unspecified> -multiple 1
-license -default <unspecified> -help\
"License to be mentioned in the module file. e.g BSD, MIT"
-author -default <unspecified> -multiple 1 -help\
{Author(s) of the module. Multiple authors can be specified by repeating the -author option.
e.g -author {"John Smith" "jsmith@example.com"} -author {"Jane Doe" "jane@example.org"}}
-template -default punk.module
-type -default "[lindex $moduletypes 0]" -choices {$moduletypes}
-force -default 0 -type boolean -help\

399
src/modules/punk/nav/fs-999999.0a1.0.tm

@ -233,6 +233,11 @@ tcl::namespace::eval punk::nav::fs {
set args [lrange $args 1 end]
}
if {$v eq "/"} {
#directory only listing - we can optimize this by not getting file sizes and times - as these are only used for file listings
#we can't completely skip iterating over files as we want to know if there are any files in the directory - as this affects the display of links/shortcuts that point to directories - but we can skip getting sizes and times for files.
#todo?
}
if {![llength $args]} {
#ls is too slow even over a fairly low-latency network
@ -243,12 +248,12 @@ tcl::namespace::eval punk::nav::fs {
commandstack::basecall cd $VIRTUAL_CWD
}
}
set matchinfo [dirfiles_dict -searchbase $VIRTUAL_CWD -with_times {f d l} -with_sizes {f d l}]
set matchinfo [dirfiles_dict -searchbase $VIRTUAL_CWD -with_times {f d l b c p s} -with_sizes {f d l b c p s}]
} else {
if {[pwd] ne $VIRTUAL_CWD} {
commandstack::basecall cd $VIRTUAL_CWD
}
set matchinfo [dirfiles_dict -searchbase [pwd] -with_times {f d l} -with_sizes {f d l}]
set matchinfo [dirfiles_dict -searchbase [pwd] -with_times {f d l b c p s} -with_sizes {f d l b c p s}]
}
set dircount [llength [dict get $matchinfo dirs]]
set filecount [llength [dict get $matchinfo files]]
@ -621,30 +626,41 @@ tcl::namespace::eval punk::nav::fs {
punk::args::define {
@id -id ::punk::nav::fs::d/new
-nonportable -type none -help\
"Allow creation of directories which may not be portable across platforms.
Use with caution and only when you know what you are doing.
This allows creation of directories with names that may be invalid on some
platforms, or that may have special meanings on some platforms
(e.g reserved device names on windows).
If -nonportable is not supplied, then an error will be raised if any supplied
path is non-portable as defined by punk::winpath::illegalname_test.
Regardless of whether -nonportable is supplied or not, some characters are not
suitable for windows or most other platforms and will be rejected with an error.
An example of this is the null character (\0)."
@cmd -name punk::nav::fs::d/new\
-summary\
"Create directory or directories at the specified path(s)."\
-help\
"This command creates directories at the specified path(s).
If any part of the specified path does not exist, then it will be created as well.
If a specified path already exists, then it will be left as-is and no error will be raised.
A summary line is returned for each created directory, with the full path of the created
directory and a status line indicating the number of dirs and files in the directory if
it already existed (or showing 0 for both if it was just created)."
-nonportable -type none\
-help\
"Allows creation of directories which may not be portable across platforms.
Use with caution and only when you know what you are doing.
This allows creation of directories with names that may be invalid on some
platforms, or that may have special meanings on some platforms
(e.g reserved device names on windows).
If -nonportable is not supplied, then an error will be raised if any supplied
path is non-portable as defined by punk::winpath::illegalname_test.
Regardless of whether -nonportable is supplied or not, some characters are not
suitable for windows or most other platforms and will be rejected with an error.
An example of this is the null character (\0)."
@values -min 1 -max -1 -type string
path -type string -multiple 1 -help\
"Path(s) to create. Can be absolute or relative.
If any path is rejected due to -nonportable or other invalid characters,
or because a parent directory is not writable, then no directories will be created.
If any path is rejected due to -nonportable or other invalid characters,
or because a parent directory is not writable, then no directories will be created.
If a path already exists, then it will be left as-is and no error will be raised.
If a path already exists, then it will be left as-is and no error will be raised.
If despite passing the name tests or writability tests, a directory cannot be
created for some reason (e.g other filesystem error) then an error will be raised
and processing of any remaining paths will be aborted."
If despite passing the name tests or writability tests, a directory cannot be
created for some reason (e.g other filesystem error) then an error will be raised
and processing of any remaining paths will be aborted."
}
#todo - synchronize overall behaviour of d/new with that of n/new (for namespaces)
proc d/new {args} {
@ -718,7 +734,7 @@ tcl::namespace::eval punk::nav::fs {
}
#display summaries of created directories (which may have already existed) by reusing d/ to get info on them.
set query_paths [lmap v $paths $v/*]
set query_paths [lmap v $paths {string cat $v "/*"}]
d/ / {*}$query_paths
}
@ -734,6 +750,10 @@ tcl::namespace::eval punk::nav::fs {
#run a file
#review. On unix the shebang should be able to choose the interpreter.
#on windows the ::env(PATHEXT) and file associations should be able to choose the interpreter. (see punk::auto_execok_better)
#when running using ./script_or_exe.name - the windows file association should be used.
#when running using x/ script_or_exe.name - we want to be able to run scripts even if no file association exists (or it is inappropriate for execution e.g ps1 defaults to open in editor)
proc x/ {args} {
if {![llength $args]} {
set result [d/]
@ -748,11 +768,16 @@ tcl::namespace::eval punk::nav::fs {
lua [list exe lua extensions [list ".lua"]]\
perl [list exe perl extensions [list ".pl"]]\
php [list exe php extensions [list ".php"]]\
ps1 [list exe pwsh extensions [list ".ps1"]]\
]
#todo - allow cofnig to specify arguments for the executable. eg pwsh -noprofile -executionpolicy remotesigned for running powershell scripts.
#this is a bit of a hack - fix
set tcl_extensions [list ".tcl" ".tm" ".kit" ".tk"] ;#todo - load from config
set py_extensions [list ".py"]
set lua_extensions [list ".lua"]
set perl_extensions [list ".pl"]
#set lua_extensions [list ".lua"]
#set perl_extensions [list ".pl"]
#set pwsh_extensions [list ".ps1"]
set script_extensions [list]
set extension_lookup [dict create]
@ -792,14 +817,12 @@ tcl::namespace::eval punk::nav::fs {
set ::argc [llength $newargs]
set ::argv $newargs
tailcall source $path
} elseif {$extlower in $py_extensions} {
set pycmd [auto_execok python]
tailcall {*}$pycmd {*}$args
} elseif {$extlower in $script_extensions} {
set exename [dict get $scriptconfig $scriptlang exe]
set cmd [auto_execok $exename]
tailcall {*}$cmd $args
} else {
#review
set fd [open $path r]
set chunk [read $fd 4000]; close $fd
#consider any commented line near top of file containing 'tcl' as likely to be a tcl script of some sort and attempt to source it.
@ -824,7 +847,11 @@ tcl::namespace::eval punk::nav::fs {
}
}
} else {
puts stderr "No script executable known for this"
if {$scriptfile eq ""} {
puts stderr "script not found"
} else {
puts stderr "No script executable known for files with extension [file extension $scriptfile]. Ensure file has a known extension ($script_extensions) or add a commented hint in the file such as #!/usr/bin/env tclsh"
}
}
}
@ -934,13 +961,96 @@ tcl::namespace::eval punk::nav::fs {
# -searchbase is always passed through - and is only used to construct a location path if a relative searchspec was supplied
punk::args::define {
@id -id ::punk::nav::fs::dirfiles_dict
@cmd -name punk::nav::fs::dirfiles_dict
@cmd -name punk::nav::fs::dirfiles_dict\
-summary\
""\
-help\
"This command performs a directory listing of the specified location and returns the results as a dictionary
containing lists of keys such as files and directories and their properties.
The results are returned as a dictionary with the following structure:
{
location <searched location>
searchbase <searchbase supplied>
dirs {<list of directories matching search>}
vfsmounts {<list of entries that are mount points for virtual filesystems>}
links {<list of links matching search>}
linkinfo {<list of link targets for links matching search, in same order as links list, or 'na'>}
files {<list of files matching search>}
filesizes {<list of file sizes for files matching search, in same order as files list, or 'na'>}
sizes {<dictionary keyed on entry name with sub-dictionary of size information keyed with bytes>}
times {<dictionary keyed on entry name with sub-dictionary of time properties keyed with c a m>}
flaggedhidden {<dictionary keyed on entry name with value of 1 if entry is hidden or 0, for entries matching search, or 'na'>}
flaggedsystem {<dictionary keyed on entry name with value of 1 if entry is a system file or 0, for entries matching search, or 'na'>}
flaggedreadonly {<dictionary keyed on entry name with value of 1 if entry is read-only or 0, for entries matching search, or 'na'>}
altnames {<dictionary keyed on entry name with value of list of alternate names for that entry, or 'na'>}
opts {
-glob <glob pattern used for search>
-filedebug <filedebug supplied>
-patterndebug <patterndebug supplied>
-types <types supplied>
-with_sizes <with_sizes supplied>
-with_times <with_times supplied>
}
debuginfo {
<any debug info that may be useful to caller>
}
errors {
<any errors encountered during search, e.g inaccessible folders etc>
}
nonportable {<dictionary keyed on entry name with value of 1 if entry is non-portable or 0>}
underlayfiles {<list of files that are underlay files for virtual filesystem mounts matching search>}
underlayfilesizes {<list of sizes for underlay files for virtual filesystem mounts matching search, in same order as underlayfiles list, or 'na'>}
timinginfo {
<any timing info that may be useful to caller>
}
}
"
@opts -any 0
-searchbase -default ""
-tailglob -default "\uFFFF"
-searchbase -default ""
-tailglob -default "\uFFFF"
-filedebug -default 0 -type boolean
-patterndebug -default 0 -type boolean
#with_sizes & with_times must accept 0|1|f|d|l where f = files d = dirs l = links (punk::du)
-with_sizes -default "\uFFFF" -type string
-with_times -default "\uFFFF" -type string
-with_sizes -default "\uFFFF" -type list
-with_times -default "\uFFFF" -type list
-link_info -default 1 -type boolean -help "When links are included in results, also include resparseinfo if present."
-portabilitycheck -default 1 -type boolean -help\
"Perform portability checks on entry names and flag non-portable entries in results.
Non-portable entries are those that may not be portable across platforms, for example due
to containing characters that are illegal on some platforms, or reserved device names on windows.
When this option is enabled, entries that are determined to be non-portable will be listed under
the 'nonportable' key."
-types -default {} -type list\
-help "Restrict results to specified types.
This uses the same basic types and mechanism as the Tcl 'glob' command.
entry:
{f}iles {d}irs {l}inks {s}ockets {p}ipes {b}lock devices {c}haracter special devices
attributes:
hidden readonly
permissions:
r w x
Note that on windows, Tcl's glob command uses very basic heuristics to determine the permissions - it doesn't
actually check the ACLs.
For example - the test for executable permission is just whether the file has a known executable extension
and the test for readonly is just whether the file has the read-only attribute set.
On unix, the permissions are likely to be determined by checking the actual permissions of the file against
the current user's uid and groups.
When entry types are given, results matching any of those types will be returned.
When attributes or permissions are given, only results matching all of the specified attributes or permissions
will be returned.
The default (empty list) is to return all types and ignore attributes and permissions.
If just attributes or permissions are given without entry types, then the types will be filtered according to
the specified attributes or permissions but not according to the directory entry type
- so for example if just 'hidden' attribute is given, then both files and folders with the hidden attribute
will be returned.
(todo - macintosh specific type handling)
"
@values -min 0 -max -1 -type string -unnamed true
}
proc dirfiles_dict {args} {
@ -955,23 +1065,29 @@ tcl::namespace::eval punk::nav::fs {
#review - spaced paths ?
error "dirfiles_dict: multiple listing not *yet* supported"
}
set searchspec [lindex $searchspecs 0]
# -- --- --- --- --- --- ---
set opt_searchbase [dict get $opts -searchbase]
set opt_tailglob [dict get $opts -tailglob]
set opt_with_sizes [dict get $opts -with_sizes]
set opt_with_times [dict get $opts -with_times]
set opt_searchbase [dict get $opts -searchbase]
set opt_tailglob [dict get $opts -tailglob]
set opt_with_sizes [dict get $opts -with_sizes]
set opt_with_times [dict get $opts -with_times]
set opt_link_info [dict get $opts -link_info]
set opt_types [dict get $opts -types]
set opt_filedebug [dict get $opts -filedebug]
set opt_patterndebug [dict get $opts -patterndebug]
set opt_portabilitycheck [dict get $opts -portabilitycheck]
# -- --- --- --- --- --- ---
set searchspec [lindex $searchspecs 0]
#we don't want to normalize..
#for example if the user supplies ../ we want to see ../result
set is_relativesearchspec [expr {[file pathtype $searchspec] eq "relative"}]
if {$opt_searchbase eq ""} {
set searchbase .
} else {
#if {$opt_searchbase eq ""} {
# set searchbase .
#} else {
set searchbase $opt_searchbase
}
#}
switch -- $opt_tailglob {
@ -979,7 +1095,7 @@ tcl::namespace::eval punk::nav::fs {
if {$searchspec eq ""} {
set location
} else {
if {$is_relativesarchspec} {
if {$is_relativesearchspec} {
#set location [file dirname [file join $opt_searchbase $searchspec]]
set location [punk::path::normjoin $searchbase $searchspec ..]
} else {
@ -1063,7 +1179,7 @@ tcl::namespace::eval punk::nav::fs {
set ts1 [clock clicks -milliseconds]
if {$is_in_vfs} {
set listing [punk::du::lib::du_dirlisting_tclvfs $location -glob $match_contents {*}$next_opt_with_sizes {*}$next_opt_with_times]
set listing [punk::du::lib::du_dirlisting_tclvfs $location -glob $match_contents -filedebug $opt_filedebug -patterndebug $opt_patterndebug {*}$next_opt_with_sizes {*}$next_opt_with_times -types $opt_types]
} else {
set invfs ""
switch -glob -- $location {
@ -1094,20 +1210,25 @@ tcl::namespace::eval punk::nav::fs {
switch -- $invfs {
zipfs {
#relative vs absolute? review - cwd valid for //zipfs:/ ??
set listing [punk::du::lib::du_dirlisting_zipfs $location -glob $match_contents {*}$next_opt_with_sizes {*}$next_opt_with_times]
set listing [punk::du::lib::du_dirlisting_zipfs $location -glob $match_contents -filedebug $opt_filedebug -patterndebug $opt_patterndebug {*}$next_opt_with_sizes {*}$next_opt_with_times -types $opt_types]
}
cookit {
#seems to be a vfs - except it mounts on a pseudo-volume path //cookit:/
#don't use twapi
#could possibly use du_dirlisting_tclvfs REVIEW
#files and folders are all returned with the -types hidden option for glob on windows
set listing [punk::du::lib::du_dirlisting_generic $location -glob $match_contents {*}$next_opt_with_sizes {*}$next_opt_with_times]
set listing [punk::du::lib::du_dirlisting_generic $location -glob $match_contents -filedebug $opt_filedebug -patterndebug $opt_patterndebug {*}$next_opt_with_sizes {*}$next_opt_with_times -types $opt_types]
}
pseudovol {
set listing [punk::du::lib::du_dirlisting_generic $location -glob $match_contents {*}$next_opt_with_sizes {*}$next_opt_with_times]
set listing [punk::du::lib::du_dirlisting_generic $location -glob $match_contents -filedebug $opt_filedebug -patterndube $opt_patterndebug {*}$next_opt_with_sizes {*}$next_opt_with_times -types $opt_types]
}
default {
set listing [punk::du::dirlisting $location -glob $match_contents {*}$next_opt_with_sizes {*}$next_opt_with_times]
#The default call to punk::du::dirlisting will use the most appropriate mechanism for the platform and path
#- e.g twapi for windows local paths (if twapi is available), tcl glob for unix etc
#- and will be able to handle vfs paths that are visible to the filesystem as well (e.g cookit)
#- but may not be able to handle some vfs paths that aren't visible as normal files/folders to the filesystem
#(e.g if the vfs doesn't report itself as a vfs in vfs::filesystem info)
set listing [punk::du::dirlisting $location -glob $match_contents -filedebug $opt_filedebug -patterndebug $opt_patterndebug {*}$next_opt_with_sizes {*}$next_opt_with_times -link_info $opt_link_info -types $opt_types]
}
}
}
@ -1157,6 +1278,10 @@ tcl::namespace::eval punk::nav::fs {
#we add dotfiles to flaggedhidden list in case there is some other mechanism that has flagged items as hidden
if {$::tcl_platform(platform) ne "windows"} {
#lappend flaggedhidden {*}[lsearch -all -inline [list {*}$dirs {*}$files] ".*"]
#todo - fix! we want to match any file or folder with a leading dot
#- but we also want to preserve the full path in the flaggedhidden list - so we need to check the tail of each entry for leading dot, rather than just doing a glob match on the whole path.
lappend flaggedhidden {*}[tcl::prefix::all [list {*}$dirs {*}$files] .]
#e.g we can have dupes in the case where there are vfs mounted files that appear as dirs
#as we will need to do a (nat)sort as a last step - it will be faster to not sort items prematurely
@ -1205,9 +1330,11 @@ tcl::namespace::eval punk::nav::fs {
#jmn
set ts1 [clock milliseconds]
foreach nm [list {*}$dirs {*}$files] {
if {[punk::winpath::illegalname_test $nm]} {
lappend nonportable $nm
if {$opt_portabilitycheck} {
foreach nm [list {*}$dirs {*}$files] {
if {[punk::winpath::illegalname_test $nm]} {
lappend nonportable $nm
}
}
}
set ts2 [clock milliseconds]
@ -1221,6 +1348,165 @@ tcl::namespace::eval punk::nav::fs {
return [dict merge $listing $updated]
}
#replacement for tcl's glob.
#we can document with punk::args - but we don't want the overhead of full option parsing for every glob call - so we'll just do the option parsing manually within the proc.
#todo - add alias for punk::args id ::punk::nav::fs::fglob to ::glob so documentation matches.
proc fglob_parse_test1 {args} {
#tcl's glob requires a -- when arguments in positions that could be an option begin with a dash.
#This means accounting for prefixes of valid options and determining if they accept a parameter (which is allowed to begin with a dash) or not.
#Any argument that is in a possible option position and begins with a dash but isn't a valid option (or the -- itself)
#should raise an error rather than being treated as a literal glob pattern.
set valid_options [list -directory -join -nocomplain -path -tails -types]
set solo_options [list -nocomplain -join -tails]
set eopts_reached 0
set options [dict create] ;#for solos we will set the value to 1, for options that take parameters we will set the value to the parameter
set patterns [list]
for {set i 0} {$i < [llength $args]} {incr i} {
set a [lindex $args $i]
if {[string match -* $a]} {
if {$a eq "--"} {
set eopts_reached 1
set patterns [lrange $args [expr {$i + 1}] end]
break
}
#before -- we need to check if arg is a valid option or not
set full_option_name [tcl::prefix::match -error "" $valid_options $a]
if {$full_option_name ne ""} {
if {$full_option_name ni $solo_options} {
#option takes a parameter - so next arg is parameter even if it looks like an option
incr i
if {$i < [llength $args]} {
set param [lindex $args $i]
dict set options $full_option_name $param
} else {
error "fglob: option \"$a\" requires a parameter but none found"
}
} else {
dict set options $full_option_name 1
}
} else {
error "fglob: bad option \"$a\": must be [join $valid_options ", "] or --"
}
} else {
set patterns [lrange $args $i end]
break
}
}
return [dict create options $options patterns $patterns]
}
proc fglob_parse_test {args} {
#tcl's glob requires a -- when arguments in positions that could be an option begin with a dash.
#This means accounting for prefixes of valid options and determining if they accept a parameter (which is allowed to begin with a dash) or not.
#Any argument that is in a possible option position and begins with a dash but isn't a valid option (or the -- itself)
#should raise an error rather than being treated as a literal glob pattern.
#set valid_options [list -directory -join -nocomplain -path -tails -types]
#set solo_options [list -nocomplain -join -tails]
set options [dict create] ;#for solos we will set the value to 1, for options that take parameters we will set the value to the parameter
set patterns [list]
set arglen [llength $args]
for {set i 0; set j 1; set a [lindex $args 0]} {$i < $arglen} {incr j; set a [lindex $args [incr i]]} {
if {![string match -* $a]} {
#encountered non-option-like argument. All subsequent arguments should be treated as patterns even if they look like options.
return [dict create options $options patterns [lrange $args $i end]]
}
if {$a eq "--"} {
return [dict create options $options patterns [lrange $args $j end]]
}
#flag-like argument before -- we need to check if arg is a valid option or not
set full_option_name [tcl::prefix::match {-directory -join -nocomplain -path -tails -types} $a]
switch -- $full_option_name {
-directory - -path - -types {
#option takes a parameter - so next arg is parameter even if it looks like an option
if {$j < $arglen} {
dict set options $full_option_name [lindex $args $j]
incr i
incr j
} else {
error "fglob: option \"$a\" requires a parameter but none found"
}
}
default {
dict set options $full_option_name 1
}
}
}
return [dict create options $options patterns $patterns]
}
proc fglob {args} {
#tcl's glob requires a -- when arguments in positions that could be an option begin with a dash.
#This means accounting for prefixes of valid options and determining if they accept a parameter (which is allowed to begin with a dash) or not.
#Any argument that is in a possible option position and begins with a dash but isn't a valid option (or the -- itself)
#should raise an error rather than being treated as a literal glob pattern.
#set valid_options [list -directory -join -nocomplain -path -tails -types]
#set solo_options [list -nocomplain -join -tails]
set options [dict create] ;#for solos we will set the value to 1, for options that take parameters we will set the value to the parameter
set patterns [list]
set arglen [llength $args]
for {set i 0; set j 1; set a [lindex $args 0]} {$i < $arglen} {incr j; set a [lindex $args [incr i]]} {
if {![string match -* $a]} {
#encountered non-option-like argument. All subsequent arguments should be treated as patterns even if they look like options.
#return [dict create options $options patterns [lrange $args $i end]]
set patterns [lrange $args $i end]
break
}
if {$a eq "--"} {
#return [dict create options $options patterns [lrange $args $j end]]
set patterns [lrange $args $j end]
break
}
#flag-like argument before -- we need to check if arg is a valid option or not
set full_option_name [tcl::prefix::match {-directory -join -nocomplain -path -tails -types} $a]
switch -- $full_option_name {
-directory - -path - -types {
#option takes a parameter - so next arg is parameter even if it looks like an option
if {$j < $arglen} {
dict set options $full_option_name [lindex $args $j]
incr i
incr j
} else {
error "fglob: option \"$a\" requires a parameter but none found"
}
}
default {
dict set options $full_option_name 1
}
}
}
#return [dict create options $options patterns $patterns]
#todo -join
# process each glob as a directory listing.
if {[dict exists $options -directory]} {
set basedir [dict get $options -directory]
} else {
set basedir ""
}
#ignore -nocomplain - like tcl9 glob - if no results - return empty list rather than error
if {[dict exists $options -types]} {
set types [dict get $options -types]
} else {
set types {}
}
#todo - fix for multiple absolute paths supplied.
#e.g c:/cmdfiles/*.exe c:/repo/jn/shellspy/bin/*.exe
#- we would want to treat as two separate globs with different basedirs
#for common basedir - we might be better off creating a single glob pattern using the brace syntax for alternatives.
#TCL's glob returns a single list when multiple patterns supplied.
#It does not seem to sort or de-dup, or group results by type.
set results [list]
foreach pattern $patterns {
set resultd [dirfiles_dict -portabilitycheck 0 -searchbase $basedir -with_times 0 -with_sizes 0 -link_info 0 -types $types -tailglob $pattern]
#puts [showdict $resultd]
#todo - other types?
lappend results {*}[dict get $resultd dirs] {*}[dict get $resultd files] {*}[dict get $resultd links]
}
return $results
}
punk::args::define {
@id -id ::punk::nav::fs::dirfiles_dict_as_lines
-stripbase -default 0 -type boolean
@ -1257,6 +1543,8 @@ tcl::namespace::eval punk::nav::fs {
# - we may be able to provide a reasonable default for windows vs other - but a proc option is likely needed to allow caller to override the default behaviour on a call by call basis,
# and a config option may be desirable for the user to override the platform default.
# The chosen defaults based on platform may be generally ok - but then not give the desired behaviour when accessing a particular filesystem/mount
# - note also that case-insensitivity can be configured per folder on windows via the fsutil.exe utility
# unfortunately we have no simple & fast way to query the case-sensitivity of a particular folder or filesystem.
if {$::tcl_platform(platform) eq "windows"} {
#case-preserving but case-insensitive matching is the default
foreach d $list_of_dicts {
@ -1704,9 +1992,13 @@ tcl::namespace::eval punk::nav::fs::lib {
punk::args::define {
@id -id ::punk::nav::fs::lib::is_fileglob
@cmd -name punk::nav::fs::lib::is_fileglob
@cmd -name punk::nav::fs::lib::is_fileglob\
-help\
"Determine if a string is a glob pattern as recognised by the tcl 'glob' command.
This is used for example to determine whether to treat a path component as a literal
or a glob pattern when processing paths with glob patterns in them (e.g for the ./ command)."
@values -min 1 -max 1
path -type string -required true -help\
path -type string -optional 0 -help\
{String to test for being a glob pattern as recognised by the tcl 'glob' command.
If the string represents a path with multiple segments, only the final component
of the path will be tested for glob characters.
@ -1799,6 +2091,7 @@ interp alias {} vwd {} punk::nav::fs::vwd ;#return punk::nav::fs::VI
interp alias {} dirlist {} punk::nav::fs::dirlist
interp alias {} dirfiles {} punk::nav::fs::dirfiles
interp alias {} dirfiles_dict {} punk::nav::fs::dirfiles_dict
interp alias {} fglob {} punk::nav::fs::fglob
interp alias {} ./new {} punk::nav::fs::d/new
interp alias {} d/new {} punk::nav::fs::d/new

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

@ -116,6 +116,8 @@ namespace eval punk::path {
#(sometimes //server.com used as a short form for urls - which doesn't seem too incompatible with this anyway)
# -- ---
#This is intended to be purely a string analysis - without reference to filesystem volumes or vfs or zipfs mountpoints etc
#(but this also means we won't be able to resolve windows shortnames or dos device paths - so we will preserve those as they are) - review
#(It also means we can't resolve per drive working directories on windows - so we will preserve c: as is rather than converting to absolute - review)
#
#TODO - option for caller to provide a -base below which we can't backtrack.
#This is preferable to setting policy here for example regarding forcing no trackback below //servername/share
@ -179,6 +181,9 @@ namespace eval punk::path {
#relpaths all end up with leading . - while not always the simplest form, this is ok. (helps stop inadvertent conversions to absolutes)
# Tests - TODO
# normjoin /d:/..//vfs:/test -> /vfs:/test (good - not converted to //vfs:/test)
#normjoin c: should theoretically return current per drive working directory on c:
# - would need to use win32 GetFullPathName to resolve this.
proc normjoin {args} {
set args [lmap a $args {string map "\\\\ /" $a}]
set path [plainjoin {*}$args]

290
src/modules/punk/winlnk-999999.0a1.0.tm

@ -21,11 +21,11 @@
#[manpage_begin punkshell_module_punk::winlnk 0 999999.0a1.0]
#[copyright "2024"]
#[titledesc {windows shortcut .lnk library}] [comment {-- Name section and table of contents description --}]
#[moddesc {punk::winlnk}] [comment {-- Description at end of page heading --}]
#[moddesc {punk::winlnk}] [comment {-- Description at end of page heading --}]
#[require punk::winlnk]
#[keywords module shortcut lnk parse windows crossplatform]
#[description]
#[para] Tools for reading windows shortcuts (.lnk files) on any platform
#[para] Tools for reading windows shortcuts (.lnk files) on any platform
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
@ -164,6 +164,17 @@ tcl::namespace::eval punk::winlnk {
error "punk::winlnk::Header_Has_LinkFlag error - flagname $flagname not known"
}
}
proc Header_Get_LinkFlags_as_list {contents} {
variable LinkFlags
set allflags [Header_Get_LinkFlags $contents]
set setflags {}
dict for {flagname binflag} $LinkFlags {
if {$allflags & $binflag} {
lappend setflags $flagname
}
}
return $setflags
}
#MS-SHLLINK.pdf documents the .lnk file format in detail, but here is a brief overview of the structure of a .lnk file:
#protocol revision 10.0 (November 2025) https://winprotocoldocs-bhdugrdyduf5h2e4.b02.azurefd.net/MS-SHLLINK/%5bMS-SHLLINK%5d.pdf
@ -487,6 +498,89 @@ tcl::namespace::eval punk::winlnk {
}
}
variable known_guids [dict create\
"00021400-0000-0000-C000-000000000046" {name Desktop guidtype CLSID}\
"00021401-0000-0000-C000-000000000046" {name Shortcut guidtype CLSID}\
"20D04FE0-3AEA-1069-A2D8-08002B30309D" {name "This PC" guidtype CLSID}\
"F02C1A0D-BE21-4350-88B0-7367FC96EF3C" {name "Computers and Devices" guidtype CLSID}\
"645FF040-5081-101B-9F08-00AA002F954E" {name "Recycle Bin" guidtype CLSID}\
"26EE0668-A00A-44D7-9371-BEB064C98683" {name "Control Panel" guidtype CLSID}\
]
proc guid_lookup {guid} {
# This function looks up known GUIDs and returns their associated names or descriptions.
# In a real implementation, this could query a database of known GUIDs or use a predefined mapping of common GUIDs to their meanings.
# For example, it could recognize the CLSID for the "My Computer" folder, the "Network" folder, etc., and return human-readable names for those.
#
variable known_guids
if {[dict exists $known_guids $guid]} {
return [dict get $known_guids $guid]
} else {
if {"windows" eq $::tcl_platform(platform)} {
# On Windows, we can use the registry to look up GUIDs.
#for now we will just look up CLSIDs in HKEY_CLASSES_ROOT CLSID {guid}
package require registry
set reg_path [join [list HKEY_CLASSES_ROOT CLSID "{$guid}"] "\\"]
if {![catch {registry get $reg_path ""} name]} {
return [dict create name $name guidtype "CLSID"]
}
return ""
} else {
# On non-Windows platforms, we likely won't have a way to look up the GUID.
return ""
}
}
return ""
}
#some more hints: https://github.com/libyal/libfwsi/blob/main/documentation/Windows%20Shell%20Item%20format.asciidoc
proc Parse_LinkTargetID_typehex_1F {rawcontent} {
#The structure of this ItemID type is as follows:
#Offset 0: 2 bytes - size of the ItemID (including these 2 bytes)
#Offset 2: 1 byte - type byte (0x1F for file system objects)
#Offset 3: variable length - data specific to the item (e.g. file name, attributes, etc.)
set size_field [string range $rawcontent 0 1]
binary scan $size_field su size
set type_byte [string index $rawcontent 2]
if {[format %02X [scan $type_byte %c]] ne "1F"} {
error "punk::winlnk::Parse_LinkTargetID_typehex_1F error - expected type byte 0x1F but got [format %02X [scan $type_byte %c]]"
}
set parsed [dict create]
if {$size == 20} {
#when size is 20 and type is 0x1F - the data is assumed to be a GUID.
dict set parsed ident "GUID"
dict set parsed indicator [string range $rawcontent 3 3] ;#unknown - specific to shell implementation?
#structure is 4 bytes le, 2 bytes le, 2 bytes le, 8 bytes be
#the final hex format is 8-4-4-4-12 (e.g. 00021401-0000-0000-C000-000000000046) but the endianness is different for the first three parts vs the last two parts, which is a common pattern in Windows GUID/CLSID structures
set d1 [string range $rawcontent 4 7]
set d2 [string range $rawcontent 8 9]
set d3 [string range $rawcontent 10 11]
set d4 [string range $rawcontent 12 19]
#set d1_dec [scan $d1 i val1]
binary scan $d1 i d1_dec
binary scan $d2 s d2_dec
binary scan $d3 s d3_dec
#d4 is 8 bytes treated as individual bytes, so we can scan it as 8 individual bytes
set scan [string repeat %c 8]
set fmt [string repeat %02X 8]
set val4 [scan $d4 $scan]
set guid [format "%08X-%04X-%04X-%02X%02X-%02X%02X%02X%02X%02X%02X" $d1_dec $d2_dec $d3_dec {*}$val4]
dict set parsed guid $guid
set guid_info [guid_lookup $guid]
if {[dict size $guid_info]} {
dict set parsed name [dict get $guid_info name]
dict set parsed guidtype [dict get $guid_info guidtype]
}
return $parsed
} else {
#unknown
return $parsed
}
set data [string range $rawcontent 3 [expr {$size - 1}]]
#TODO - parse the data according to the structure of the ItemID type 0x1F
return [dict create size $size type "1F" rawdata $data]
}
#some clues on the structure of the IDList content and how to parse it can be found in the analysis of CVE-2020-0729,
#which is a remote code execution vulnerability in Windows that can be exploited through specially crafted .lnk files that contain malicious IDList content.
#The analysis of this vulnerability provides insights into how the IDList content is structured and how it can be parsed to extract information about the link target and potentially execute code.
@ -498,8 +592,8 @@ tcl::namespace::eval punk::winlnk {
set offset 0
while {$offset < [string length $idlist_content]} {
if {[string length $idlist_content] - $offset < 2} break
set size_bytes [string range $idlist_content $offset [expr {$offset + 1}]] ;#size including these 2 bytes
binary scan $size_bytes su size
set size_field [string range $idlist_content $offset $offset+1] ;#size including these 2 bytes
binary scan $size_field su size
if {$size == 0} break
if {$size < 2} {
# Invalid size, abort
@ -511,15 +605,24 @@ tcl::namespace::eval punk::winlnk {
break
}
set itemid [string range $idlist_content $offset [expr {$offset + $size - 1}]]
set itemid_bytes [string range $itemid 0 1]
binary scan $itemid_bytes su itemid_size
set itemid_size_field [string range $itemid 0 1]
binary scan $itemid_size_field su itemid_size
#in *general* byte 3 of the ItemID structure can be used to determine the type of the item
#(e.g. file, folder, network location, etc.) but this is not always reliable and can vary
#based on the specific structure of the ItemID and the context in which it is used
set itemid_type_byte [string index $itemid 2]
#puts stderr "ItemID size: $itemid_size, type byte: [format %02X [scan $itemid_type_byte %c]]"
set maybe_type [format %02X [scan $itemid_type_byte %c]]
lappend result [dict create size $itemid_size type $maybe_type rawcontent $itemid]
set typedec [scan $itemid_type_byte %c]
set typehex [format %02X $typedec]
set item_dict [dict create size $itemid_size typehex $typehex typedec $typedec rawbytes $itemid viewbytes [ansistring VIEW -lf 1 $itemid]]
switch -- $typehex {
"1F" {
set parsed [Parse_LinkTargetID_typehex_1F $itemid]
dict set item_dict parsed $parsed
}
}
lappend result $item_dict
incr offset $size
}
@ -615,9 +718,91 @@ tcl::namespace::eval punk::winlnk {
return [dict create localbasepath $localbasepath commonpathsuffix $commonpathsuffix note <incomplete>]
}
proc Contents_Get_DataStrings {contents start} {
set data_strings [string range $contents $start end]
return $data_strings
proc Contents_Get_StringData_String {contents start} {
#All StringData structures have a CountCharacters field of 2 bytes that is an unsigned integer specifying the number of characters
#in the string, followed by the string characters themselves.
#The string of variable length, must not be NULL-terminated.
#(which is 2 bytes of 0x00 if the IsUnicode flag is set in the LinkFlags field, or 1 byte of 0x00 if the IsUnicode flag is not set).
set lenfield [string range $contents $start $start+1]
set r [binary scan $lenfield su count_chars] ;# su is for unsigned short in little endian order
set string_value ""
if {[Header_Has_LinkFlag $contents "IsUnicode"]} {
#string is UTF-16LE encoded
set numbytes [expr {2 * $count_chars}]
set string_bytes [string range $contents $start+2 [expr {$start + 2 + $numbytes - 1}]]
#consider using tcl encoding convertfrom utf-16le instead of manually parsing the UTF-16LE bytes - this would be more robust and handle edge cases better (e.g. surrogate pairs, non-BMP characters, etc.)
set string_value [encoding convertfrom utf-16le $string_bytes]
#for {set i 0} {$i < [string length $string_bytes]} {
# set char_bytes [string range $string_bytes $i [expr {$i + 1}]]
# set r [binary scan $char_bytes su char] ;# s for unsigned short
# append string_value [format %c $char]
# incr i 1 ;# skip the next byte since it's part of the UTF-16LE encoding
#}
} else {
set numbytes $count_chars
set string_bytes [string range $contents $start+2 [expr {$start + 2 + $numbytes - 1}]]
#string is ANSI encoded
set string_value $string_bytes
}
return [dict create string $string_value next_start [expr {$start + 2 + $numbytes}]]
}
#MS-SHLLINK StringData
#https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-shllink/17b69472-0f34-4bcf-b290-eccdb8de224b
#stored in following order - each element is optional based on the presence of corresponding flags in the LinkFlags field of the header:
# NAME_STRING (description) - flag HasName
# RELATIVE_PATH - flag HasRelativePath
# WORKING_DIR - flag HasWorkingDir
# COMMAND_LINE_ARGUMENTS - flag HasArguments
# ICON_LOCATION - flag HasIconLocation
proc Contents_Get_StringData {contents start} {
set flags_enabled [Header_Get_LinkFlags_as_list $contents]
#puts stderr "flags_enabled: $flags_enabled"
#set data_strings [string range $contents $start end]
#return $data_strings
set current_offset $start
set result {}
if {"HasName" in $flags_enabled} {
set stringinfo [Contents_Get_StringData_String $contents $current_offset]
#set current_offset [expr {$current_offset + [string length $name] + 1}] ;#+1 for null terminator
#incr current_offset [string length $name]
set current_offset [dict get $stringinfo next_start]
set name [dict get $stringinfo string]
dict set result name_string $name
}
if {"HasRelativePath" in $flags_enabled} {
set stringinfo [Contents_Get_StringData_String $contents $current_offset]
set current_offset [dict get $stringinfo next_start]
set relative_path [dict get $stringinfo string]
#set current_offset [expr {$current_offset + [string length $relative_path] + 1}] ;#+1 for null terminator
dict set result relative_path $relative_path
}
if {"HasWorkingDir" in $flags_enabled} {
set stringinfo [Contents_Get_StringData_String $contents $current_offset]
set current_offset [dict get $stringinfo next_start]
set working_dir [dict get $stringinfo string]
#set current_offset [expr {$current_offset + [string length $working_dir] + 1}] ;#+1 for null terminator
dict set result working_dir $working_dir
}
if {"HasArguments" in $flags_enabled} {
set stringinfo [Contents_Get_StringData_String $contents $current_offset]
set current_offset [dict get $stringinfo next_start]
set arguments [dict get $stringinfo string]
#set current_offset [expr {$current_offset + [string length $arguments] + 1}] ;#+1 for null terminator
dict set result command_line_arguments $arguments
}
if {"HasIconLocation" in $flags_enabled} {
set stringinfo [Contents_Get_StringData_String $contents $current_offset]
set current_offset [dict get $stringinfo next_start]
set icon_location [dict get $stringinfo string]
#set current_offset [expr {$current_offset + [string length $icon_location] + 1}] ;#+1 for null terminator
dict set result icon_location $icon_location
}
return $result
}
proc Contents_Get_Info {contents} {
@ -639,12 +824,13 @@ tcl::namespace::eval punk::winlnk {
#Working Dir: C:\repo\jn\tclmodules\tomlish\src\modules\test\#modpod-tomlish-999999.0a1.0\suites\roundtrip\roundtrip_files
variable LinkFlags
set flags_enabled [list]
dict for {k v} $LinkFlags {
if {[Header_Has_LinkFlag $contents $k] > 0} {
lappend flags_enabled $k
}
}
set flags_enabled [Header_Get_LinkFlags_as_list $contents]
#set flags_enabled [list]
#dict for {k v} $LinkFlags {
# if {[Header_Has_LinkFlag $contents $k] > 0} {
# lappend flags_enabled $k
# }
#}
set showcommand_val [Header_Get_ShowCommand $contents]
switch -- $showcommand_val {
@ -726,19 +912,13 @@ tcl::namespace::eval punk::winlnk {
}
# ----------------------------------------------------------------------
#todo - get Data strings by parsing contents starting at $next_start
#stored in following order:
# description
# relative path
# working directory
# command line arguments
# icon location
#get StringData by parsing contents starting at $next_start
#Data strings format:
# 2 bytes: number of characters in the string
# following: The string. ASCII or UTF-16 little-endian string
set datastring_dict [Contents_Get_DataStrings $contents $next_start]
#puts stderr "next_start: $next_start"
set datastring_dict [Contents_Get_StringData $contents $next_start]
# ----------------------------------------------------------------------
@ -770,18 +950,32 @@ tcl::namespace::eval punk::winlnk {
target_type_mech $target_type_mech\
idlist $linktargetidlist\
linkinfo $linkfields\
stringdata $datastring_dict\
]
#relative_path "?"
}
namespace eval argdoc {
variable PUNKARGS
lappend PUNKARGS [list {
@id -id ::punk::winlnk::file_check_header
@cmd -name punk::winlnk::file_check_header\
-summary\
"Test if .lnk file has a valid header for a windows shortcut."\
-help\
"Check the header of the file specified in path to see if it matches the expected
structure of a windows .lnk file header.
Returns a boolean.
If an invalid path is provided or the file cannot be read, an error will be raised."
@values -min 1 -max 1
path -type string -help "Path to the .lnk file to resolve"
}]
}
proc file_check_header {path} {
#*** !doctools
#[call [fun file_check_header] [arg path] ]
#[para]Return 0|1
#[para]Determines if the .lnk file specified in path has a valid header for a windows shortcut
set c [Get_contents $path 20]
return [Contents_check_header $c]
}
namespace eval argdoc {
variable PUNKARGS
lappend PUNKARGS [list {
@ -798,10 +992,6 @@ tcl::namespace::eval punk::winlnk {
}]
}
proc resolve {path} {
#*** !doctools
#[call [fun resolve] [arg path] ]
#[para] Return a dict of info obtained by parsing the binary data in a windows .lnk file
#[para] If the .lnk header check fails, then the .lnk file probably isn't really a shortcut file and the dictionary will contain an 'error' key
set c [Get_contents $path]
if {[Contents_check_header $c]} {
return [Contents_Get_Info $c]
@ -840,6 +1030,7 @@ tcl::namespace::eval punk::winlnk {
target_type target_type\
idlist idlist/@*/@*.@*\
linkinfo linkinfo/@*.@*\
stringdata stringdata/@*.@*\
]
set info [resolve $path]
if {[dict exists $info error]} {
@ -869,11 +1060,18 @@ tcl::namespace::eval punk::winlnk {
"Return the target path of the .lnk file specified in path.
This is a convenience function that extracts the target path from the .lnk file and returns it directly,
without all the additional information that resolve provides. If the .lnk header check fails, then
the .lnk file probably isn't really a shortcut file and an error message will be returned."
the .lnk file probably isn't really a shortcut file and an error message will be returned.
Incomplete! - needs to process arguments when HasArguments link flag is set and append the arguments to the target path.
e.g for a shortcut to 'START /D ...' the target will currently just return a path to cmd.exe - which is insufficient."
@values -min 1 -max 1
path -type string -help "Path to the .lnk file to resolve"
}]
}
#consider a shortcut to the following:
# START /D ^"C:\tcl\bin^" wish.exe c:\cmdfiles\ftp.tcl"
# the target currently only returns c:/Windows/System32/cmd.exe.
proc target {path} {
#*** !doctools
#[call [fun target] [arg path] ]
@ -941,19 +1139,6 @@ tcl::namespace::eval punk::winlnk {
}
#proc sample1 {p1 n args} {
# #*** !doctools
# #[call [fun sample1] [arg p1] [arg n] [opt {option value...}]]
# #[para]Description of sample1
# #[para] Arguments:
# # [list_begin arguments]
# # [arg_def tring p1] A description of string argument p1.
# # [arg_def integer n] A description of integer argument n.
# # [list_end]
# return "ok"
#}
#*** !doctools
@ -973,13 +1158,6 @@ tcl::namespace::eval punk::winlnk::lib {
#[para] Secondary functions that are part of the API
#[list_begin definitions]
#proc utility1 {p1 args} {
# #*** !doctools
# #[call lib::[fun utility1] [arg p1] [opt {?option value...?}]]
# #[para]Description of utility1
# return 1
#}
#*** !doctools
@ -1010,7 +1188,7 @@ namespace eval ::punk::args::register {
package provide punk::winlnk [tcl::namespace::eval punk::winlnk {
variable pkg punk::winlnk
variable version
set version 999999.0a1.0
set version 999999.0a1.0
}]
return

41
src/modules/punk/winpath-999999.0a1.0.tm

@ -136,18 +136,30 @@ namespace eval punk::winpath {
proc illegal_char_map_to_ntfs {ch} {
#windows ntfs maps illegal chars to the PUA unicode range 0xF000-0xF0FF for characters that are illegal in windows API but can exist on NTFS or samba shares etc.
#see also: https://cygwin.com/cygwin-ug-net/using-specialnames.html#pathnames-specialchars
#see also: https://stackoverflow.com/questions/76738031/unicode-private-use-characters-in-ntfs-filenames#:~:text=map_dict%20=%20%7B%200xf009:'%5C,c%20return%20(output_name%2C%20mapped)
#see also: https://stackoverflow.com/questions/76738031/unicode-private-use-characters-in-ntfs-filenames
#formula is 0xF000 + ascii value of char.
#(decimal values are 61440 + ascii value of char)
#see also punk::char::ascii2NTFSPUA
# punk::char::codetable ascii
set map [dict create \
"\"" "\uF022" \
"*" "\uF02A" \
":" "\uF03A" \
"<" "\uF03C" \
">" "\uF03E" \
":" "\uF03A" \
"\"" "\uF022" \
"/" "unknown" \
"?" "\uF03F" \
"\\" "\uF05c" \
"|" "\uF07C" \
"?" "\uF03F" \
"*" "\uF02A"]
]
dict set map "/" "\uF02F"
#ESC (\x1b) is also mapped.
dict set map \x1b "\uF01B"
#note that : is used for NTFS alternate data streams - but the character is still illegal in the main filename - so we will map it to a glyph in the PUA range for display purposes - but it will still need dos device syntax to be accessed via windows API.
if {$ch in [dict keys $map]} {
return [dict get $map $ch]
@ -412,14 +424,15 @@ namespace eval punk::winpath::system {
}
# -----------------------------------------------------------------------------
# register namespace(s) to have PUNKARGS,PUNKARGS_aliases variables checked
# -----------------------------------------------------------------------------
# variable PUNKARGS
# variable PUNKARGS_aliases
namespace eval ::punk::args::register {
#use fully qualified so 8.6 doesn't find existing var in global namespace
lappend ::punk::args::register::NAMESPACES ::punk::winpath
}
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
## Ready

1
src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/include_modules.config

@ -54,6 +54,7 @@ set bootsupport_modules [list\
modules punk::assertion\
modules punk::args\
modules punk::args::moduledoc::tclcore\
modules punk::auto_exec\
modules punk::cap\
modules punk::cap::handlers::caphandler\
modules punk::cap::handlers::scriptlibs\

247
src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/punk-0.1.tm

@ -75,6 +75,76 @@ namespace eval punk {
#
}
if {![interp issafe] && $::tcl_platform(platform) eq "windows"} {
#return the raw command string from the registry for the association of the given extension, without processing the placeholders such as %1 %SystemRoot% etc.
#This is because we want to process these ourselves to be able to return a proper list of command and arguments.
#Note that the resulting string can't be directly treated as a tcl list because it has double quoted segments with characters that are literals (not escaped)
#Accessing it directly as a list will cause tcl to interpret the backslashes as escapes and lose the literal meaning values such as the path.
proc extension_open_association {ext} {
#look up assoc and ftype to find associated file type and application.
#Note that the windows 'ftype' and 'assoc' commands only examine the system's data - not the user-specific data which may override the system data.
#to get the user-specific associations we need to read the registry keys.
#extensions in the registry seem to be stored lower case wnd with a leading dot.
set lext [string tolower $ext]
package require registry
set user_assoc_path [join [list HKEY_CURRENT_USER Software Microsoft Windows CurrentVersion Explorer FileExts $lext UserChoice] "\\"]
#The UserChoice subkey under this key is where the user-specific association is stored when the user has made a choice. It contains a Progid value which points to the associated file type.
#It can return something like "Python.File" or "Applications\python.exe" or even tcl_auto_file (for .tcl files associated with tclsh by a tcl installer)
#The UserChoice key may not exist if the user hasn't made an explicit choice, in which case we fall back to the system association which is stored under HKEY_CLASSES_ROOT\$ext (which also contains user-specific overrides but is more cumbersome to query for them)
if {![catch {registry get $user_assoc_path Progid} user_choice]} {
if {$user_choice ne ""} {
#examples such as Applications\python.exe and tcl_auto_file are found under HKEY_CURRENT_USER\Software\Classes
#they then have a sub-path shell\open\command which contains the command to open files of that type, which is what we need to extract the associated application.
#it is faster to query for the key directly within a catch than to retrieve keys as lists and iterate through them to find the one we want.
if {![catch {registry get [join [list HKEY_CURRENT_USER Software Classes $user_choice shell open command] "\\"] ""} raw_assoc]} {
#The command string can contain placeholders like "%1" for the file name, so we need to extract just the executable path.
#e.g .py -> "c:\Program Files\Python\python.exe" "%1"
#e.g .rb -> "C:\tools\ruby31\bin\ruby.exe" "%1" %*
# e.g .vbs -> "%SystemRoot%\System32\WScript.exe" "%1" %*
#we need to process this without Tcl interpreting the backslashes as escapes.
#we will use double quotes to determine which entries belong together as a list item for the resulting list of command and arguments.
#set assoc [string map [list \\ \\\\] $raw_assoc]
set assoc $raw_assoc
} else {
#e.g Python.File
if {![catch {registry get [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes $user_choice shell open command] "\\"] ""} raw_assoc]} {
#set assoc [string map [list \\ \\\\] $raw_assoc]
set assoc $raw_assoc
} else {
set assoc ""
}
}
} else {
#review - is it possible for Progid to be empty string? If so we should probably fall back to system association instead of returning no association.
#alternatively it could mean the user has explicitly chosen to have no association for this file type - in which case returning an empty association may be the correct behaviour.
set assoc ""
}
} else {
#fall back to system association and ftype
if {![catch {registry get [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes $lext] "\\"] ""} ftype]} {
#ftype is the file type associated with the extension, e.g "Python.File"
#we then need to look up the command associated with this file type under the same path but with the file type instead of the extension and with the additional sub-path shell\open\command
if {![catch {registry get [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes $ftype shell open command] "\\"] ""} raw_assoc]} {
#set assoc [string map [list \\ \\\\] $raw_assoc]
set assoc $raw_assoc
} else {
set assoc ""
}
} else {
set assoc ""
}
}
return $assoc
}
}
if {![interp issafe] && $has_commandstack && $::tcl_platform(platform) eq "windows"} {
#still a caching version of auto_execok - but with proper(fixed) search order
@ -113,6 +183,8 @@ namespace eval punk {
proc auto_execok_better name {
global auto_execs env tcl_platform
#for now at least, auto_execok_better is windows-specific.
package require punk::auto_exec
if {[info exists auto_execs($name)]} {
return $auto_execs($name)
@ -141,11 +213,60 @@ namespace eval punk {
}
if {[llength [file split $name]] != 1} {
#has a path
#has a path component - could be relative or absolute.
if {[string tolower [file extension $name]] eq ".lnk"} {
#special case .lnk
#todo - consider working directory or other properties of link before launching?
package require punk::winlnk
if {![catch {punk::winlnk::target $name} linktarget]} {
if {$linktarget ne ""} {
set target $linktarget
} else {
return ""
}
} else {
set target $name
}
} else {
set target $name
}
#always store $name as the key when setting auto_execs.
foreach ext $execExtensions {
set file ${name}${ext}
set file ${target}${ext}
#first execExtension is empty string - ensure we test actual file as given before we try appending extensions.
if {$ext eq ""} {
set test_ext [file extension $file]
} else {
set test_ext $ext
}
if {[file exists $file] && ![file isdirectory $file]} {
return [set auto_execs($name) [list $file]]
#look up assoc and ftype to find associated file type and application.
#Note that the windows 'ftype' and 'assoc' commands only examine the system's data - not the user-specific data which may override the system data.
#set assoc [extension_open_association $ext]
set associnfo [punk::auto_exec::shell_open_command $test_ext]
set valuetype [dict get $associnfo type]
set assoc [dict get $associnfo value]
set ftype [dict get $associnfo filetype]
if {$assoc eq ""} {
return [set auto_execs($name) [list $file]]
} else {
if {[file pathtype $target] eq "relative" && $ftype eq "InternetShortcut"} {
#special case InternetShortcut - cannot accept relative path - so we can't cache it in auto_execs if we used a relative path to launch
#if we return an empty string - the auto_exec will fail to launch this every time.
#The best we can do is return a token for the 'unknown' process to detect and re-resolve the path every time.
#This requires cooperation from 'unknown' which may not be configured to handle this token if the default 'punk' version isn't installed.
#we can't resolve using absolute path here - because we don't want to lock in a specific file for a relative path.
#e.g ::auto_execs(./link.url) = some.exe c:/desktop/link.url
#this would be wrong if the user changed directory and tried to run ./link.url again on a different file with the same name
# - as the cached path would no longer be correct.
return [set auto_execs($name) "(resolve_in_unknown_handler) punk::auto_exec absolute_path required"]
}
puts stderr "auto_execok_better: (review required) assoc $assoc for file $file ext $test_ext"
set run [punk::auto_exec::shell_command_as_tcl_list -type $valuetype $assoc $file] ;# -workingdir [pwd] vs path of script?
return [set auto_execs($name) $run]
#return [set auto_execs($name) [list $file]]
}
}
}
return ""
@ -164,11 +285,20 @@ namespace eval punk {
append path "$windir/system32;$windir/system;$windir;"
}
foreach var {PATH Path path} {
if {[info exists env($var)]} {
append path ";$env($var)"
}
# ------------------------
#Note that unlike an ordinary Tcl array - the linked ::env behaves differently.
#e.g parray ::env Path will not find ::env(PATH) and yet 'info exists env(Path)' returns true.
#similarly 'set ::env(Path) ?newval?' or any case variation can set/get the value of ::env(PATH)
#Windows environment variables are case-insensitive.
#No evidence has been seen that any version of windows; current or historic since NT; can allow differently cased versions
# of an environment variable to exist concurrently in the same environment.
#for this reason we should be able to just use PATH.
#
if {[info exists env(PATH)]} {
append path ";$env(PATH)"
}
# ------------------------
#change2
if {[file extension $name] ne "" && [string tolower [file extension $name]] in [string tolower $execExtensions]} {
@ -177,6 +307,8 @@ namespace eval punk {
set lookfor [lmap ext $execExtensions {string cat ${name} ${ext}}]
}
#puts "-->$lookfor"
foreach dir [split $path {;}] {
set dir [string trim $dir {\\}] ;#trailing slash will result in a tail such as "/python.exe"
#set dir [file normalize $dir]
@ -207,7 +339,30 @@ namespace eval punk {
foreach match [glob -nocomplain -dir $dir -tail {*}$lookfor] {
set file [file join $dir $match]
if {[file exists $file] && ![file isdirectory $file]} {
return [set auto_execs($name) [list $file]]
#set assoc [extension_open_association [file extension $file]]
#todo - cache this lookup for each extension we encounter? maybe not, as the user might like changes reflected between runs. review.
if {"windows" ne $::tcl_platform(platform)} {
return [set auto_execs($name) [list $file]]
}
set associnfo [punk::auto_exec::shell_open_command [file extension $file]]
set assoc [dict get $associnfo value]
set type [dict get $associnfo type]
if {$assoc eq ""} {
return [set auto_execs($name) [list $file]]
} else {
puts stderr "auto_execok_better: assoc $assoc for file $file with type $type"
#return [set auto_execs($name) [list $file]]
#review - our stored auto_execs doesn't have any way to capture the full assoc info such as how subsequent arguments should be processed.
#This may need handling in our Tcl shell 'unknown' function when calls are actually made to these commands
#- we may need to re-process the assoc info at that point to determine how to combine all arguments with the calling specification in the assoc string.
#The workingdir for the command may also need to be determined at that point - should it be the dir of the script being called, or the current dir of the shell?
#The main point of Tcl's auto_execs is to avoid scanning the PATH entries every time a command is called,
#but we may want to keep some of the assoc info available for processing at call time.
set run [punk::auto_exec::shell_command_as_tcl_list -type $type $assoc $file] ;# -workingdir [pwd] vs path of script?
return [set auto_execs($name) $run]
}
}
}
}
@ -5203,11 +5358,11 @@ namespace eval punk {
#These can be stacked with shellfilter and operate as OS handles - which we can't do with fifo2 etc
#
if {[string first " " $new] > 0} {
set c1 $name
} else {
set c1 $new
}
#if {[string first " " $new] > 0} {
# set c1 $name
#} else {
# set c1 $new
#}
# -- --- --- --- ---
set idlist_stdout [list]
@ -5241,6 +5396,24 @@ namespace eval punk {
} else {
set repl_runid [punk::get_repl_runid]
#set ::punk::last_run_display [list]
if {$new eq "(resolve_in_unknown_handler) punk::auto_exec absolute_path required"} {
#re-resolve.
puts "(unknown-handler): auto_execok for $name requires absolute path. Re-resolving $name with absolute path."
set ext [file extension $name]
set associnfo [punk::auto_exec::shell_open_command $ext]
set valuetype [dict get $associnfo type]
set assoc [dict get $associnfo value]
set ftype [dict get $associnfo filetype]
set fullpath [file normalize $name]
#at least for .url files - long paths (paths with multiple spaces?) can fail to run. Using the short path seems to fix this.
#This seems hacky but anyway..
set attributes [file attributes $fullpath]
if {[dict exists $attributes -shortname]} {
set fullpath [dict get $attributes -shortname]
}
set new [punk::auto_exec::shell_command_as_tcl_list -type $valuetype $assoc $fullpath]
}
set redir ">&@stdout <@stdin"
uplevel 1 [list ::catch [concat exec $redir $new [lrange $args 1 end]] ::tcl::UnknownResult ::tcl::UnknownOptions]
@ -6108,6 +6281,16 @@ namespace eval punk {
}
}
#This should be designed to be useful on all platforms.
#Case sensitivity represents a difficulty because even on a particular platform
#- different filesystems or folders may have different case sensitivity configurations.
#as a first step - we can detect windows and mac platforms and treat paths as case-insensitive, vs case-sensitive on other unix-like platforms.
#as a second step - we will consider running a test on each path to determine if the folder at the leaf level is case-sensitive or not.
#- and then use that information to determine how to treat the executables in that path.
#This may be a bit of a performance hit - so we may want to cache the results of this test for each path - and provide a way to clear the cache if needed.
#Alternatively we could just provide an option to treat all paths as case-sensitive or case-insensitive.
#Windows executable search location order:
#1. The current directory.
#2. The directories that are listed in the PATH environment variable.
@ -6203,8 +6386,12 @@ namespace eval punk {
#(This seems to occur when the pattern does *not* contain a wildcard and is probably a bug)
#We would rather report results with the actual case as they appear on the filesystem - so we try to normalize the results.
#The normalization doesn't work as expected - but can be worked around by using 'string range $e 0 end' instead of just $e - which seems to force Tcl to give us the actual case from the filesystem rather than the case as globbed for.
#(another workaround is to use a degenerate case glob e.g when looking for 'SDX.exe' to glob for '[S]DX.exe')
#(another workaround is to use a degenerate case glob e.g when looking for 'SDX.exe' to glob for '[S]DX.exe'
# but tcl's glob does not respect the case of even the character-class pattern - so this is not a reliable workaround).
#see punk::fglob for a work-in-progress glob implementation which gives us more control over case sensitivity and the case of results on windows.
set globresults [lsort -unique [glob -nocomplain -directory $pnormglob -types {f x} {*}$binglobs]]
#JJJ
set executables [list]
foreach e $globresults {
puts stderr "glob result: $e"
@ -6596,6 +6783,29 @@ namespace eval punk {
#These aliases work fine for interactive use - but the result is always a string int-rep
#interp alias {} ~ {} file join $::env(HOME) ;#HOME must be capitalized to work cross platform (lowercase home works on windows - but probably not elsewhere)
#interp alias {} ~ {} apply {args {file join $::env(HOME) $args}}
namespace eval argdoc {
punk::args::define {
@id -id ::punk::~
@cmd -name "punk::~"\
-summary\
"Return home directory"\
-help\
"Return the home directory path. With additional arguments,
return the path obtained by joining the home directory with
the supplied arguments.
usage e.g
cd [~]
(change to home directory)
.// [~] .config
(change to .config directory within home directory
and list contents.)
"
@opts
@values -min 0 -max -1
arg -type any -optional 1 -multiple 1
}
}
proc ~ {args} {
set hdir [punk::valcopy $::env(HOME)]
file pathtype $hdir
@ -7811,9 +8021,10 @@ namespace eval punk {
# -------------------------------------------------------
set title "[a+ brightgreen] Filesystem navigation: "
set cmdinfo [list]
lappend cmdinfo [list ./ "?${I}subdir${NI}?" "view/change directory"]
lappend cmdinfo [list ../ "" "go up one directory"]
lappend cmdinfo [list ./new "${I}subdir${NI}" "make new directory and switch to it"]
lappend cmdinfo [list ./ "?${I}glob${NI}?" "view/change dir, list dirs."]
lappend cmdinfo [list ../ "?${I}path${NI}" "go up one dir, then to path if given"]
lappend cmdinfo [list .// "?${I}glob${NI}?" "view/change dir, list dirs and files"]
lappend cmdinfo [list ./new "${I}subdir${NI}..." "make new dir or dirs and show status"]
lappend cmdinfo [list fcat "${I}file ?file?...${NI}" "cat file(s)"]
set t [textblock::class::table new -minwidth 80 -show_seps 0]
foreach row $cmdinfo {
@ -7859,7 +8070,7 @@ namespace eval punk {
lappend cmdinfo [list i "${I}cmd${NI} ?${I}subcommand${NI}...?" "Show usage for a command or ensemble subcommand"]
lappend cmdinfo [list s "${I}cmd${NI} ?${I}subcommand${NI}...?" "Show synopsis for a command or ensemble subcommand"]
lappend cmdinfo [list eg "${I}cmd${NI} ?${I}subcommand${NI}...?" "Show example from manpage"]
lappend cmdinfo [list corp "${I}proc${NI}" "View proc body and arguments"]
lappend cmdinfo [list corp "${I}proc${NI}" "View proc body and arguments with basic highlighting"]
set t [textblock::class::table new -minwidth 80 -show_seps 0]
foreach row $cmdinfo {
$t add_row $row

24
src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/punk/ansi-0.1.1.tm

@ -1148,7 +1148,7 @@ tcl::namespace::eval punk::ansi {
"The grepstr command can find strings in ANSI text even if there are interspersed
ANSI colour codes etc. Even if a word has different coloured/styled letters, the
regex can match the plaintext. (Search is performed on ansistripped text, and then
the matched sections are highlighted and overlayed on the original styled/colourd
the matched sections are highlighted and overlayed on the original styled/coloured
input.
If the input string has ANSI movement codes - the resultant text may not be directly
@ -3524,9 +3524,9 @@ Brightblack 100 Brightred 101 Brightgreen 102 Brightyellow 103 Brightblu
underline {lappend t 4}
underlinedefault {lappend t 59}
underextendedoff {
#Remove any existing 4:1 etc extended underline codes
#NOTE: struct::set result order can differ depending on whether tcl/critcl impl is used.
#FIXED: Using punk::lib::ldiff instead of struct::set difference for consistent ordering.
#lremove any existing 4:1 etc
#NOTE struct::set result order can differ depending on whether tcl/critcl imp used
#set e [struct::set difference $e [list 4:1 4:2 4:3 4:4 4:5]]
set e [punk::lib::ldiff $e [list 4:1 4:2 4:3 4:4 4:5]]
lappend e 4:0
}
@ -3543,8 +3543,9 @@ Brightblack 100 Brightred 101 Brightgreen 102 Brightyellow 103 Brightblu
lappend e 4:4
}
underdashed - underdash {
# FIXED: Extended codes with colon suppress normal SGR attributes when in same escape sequence
# on terminals that don't support the extended codes. Emit as separate sequence if needed.
#TODO - fix
# extended codes with colon suppress normal SGR attributes when in same escape sequence on terminal that don't support the extended codes.
# need to emit in
lappend e 4:5
}
doubleunderline {lappend t 21}
@ -4732,11 +4733,7 @@ Brightblack 100 Brightred 101 Brightgreen 102 Brightyellow 103 Brightblu
if {[string length $uri] > 2083} {
error "punk::ansi::hyperlink uri too long: limit 2083"
}
#SECURITY: Sanitize hyperlink ID to prevent injection attacks
#Current mapping: : -> . ; -> , prevents common delimiter issues
#FUTURE: Consider additional restrictions on special characters (=, &, ?, #, etc.)
#to prevent URL parameter injection or other hyperlink protocol exploits
set id [string map {: . {;} ,} $id]
set id [string map {: . {;} ,} $id] ;#avoid some likely problematic ids. TODO - review, restrict further.
set params "id=$id"
return "\x1b\]8\;$params\;$uri\x1b\\"
}
@ -6829,9 +6826,8 @@ tcl::namespace::eval punk::ansi::ta {
# -- --- --- ---
#variable re_ansi_detect1 "${re_csi_code}|${re_esc_osc1}|${re_esc_osc2}|${re_esc_osc3}|${re_standalones}|${re_ST}|${re_g0_open}|${re_g0_close}"
# -- --- --- ---
#handrafted TRIE version of above. Somewhat difficult to construct and maintain.
#FUTURE: Consider using a regexp TRIE generator that works with Tcl regexes for maintainability.
#This does make things quicker - but it's too early to finalise the detect/split regexes (e.g missing \U0090 ) - will need to be redone.
#handrafted TRIE version of above. Somewhat difficult to construct and maintain. TODO - find a regexp TRIE generator that works with Tcl regexes
#This does make things quicker - but it's too early to finalise the detect/split regexes (e.g missing \U0090 ) - will need to be redone.
#variable re_ansi_detect {(?:\x1b(?:\((?:0|B)|\[(?:[\x20-\x2f\x30-\x3f]*[\x40-\x7e])|\](?:(?:[^\007]*)\007|(?:(?!\x1b\\).)*\x1b\\)|(?:P|X|\^|_)(?:(?:(?!\x1b\\|\007).)*(?:\x1b\\|\007))|c|7|8|M|E|D|H|=|>|(?:#(?:3|4|5|6|8))))|(?:\u0090|\u0098|\u009E|\u009F)(?:(?!\u009c).)*(?:\u009c)|(?:\u009b)[\x20-\x2f\x30-\x3f]*[\x40-\x7e]|(?:\u009d)(?:[^\u009c]*)?\u009c}
#NOTE - the literal # char can cause problems in expanded syntax - even though it's within a bracketed section. \# seems to work though.

35
src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/punk/args-0.2.1.tm

@ -6148,7 +6148,7 @@ tcl::namespace::eval punk::args {
### set e [lindex $clauseval $clausecolumn]
### set e_check [lindex $clauseval_check $clausecolumn]
### #//review - we may need '?' char on windows
### if {!([tcl::string::length $e_check]>0 && ![regexp {[\"*<>\;]} $e_check])} {
### if {!([tcl::string::length $e_check]>0 && ![regexp {[\"*<>]} $e_check])} {
### #what about special file names e.g on windows NUL ?
### set msg "$argclass $argname for %caller% requires type '$type'. Received: '$e' which doesn't look like it could be a file or directory"
### return -options [list -code error -errorcode [list PUNKARGS VALIDATION [list typemismatch $type] -badarg $argname -argspecs $argspecs]] $msg
@ -6671,9 +6671,12 @@ tcl::namespace::eval punk::args {
directory -
existingfile -
existingdirectory {
#//review - we may need '?' char on windows
if {!([tcl::string::length $e_check]>0 && ![regexp {[\"*<>\;]} $e_check])} {
#what about special file names e.g on windows NUL ?
#we have to support file and directory names on all platforms - and even characters illegal on a filesystem/platform may need to be passed.
#For example a file/folder may be created with an illegal name on a platform (or mounted on it) and be mapped to another string on the filesystem
#- yet it may remain accessible to commands such as file stat etc via the string with 'illegal' characters as well as its underlying stored (mapped) name.
#NUL is almost universally problematic - so we will reject.
if {[tcl::string::length $e_check]==0 || [string first \0 $e_check] >= 0} {
set msg "$argclass $argname for %caller% requires type '$type'. Received: '$e' which doesn't look like it could be a file or directory"
lset clause_results $c_idx $a_idx [list err [list typemismatch $type] msg $msg]
continue
@ -6693,6 +6696,30 @@ tcl::namespace::eval punk::args {
}
lset clause_results $c_idx $a_idx 1
}
existingportablefile -
existingportabledirectory -
portablefile -
portabledirectory {
if {[tcl::string::length $e_check]==0 || [string first \0 $e_check] >= 0 || [punk::winpath::illegalname_test $e_check]} {
set msg "$argclass $argname for %caller% requires type '$type'. Received: '$e' which doesn't look like it could be a portable file or directory (must pass punk::winpath::illegalname_test)"
lset clause_results $c_idx $a_idx [list err [list typemismatch $type] msg $msg]
continue
}
if {$type eq "existingportablefile"} {
if {![file exists $e_check]} {
set msg "$argclass $argname for %caller% requires type '$type'. Received: '$e' which is not an existing file"
lset clause_results $c_idx $a_idx [list err [list typemismatch $type] msg $msg]
continue
}
} elseif {$type eq "existingportabledirectory"} {
if {![file isdirectory $e_check]} {
set msg "$argclass $argname for %caller% requires type '$type'. Received: '$e' which is not an existing directory"
lset clause_results $c_idx $a_idx [list err [list typemismatch $type] msg $msg]
continue
}
}
lset clause_results $c_idx $a_idx 1
}
char {
#review - char vs unicode codepoint vs grapheme?
if {[tcl::string::length $e_check] != 1} {

33
src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/punk/args/moduledoc/tclcore-0.1.0.tm

@ -4490,17 +4490,15 @@ tcl::namespace::eval punk::args::moduledoc::tclcore {
# -- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---
# -- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---
#DOCUMENTED: CLOCK_ARITHMETIC and TIME ZONES references added to help text
#TODO - add CLOCK_ARITHMETIC documentation
#TODO - TIME ZONES documentation?
lappend PUNKARGS [list {
@id -id ::tcl::clock::add
@cmd -name "Built-in: tcl::clock::add"\
-summary\
"Add an offset to timeVal in seconds (base 1970-01-01 00:00 UTC)"\
-help\
"Adds a (possibly negative) offset to a time that is expressed as an integer number of seconds.
CLOCK ARITHMETIC: Supports count_unit pairs (e.g., 1 month, 2 weeks) for flexible date arithmetic.
TIME ZONES: Use -timezone option with values like :UTC, :localtime, or location-based zones (:America/New_York).
See the clock manpage for complete CLOCK ARITHMETIC and TIME ZONES documentation."
"Adds a (possibly negative) offset to a time that is expressed as an integer number of seconds. See CLOCK ARITHMETIC for a full description."
@leaders -min 1 -max -1
timeVal -type integer|literal(now) -help\
"Time value in integer number of seconds since epoch time.
@ -5427,6 +5425,23 @@ tcl::namespace::eval punk::args::moduledoc::tclcore {
glob */
}]}
except that the first case doesn't return the trailing “/” and is more platform independent."
#todo - review eopts handling.
#The actual glob function will treat the first non-dashed argument as the first pattern, whereas
#punk::args will treat the first invalid option or non-dashed as the end of options and the start of patterns.
#This means that if you have a pattern that starts with a dash, you must use -- to mark the end of options.
#We need a way to force punk::args to have that more restrictive behaviour in this case so that the help is aligned.
#ie tcl glob requires -- whenever values start with a dash, but punk::args only requires it only when the first of multiple values
#start with a dash or when a single has a prefix-match for a flag.
#Arguably punk::args should be more restrictive and require -- whenever any value starts with a dash.
#e.g punk::args will accept "glob -nonflag" but will warn of "glob -nonflag -nonflag" as an invalid option.
#This helps provide warnings for mistyped flags, but is potentially confusing.
#In any case, the help for glob should be consistent with the actual behaviour of glob, so we need to make sure that punk::args
#is configured to require -- whenever any value starts with a dash.
#This requires either a change to the default behaviour of punk::args when {-- -type none} is used,
#or a new option to control this behaviour.
#A new option may be preferable to avoid changing the default behaviour of punk::args in other cases where it may be desirable
#to allow values starting with dashes without requiring --.
#(e.g when a command only accepts a single value and that value may start with a dash)
-- -type none -help\
"Marks the end of switches. The argument following this one will be treated as a pattern even if it
starts with a -."
@ -5760,8 +5775,8 @@ tcl::namespace::eval punk::args::moduledoc::tclcore {
@form -form configure
@values -min 0 -max -1
#FUTURE: Implement choice-parameters for better validation
#Would allow: -choiceparameters {literalprefix type} for smarter option validation
#TODO - choice-parameters
#?? -choiceparameters {literalprefix type}
optionpair\
-type {string any}\
-typesynopsis {${$I}-option value${$NI}}\
@ -5769,7 +5784,7 @@ tcl::namespace::eval punk::args::moduledoc::tclcore {
-multiple 1\
-choicerestricted 0\
-choices {{-command string} {-granularity int} {-milliseconds int} {-seconds int} {-value any}}\
-help "Option-value pairs. Valid options: -command, -granularity, -milliseconds, -seconds, -value"
-help "(todo: adjust args definition to validate optionpairs properly)"
@form -form query
@values -min 0 -max 1
@ -6943,7 +6958,7 @@ tcl::namespace::eval punk::args::moduledoc::tclcore {
@opts -type string -parsekey "" -group "NESTED LIST OPTIONS" -grouphelp\
"These options are used to search lists of lists. They may be used with any other options."
-stride -type integer -default 1 -typesynopsis strideLength -help\
-stride -type integer -default 1 -range {1 ""} -typesynopsis strideLength -help\
"If this option is specified, the list is treated as consisting of groups of ${$I}strideLength${$NI} elements and the
groups are searched by either their first element or, if the ${$B}-index${$N} option is used, by the element within
each group given by the first index passed to -index (which is then ignored by -index). The resulting

786
src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/punk/auto_exec-0.1.0.tm

@ -0,0 +1,786 @@
# -*- tcl -*-
# Maintenance Instruction: leave the 999999.xxx.x as is and use punkshell 'dev make' or bin/punkmake to update from <pkg>-buildversion.txt
# module template: shellspy/src/decktemplates/vendor/punk/modules/template_module-0.0.4.tm
#
# Please consider using a BSD or MIT style license for greatest compatibility with the Tcl ecosystem.
# Code using preferred Tcl licenses can be eligible for inclusion in Tcllib, Tklib and the punk package repository.
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
# (C) 2026
#
# @@ Meta Begin
# Application punk::auto_exec 0.1.0
# Meta platform tcl
# Meta license <unspecified>
# @@ Meta End
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
## Requirements
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
package require Tcl 8.6-
#----------------------
# registry notes
#Computer\HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\ApplicationAssociationToasts
# - associates applications/file types with extensions and protocols.
#.cp items have a shell\cplopen\command subkey instead of shell\open\command
#- but the command string has the same format and placeholders as the shell\open\command entries for other file types,
#so we can handle them in the same way as other file types when we extract the associated command string.
#----------------------
tcl::namespace::eval punk::auto_exec {
namespace eval argdoc {
variable PUNKARGS
set I "\x1b\[3m" ;# [a+ italic]
set NI "\x1b\[23m" ;# [a+ noitalic]
set B "\x1b\[1m" ;# [a+ bold]
set N "\x1b\[22m" ;# [a+ normal]
set T "\x1b\[1\;4m" ;# [a+ bold underline]
set NT "\x1b\[22\;24m\x1b\[4:0m" ;# [a+ normal nounderline]
}
variable PUNKARGS
lappend PUNKARGS [list {
@id -id "::punk::auto_exec::shell_open_command"
@cmd -name "punk::auto_exec::assoc" -help\
{Returns the raw 'open' command string associated with the file type for the given file extension,
by looking up the user-specific association in the registry and falling back to the system
association if no user-specific association is found.
The resulting command string can contain placeholders like "%1" for the file name.
e.g .py -> "c:\Program Files\Python\python.exe" "%1"
e.g .rb -> "C:\tools\ruby31\bin\ruby.exe" "%1" %*
e.g .vbs -> "%SystemRoot%\System32\WScript.exe" "%1" %*
Note that the resulting string has unescaped backslashes within double quotes, so it is
not suitable for direct execution by Tcl without further processing to handle the backslashes
and placeholders.
see 'shell_command_as_tcl_list' for processing the command string into a Tcl list of command and
arguments with placeholders substituted.
}
@opts
@values -min 0 -max 1
ext -type string -default "" -optional true -help\
"File extension to look up, e.g .txt or .py"
}]
proc shell_open_command {ext} {
#look up assoc and ftype to find associated file type and application.
#Note that the windows 'ftype' and 'assoc' commands only examine the system's data - not the user-specific data which may override the system data.
#to get the user-specific associations we need to read the registry keys.
#extensions in the registry seem to be stored lower case wnd with a leading dot.
set lext [string tolower $ext]
package require registry
set user_assoc_path [join [list HKEY_CURRENT_USER Software Microsoft Windows CurrentVersion Explorer FileExts $lext UserChoice] "\\"]
#The UserChoice subkey under this key is where the user-specific association is stored when the user has made a choice. It contains a Progid value which points to the associated file type.
#It can return something like "Python.File" or "Applications\python.exe" or even tcl_auto_file (for .tcl files associated with tclsh by a tcl installer)
set openverb "open"
#The UserChoice key may not exist if the user hasn't made an explicit choice, in which case we fall back to the system association which is stored under HKEY_CLASSES_ROOT\$ext (which also contains user-specific overrides but is more cumbersome to query for them)
if {![catch {registry get $user_assoc_path Progid} user_choice]} {
if {$user_choice ne ""} {
#examples such as Applications\python.exe and tcl_auto_file are found under HKEY_CURRENT_USER\Software\Classes
#they then have a sub-path shell\open\command which contains the command to open files of that type, which is what we need to extract the associated application.
#it is faster to query for the key directly within a catch than to retrieve keys as lists and iterate through them to find the one we want.
#special case .cpl cplfile
if {$user_choice eq "cplfile"} {
set openverb "cplopen"
}
set verbinfo [ftype $user_choice]
if {[dict exists $verbinfo $openverb]} {
set ftypeinfo [dict get $verbinfo $openverb]
return [dict set ftypeinfo filetype $user_choice]
} else {
return [dict create type "notfound" value "" scope user filetype $user_choice]
}
} else {
#review - is it possible for Progid to be empty string? If so we should probably fall back to system association instead of returning no association.
#alternatively it could mean the user has explicitly chosen to have no association for this file type - in which case returning an empty association may be the correct behaviour.
return [dict create type "empty" value "" scope user filetype ""]
}
} else {
#fall back to system association and ftype
if {![catch {registry get [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes $lext] "\\"] ""} ftype]} {
#ftype is the file type associated with the extension, e.g "Python.File"
#we then need to look up the command associated with this file type under the same path but with the file type instead of the extension and with the additional sub-path shell\open\command
#special case .cpl cplfile
if {$ftype eq "cplfile"} {
set openverb "cplopen"
}
set verbinfo [ftype $ftype]
if {[dict exists $verbinfo $openverb]} {
set ftypeinfo [dict get $verbinfo $openverb]
return [dict set ftypeinfo filetype $ftype]
} else {
return [dict create type "notfound" value "" scope system filetype $ftype]
}
} else {
return [dict create type "notfound" value "" scope "" filetype ""]
}
}
#shouldn't get here - there is a return in each branch above.
return "no-result"
}
# %1 - standard placeholder for the first file parameter.
# %L (or %l) - Long File Name form of the path.
# %* - replaces with all parameters passed to the command. (ie not including the command itself)
# %W (or %w) - working directory.
# other placeholders that we won't handle for now:
# %I (or %i) - handle to an Item ID List (IDList)
# e.g for filetype ArchiveFolder (associated with .7z, .gz, .CPIO etc) we have "%SystemRoot%\Explorer.exe" /idlist,%I,%L
# %D (or %d) - Desktop absolute parsing name of the first parameter
# (for items that don't have file system paths) will be the same as %1 for file system items.
#todo - we need to substitute other placeholders such as %SystemRoot% etc. (case insensitively)
# - but only if the type of the key is expand_sz.
#These are environment variables that can be used in the command string.
#when type is sz - only the single letter placeholders are substituted, and environment variables are not substituted.
#when type is expand_sz - the single letter placeholders are substituted, and environment variables are also substituted
#(case insensitively) - but only if they are in the form %VAR%
#A matching environment variable will take precedence over a single letter placeholder if there is a conflict,
#e.g if the string is %1% and there is an environment variable named "1" then it will be substituted instead of the %1 placeholder.
#a non-existant environment variable will not be substituted and it's wrapping % characters will not be included in the output.
#similarly a single letter placeholder that is not recognised, such as %x, will result in only the x being included in the output without the leading % character.
#todo - tests for shell_command_as_tcl_list with various combinations of placeholders, quoted and unquoted,
#and with both reg_sz and reg_expand_sz types, and with various edge cases such as missing arguments, extra arguments,
#empty arguments, unrecognized placeholders, invalid env var references etc.
lappend PUNKARGS [list {
@id -id "::punk::auto_exec::shell_command_as_tcl_list"
@cmd -name "punk::auto_exec::shell_command_as_tcl_list" -help\
{}
@opts
-type -type string -default sz -choices {sz expand_sz} -help\
"The type of the registry value containing the command string, which determines how
environment variables are substituted. sz means environment variables will not
be substituted, and expand_sz means environment variables will be substituted
if they are in the form %VAR% (case insensitively) and will take precedence over
single letter placeholders if there is a conflict.
In either case, the single letter placeholders will be substituted as follows:
%1 - standard placeholder for the first file parameter.
%L (or %l) - Long File Name form of the path.
%* - replaced with all subsequent parameters passed to the command.
(but not including the script name itself)
%W (or %w) - working directory."
-workingdir -type string -default "" -help\
"The working directory to substitute for the %W (or %w) placeholder."
@values -min 1 -max -1
commandspec -type string -help\
"The command string to process, which can contain placeholders like %1 for the file name,
and a list of arguments to substitute for the placeholders. The command string is typically
obtained from the registry for a file type association, and the arguments are typically the
file name and other parameters to substitute into the command string."
arg -type any -multiple 1 -optional 1 -help\
{One or more arguments to substitute for the placeholders in the command string.
The first argument (often a script or document path) will be substituted for %1,
the second argument will be substituted for %2, and so on. If the command string
contains a %* placeholder, then all of the arguments will be substituted for that
placeholder starting from %2.
If there are more placeholders than arguments, then the extra placeholders will be
substituted with empty string.
If missing arguments are specified in the commandspec as quoted strings, eg "%3" then
corresponding empty strings as separate arguments will be included in the output.}
}]
proc shell_command_as_tcl_list {args} {
set argd [punk::args::parse $args withid ::punk::auto_exec::shell_command_as_tcl_list]
lassign [dict values $argd] _leaders opts values received
set type [dict get $opts -type]
set workingdir [dict get $opts -workingdir]
set commandspec [dict get $values commandspec]
if {[dict exists $received arg]} {
set arglist [dict get $values arg]
} else {
set arglist [list]
}
set result [list]
set quoted [list 0] ;#track whether the current chunk is quoted or not. Only the last item is relevant.
set chars [split [string trim $commandspec] ""]
set in_quote 0
set current_chunk {}
set new_chunk 1
set got_placeholder 0
for {set i 0} {$i < [llength $chars]} {incr i} {
set char [lindex $chars $i]
if {$in_quote} {
if {$char eq "\""} {
if {$got_placeholder} {
#wasn't a valid placeholder - % not emitted.
set got_placeholder 0
}
#The windows implementation doesn't seem to close off a chunk just because it encounters a closing quote,
#so we don't do that either.
#The closing quote just affects whether the next space will terminate the chunk or not.
set in_quote 0
} else {
if {$char eq "%"} {
#we do not handle the trailing % of an env var such as %VAR% here
#- as we scan for that in the default case of the switch below.
if {$got_placeholder} {
#this is a % escaped by doubling up ie a literal % in the output
append current_chunk "%"
set got_placeholder 0
} else {
set got_placeholder 1
}
} elseif {$got_placeholder} {
if {$type eq "expand_sz"} {
#we scan for environment variables in the form %VAR% (case insensitively) and substitute them if found, taking precedence over single letter placeholders if there is a conflict.
#a non-existant environment variable will not be substituted and it's wrapping % characters will not be included in the output.
set env_var_name $char
for {set j [expr {$i+1}]} {$j < [llength $chars]} {incr j} {
set next_char [lindex $chars $j]
if {$next_char in {" " \t}} {
#end of env var name - we don't expect to see spaces within environment variable names,
#treat space as terminator indicating failure to match.
break
} elseif {$next_char eq "\""} {
#end of env var name - we don't expect to see quotes within environment variable names,
#treat quote as terminator indicating failure to match.
break
} elseif {$next_char eq "%"} {
#end of *possible* env var name
break
} else {
append env_var_name $next_char
}
}
if {$next_char eq "%"} {
#we found a closing % character, so we have a possible env var name between the two % characters.
#we will substitute the env var value if it exists,
# or the value of any single letter placeholder if there is a match,
# or the original string between the two % characters if there is no match for either an env var or a single letter placeholder.
if {[info exists ::env($env_var_name)]} {
append current_chunk $::env($env_var_name)
set got_placeholder 0
set i $j ;#advance past the env var name and closing % character for next iteration of main loop
continue
}
} else {
#we didn't find a closing % character, so this isn't a valid env var reference.
set env_var_name ""
#fall through to single % placeholder handling below,
#which will treat the first character after the % as a single letter placeholder and include the rest of the string as literal characters in the output.
}
}
switch -- $char {
1 - L - l {
append current_chunk [lindex $arglist 0]
}
2 - 3 - 4 - 5 - 6 - 7 - 8 - 9 {
append current_chunk [lindex $arglist $char-1]
}
D - d {
#review
#we don't have a way to represent an Item ID List (IDList) in our API, so we'll just substitute the same value as %1 for now,
#which is what windows seems to do for file system items that have file paths.
append current_chunk [lindex $arglist 0]
}
I - i {
#we don't have a way to represent an Item ID List (IDList) in our API, so we'll just substitute something for now.
#format seems to be 9 and 5 digits :nnnnnnnnn:nnnnn
#REVIEW! unknown consequences!
append current_chunk ":000000000:00000"
}
"*" {
append current_chunk [lrange $arglist 1 end]
}
W - w {
append current_chunk $workingdir
}
default {
if {$type eq "expand_sz"} {
#if expand_sz we would have already scanned for environment variables in the form %VAR% and substituted them if found,
#taking precedence over single letter placeholders if there is a conflict.
append current_chunk "$char" [string range $env_var_name 1 end] ;#if env_var_name is empty string then this will just append the single char after the % as a literal,
#which is the correct behaviour for unrecognized placeholders such as %x and for invalid env var references such as %NONEXISTANT%
} else {
append current_chunk $char
}
}
}
set got_placeholder 0
} else {
append current_chunk $char
}
}
} else {
#NOT in quoted string
if {$char eq "\""} {
if {$got_placeholder} {
append current_chunk "%" ;#wasn't a valid placeholder char, so we need to add the % that we stripped off back into the output
set got_placeholder 0
}
set in_quote 1
set new_chunk 0
lset quoted end 1 ;#we'll mark as quoted if any quote found in it - review
} elseif {$char in [list " " \t ]} {
#we don't expect to see tabs or other whitespace characters as separators in the command string, but we'll treat tab the same as spaces just in case.
if {$got_placeholder} {
#wasn't a valid placeholder char. The % is stripped and not included in the output.
set got_placeholder 0
}
#space terminates an unquoted chunk, so we add it to the result list and start a new chunk.
#we also need to ensure that consecutive spaces are treated as a single separator and don't result in empty items in the output list,
if {!$new_chunk} {
if {$current_chunk ne "" || $current_chunk eq "" && [lindex $quoted end]} {
#we add the current chunk to the result list if it's not empty, or if it is empty but is quoted (because in that case we want to preserve it as an empty argument).
lappend result $current_chunk
lappend quoted 0
set current_chunk {}
}
set new_chunk 1
}
for {set j [expr {$i+1}]} {$j < [llength $chars]} {incr j} {
if {[lindex $chars $j] in {" " \t}} {
incr i
} else {
break
}
}
} else {
if {$got_placeholder} {
if {$type eq "expand_sz"} {
#we scan for environment variables in the form %VAR% (case insensitively) and substitute them if found, taking precedence over single letter placeholders if there is a conflict.
#a non-existant environment variable will not be substituted and it's wrapping % characters will not be included in the output.
set env_var_name $char
for {set j [expr {$i+1}]} {$j < [llength $chars]} {incr j} {
set next_char [lindex $chars $j]
if {$next_char in {" " \t}} {
#end of env var name - we don't expect to see spaces within environment variable names,
#treat space as terminator indicating failure to match.
break
} elseif {$next_char eq "\""} {
#end of env var name - we don't expect to see quotes within environment variable names,
#treat quote as terminator indicating failure to match.
break
} elseif {$next_char eq "%"} {
#end of *possible* env var name
break
} else {
append env_var_name $next_char
}
}
if {$next_char eq "%"} {
#we found a closing % character, so we have a possible env var name between the two % characters.
#we will substitute the env var value if it exists,
# or the value of any single letter placeholder if there is a match,
# or the original string between the two % characters if there is no match for either an env var or a single letter placeholder.
if {[info exists ::env($env_var_name)]} {
append current_chunk $::env($env_var_name)
set got_placeholder 0
set i $j ;#advance past the env var name and closing % character for next iteration of main loop
continue
}
} else {
#we didn't find a closing % character, so this isn't a valid env var reference.
set env_var_name ""
#fall through to single % placeholder handling below,
#which will treat the first character after the % as a single letter placeholder and include the rest of the string as literal characters in the output.
}
}
switch -- $char {
"%" {
append current_chunk "%"
set got_placeholder 1
continue
}
1 -
L - l {
#append current_chunk [lindex $arglist 0]
set append_value [string trim [lindex $arglist 0]]
foreach ch [split $append_value ""] {
if {$ch eq " "} {
#we need to split on spaces within arguments as well, because unquoted %1 can expand to multiple arguments if the first argument contains spaces, and we need to preserve the correct splitting of arguments in that case.
#e.g if %1 is substituted with "C:\My Documents\file.txt" then we need to split that into three items in the output list: "C:\My", "Documents\file.txt"
if {$current_chunk ne ""} {
lappend result $current_chunk
lappend quoted 0
set current_chunk {}
}
set new_chunk 1
} else {
append current_chunk $ch
set new_chunk 0
}
}
}
2 - 3 - 4 - 5 - 6 - 7 - 8 - 9 {
append current_chunk [lindex $arglist $char-1]
}
D - d {
#review
#we don't have a way to represent an Item ID List (IDList) in our API, so we'll just substitute the same value as %1 for now,
#which is what windows seems to do for file system items that have file paths.
append current_chunk [lindex $arglist 0]
}
I - i {
#we don't have a way to represent an Item ID List (IDList) in our API, so we'll just substitute something for now.
#format seems to be 9 and 5 digits :nnnnnnnnn:nnnnn
#REVIEW! unknown consequences!
append current_chunk ":000000000:00000"
}
"*" {
#the window implementation of unquoted %* always seems to emit the previous chunk before the %* placeholder as a separate item
#in the command line, even if there is no space between them, so we will do the same.
#Note that a following string (quoted or not) immediately after the %* will be appended to the last item in the output list rather than being a separate item,
#which is also consistent with the microsoft implementation.
if {!$new_chunk} {
if {$current_chunk ne "" || $current_chunk eq "" && [lindex $quoted end]} {
lappend result $current_chunk
lappend quoted 0
set current_chunk {}
}
}
lappend result {*}[lrange $arglist 1 end]
lappend quoted {*}[lrepeat [llength $arglist] 0]
}
W - w {
append current_chunk $workingdir
}
default {
if {$type eq "expand_sz"} {
#if expand_sz we would have already scanned for environment variables in the form %VAR% and substituted them if found,
#taking precedence over single letter placeholders if there is a conflict.
append current_chunk "$char" [string range $env_var_name 1 end] ;#if env_var_name is empty string then this will just append the single char after the % as a literal,
#which is the correct behaviour for unrecognized placeholders such as %x and for invalid env var references such as %NONEXISTANT%
} else {
append current_chunk $char
}
}
}
set got_placeholder 0
set new_chunk 0
} else {
if {$char eq "%"} {
set got_placeholder 1
} else {
append current_chunk $char
set new_chunk 0
}
}
}
}
}
if {$current_chunk ne "" || $current_chunk eq "" && [lindex $quoted end]} {
#review - edge case - if the command string ends with a space then we would have already added the last chunk to the result list and started a new chunk, so we shouldn't add an empty chunk to the result list in that case.
#however if the last chunk was quoted and is empty because the command string ends with a placeholder that is substituted with an empty string, then we should add the empty chunk to the result list.
lappend result $current_chunk
}
return $result
}
namespace eval punk::auto_exec::system {
proc assoc_get_info {ext} {
set lext [string tolower $ext]
set result [dict create system "" user ""]
set user_assoc_path [join [list HKEY_CURRENT_USER Software Microsoft Windows CurrentVersion Explorer FileExts $lext UserChoice] "\\"]
if {![catch {registry get $user_assoc_path Progid} user_choice]} {
dict set result user $user_choice
}
if {![catch {registry get [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes $lext] "\\"] ""} ftype]} {
dict set result system $ftype
}
return $result
}
}
lappend PUNKARGS [list {
@id -id "::punk::auto_exec::assoc"
@cmd -name "punk::auto_exec::assoc"\
-summary\
"Look up the associated file type (system and user) for a file extension"\
-help\
"Get the associated file type for a file extension by looking up the user-specific
file type in the registry and falling back to the system file type if no
user-specific association is found.
Returns a dict with keys ${$I}system${$NI} and ${$I}user${$NI}.
One or more of the key values may be empty string if there is no defined
file type for the extension.
This is somewhat like the windows 'assoc' command except that the windows command
only looks up the system association and does not take into account any user-specific
overrides.
This function returns both values in the result dictionary if they are available."
@opts
@values -min 0 -max 1
ext -type string -default "" -optional true -help\
"File extension to look up, e.g .txt or .py"
}]
proc assoc {args} {
package require registry
set argd [punk::args::parse $args withid ::punk::auto_exec::assoc]
set ext [dict get $argd values ext]
if {$ext ne ""} {
return [punk::auto_exec::system::assoc_get_info $ext]
} else {
#look up all associated ftypes
set user_ftypes [registry keys {HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts} .*]
set system_ftypes [registry keys {HKEY_LOCAL_MACHINE\SOFTWARE\Classes} .*]
set all_ftypes [lsort -unique [concat $user_ftypes $system_ftypes]]
set results [list]
foreach ftype $all_ftypes {
dict set results $ftype [punk::auto_exec::system::assoc_get_info $ftype]
}
return $results
}
}
namespace eval argdoc {
lappend PUNKARGS [list {
@id -id "::punk::auto_exec::ftype"
@cmd -name "punk::auto_exec::ftype"\
-summary\
"Look up shell verb command values from windows file type."\
-help\
"Get the associated shell verb information (such as open) for a file type by looking up the user-specific
association in the registry and falling back to the system association if no
user-specific association is found.
Returns a dict of dicts with toplevel keys for each shell verb (e.g open, runas) and values that are dicts with keys
${$I}type${$NI} and ${$I}value${$NI} and ${$I}scope${$NI}, where
type is determined from the registry value type (e.g sz or expand_sz)
string is the raw command string from the registry
scope is either "user" or "system" depending on whether the value was found in the user-specific registry keys or
the system registry keys.
This is somewhat like the windows 'ftype' command except that the windows command only looks for the 'open' verb and
only looks up the system association and does not take into account any user-specific
overrides.
The file type can be looked up using the ${$B}assoc${$N} function in this package.
The command string can contain placeholders like \"%1\" for the file name, and environment variables
in the form %VAR% (case insensitive) if the registry value type is reg_expand_sz (expand_sz),
which will be substituted by the ${$B}shell_command_as_tcl_list${$N} function when processing the command
string into a Tcl list of command and arguments with placeholders substituted."
@opts
@values -min 0 -max 1
filetype -type string -default "" -optional true -help\
"File type associated with a file extension, e.g Python.File.
This can be looked up using the 'assoc' function in this package."
}]
}
#proc ftype {filetype} {
# package require registry
# if {$filetype eq "cplfile"} {
# #special case for cplfile (associated with .cpl files) which doesn't follow the usual pattern of having the command string under shell\open\command,
# #but instead has it under HKEY_LOCAL_MACHINE\SOFTWARE\Classes\cplfile\shell\cplopen\command.
# #There doesn't seem to be any user-specific override for this file type
# #- but we will check for one under HKEY_CURRENT_USER\Software\Classes\cplfile\shell\cplopen\command anyway for consistency with the way we check
# #for user-specific overrides for other file types.
# set key [join [list HKEY_CURRENT_USER Software Classes cplfile shell cplopen command] "\\"]
# } else {
# set key [join [list HKEY_CURRENT_USER Software Classes $filetype shell open command] "\\"]
# }
# if {![catch {registry get $key ""} raw_assoc]} {
# set tp [registry type $key ""]
# return [dict create open [dict create type $tp string $raw_assoc]]
# } else {
# #e.g Python.File
# if {$filetype eq "cplfile"} {
# set key [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes cplfile shell cplopen command] "\\"]
# } else {
# set key [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes $filetype shell open command] "\\"]
# }
# if {![catch {registry get $key ""} raw_assoc]} {
# set tp [registry type $key ""]
# return [dict create type $tp string $raw_assoc]
# } else {
# return [dict create type "" string ""] ;#no association found
# }
# }
#}
proc ftype {filetype} {
package require registry
set resultdict [dict create]
#e.g Python.File
set shellpath [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes $filetype shell] "\\"]
if {![catch {registry keys $shellpath *} shellverbs]} {
foreach verb $shellverbs {
set commandkey [join [list $shellpath $verb command] "\\"]
if {![catch {registry get $commandkey ""} cmdstring]} {
#registry queryies are case insensitive but some are cased differently e.g Open vs open.
#when using the verb as a key in the output dict, we need to normalize so that it is useful for lookups. We'll use lowercase for that.
set verb [string tolower $verb]
set tp [registry type $commandkey ""]
dict set resultdict $verb [dict create type $tp value $cmdstring scope system]
}
}
}
#allow user-specific verbs to be overridden.
set shellpath [join [list HKEY_CURRENT_USER Software Classes $filetype shell] "\\"]
if {![catch {registry keys $shellpath *} shellverbs]} {
foreach verb $shellverbs {
set commandkey [join [list $shellpath $verb command] "\\"]
if {![catch {registry get $commandkey ""} cmdstring]} {
set verb [string tolower $verb]
set tp [registry type $commandkey ""]
dict set resultdict $verb [dict create type $tp value $cmdstring scope user]
}
}
}
return $resultdict
}
}
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
# Secondary API namespace
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
tcl::namespace::eval punk::auto_exec::lib {
tcl::namespace::export {[a-z]*} ;# Convention: export all lowercase
tcl::namespace::path [tcl::namespace::parent]
}
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
#tcl::namespace::eval punk::auto_exec::system {
#}
# == === === === === === === === === === === === === === ===
# Sample 'about' function with punk::args documentation
# == === === === === === === === === === === === === === ===
tcl::namespace::eval punk::auto_exec {
tcl::namespace::export {[a-z]*} ;# Convention: export all lowercase
variable PUNKARGS
variable PUNKARGS_aliases
lappend PUNKARGS [list {
@id -id "(package)punk::auto_exec"
@package -name "punk::auto_exec" -help\
"Package
Description"
}]
namespace eval argdoc {
#namespace for custom argument documentation
proc package_name {} {
return punk::auto_exec
}
proc about_topics {} {
#info commands results are returned in an arbitrary order (like array keys)
set topic_funs [info commands [namespace current]::get_topic_*]
set about_topics [list]
foreach f $topic_funs {
set tail [namespace tail $f]
lappend about_topics [string range $tail [string length get_topic_] end]
}
#Adjust this function or 'default_topics' if a different order is required
return [lsort $about_topics]
}
proc default_topics {} {return [list Description *]}
# -------------------------------------------------------------
# get_topic_ functions add more to auto-include in about topics
# -------------------------------------------------------------
proc get_topic_Description {} {
punk::args::lib::tstr [string trim {
package punk::auto_exec
description to come..
} \n]
}
proc get_topic_License {} {
return "<unspecified>"
}
proc get_topic_Version {} {
return "$::punk::auto_exec::version"
}
proc get_topic_Contributors {} {
set authors {{<julian@precisium.com.au> {Julian Noble}}}
set contributors ""
foreach a $authors {
append contributors $a \n
}
if {[string index $contributors end] eq "\n"} {
set contributors [string range $contributors 0 end-1]
}
return $contributors
}
proc get_topic_custom-topic {} {
punk::args::lib::tstr -return string {
A custom
topic
etc
}
}
# -------------------------------------------------------------
}
# we re-use the argument definition from punk::args::standard_about and override some items
set overrides [dict create]
dict set overrides @id -id "::punk::auto_exec::about"
dict set overrides @cmd -name "punk::auto_exec::about"
dict set overrides @cmd -help [string trim [punk::args::lib::tstr {
About punk::auto_exec
}] \n]
dict set overrides topic -choices [list {*}[punk::auto_exec::argdoc::about_topics] *]
dict set overrides topic -choicerestricted 1
dict set overrides topic -default [punk::auto_exec::argdoc::default_topics] ;#if -default is present 'topic' will always appear in parsed 'values' dict
set newdef [punk::args::resolved_def -antiglobs -package_about_namespace -override $overrides ::punk::args::package::standard_about *]
lappend PUNKARGS [list $newdef]
proc about {args} {
package require punk::args
#standard_about accepts additional choices for topic - but we need to normalize any abbreviations to full topic name before passing on
set argd [punk::args::parse $args withid ::punk::auto_exec::about]
lassign [dict values $argd] _leaders opts values _received
punk::args::package::standard_about -package_about_namespace ::punk::auto_exec::argdoc {*}$opts {*}[dict get $values topic]
}
}
# end of sample 'about' function
# == === === === === === === === === === === === === === ===
# -----------------------------------------------------------------------------
# register namespace(s) to have PUNKARGS,PUNKARGS_aliases variables checked
# -----------------------------------------------------------------------------
# variable PUNKARGS
# variable PUNKARGS_aliases
namespace eval ::punk::args::register {
#use fully qualified so 8.6 doesn't find existing var in global namespace
lappend ::punk::args::register::NAMESPACES ::punk::auto_exec
}
# -----------------------------------------------------------------------------
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
## Ready
package provide punk::auto_exec [tcl::namespace::eval punk::auto_exec {
variable pkg punk::auto_exec
variable version
set version 0.1.0
}]
return

353
src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/punk/char-0.1.0.tm

@ -237,6 +237,88 @@ tcl::namespace::eval punk::char {
return $out
}
namespace eval argdoc {
variable PUNKARGS
lappend PUNKARGS [list {
@id -id ::punk::char::ascii2doublewide
@cmd -name punk::char::ascii2doublewide\
-summary\
"string to double-wide unicode char"\
-help\
"Convert parts of string in the ascii range 21 to 7E to corresponding chars from the unicode
'Halfwidth and Fullwidth Forms' block.
The space character (0x20) is converted to the 'IDEOGRAPHIC SPACE' character (0x3000)
Control chars and chars outside the ascii range are not converted and passed through as-is."
@values -min 1 -max 1
str -type string
}]
}
proc ascii2doublewide {str} {
#set base to \UFEE0 and add ascii value of char to get the double-wide character in the 'Halfwidth and Fullwidth Forms' block of unicode
set out ""
foreach ch [split $str ""] {
set decval [scan $ch %c]
if {$decval == 0x20} {
#space char maps to ideographic space rather than the fullwidth space char - as the fullwidth space char is often rendered as a narrow space rather than a wide one - and the ideographic space is more likely to be rendered as a wide space.
set decval 0x3000
} elseif {$decval < 0x21 || $decval > 0x7E} {
#chars outside the ascii range are not converted - pass through as-is
set decval [scan $ch %c]
} else {
set decval [expr {0xFEE0 + $decval}]
}
append out [format %c $decval]
}
return $out
}
proc doublewide2ascii {str} {
set out ""
foreach ch [split $str ""] {
set decval [scan $ch %c]
if {$decval == 0x3000} {
#ideographic space maps to ascii space char
set decval 0x20
append out \U3000
} elseif {$decval < 0xFF01 || $decval > 0xFF5E} {
#chars outside the part of the 'Halfwidth and Fullwidth Forms' block that corresponds to ascii are not converted - pass through as-is
append out $ch
} else {
set decval [expr {$decval - 0xFEE0}]
append out [format %c $decval]
}
}
return $out
}
proc ascii2NTFSPUA {str} {
#set base to \uF000 and add ascii value of char to get the PUA character in the 'Private Use Area' block of unicode
set out ""
foreach ch [split $str ""] {
set decval [scan $ch %c]
if {$decval <= 0x7F} {
append out [format %c [expr {0xF000 + $decval}]]
} else {
#chars outside the ascii range are not converted - pass through as-is
append out $ch
}
}
return $out
}
proc NTFSPUA2ascii {str} {
set out ""
foreach ch [split $str ""] {
set decval [scan $ch %c]
if {$decval >= 0xF000 && $decval <= 0xF0FF} {
set decval [expr {$decval - 0xF000}]
append out [format %c $decval]
} else {
#chars outside the 'Private Use Area' block that corresponds to ascii are not converted - pass through as-is
append out $ch
}
}
return $out
}
proc symbol {} {
tailcall page symbol
@ -2573,11 +2655,269 @@ tcl::namespace::eval punk::char {
return [tcl::string::map $map $str]
}
#todo - lookup from unicode tables
#ISO-3166-1 alpha-2 country codes to flag emojis
#These can change over time.
#todo - lookup from official sources - and update as needed
#
variable flags [dict create\
AC \U1F1E6\U1F1E8\
AD \U1F1E6\U1F1E9\
AE \U1F1E6\U1F1EA\
AF \U1F1E6\U1F1EB\
AG \U1F1E6\U1F1EC\
AI \U1F1E6\U1F1EE\
AL \U1F1E6\U1F1F1\
AM \U1F1E6\U1F1F2\
AO \U1F1E6\U1F1F4\
AQ \U1F1E6\U1F1F6\
AR \U1F1E6\U1F1F7\
AS \U1F1E6\U1F1F8\
AT \U1F1E6\U1F1F9\
AU \U1F1E6\U1F1FA\
AW \U1F1E6\U1F1FC\
AX \U1F1E6\U1F1FD\
AZ \U1F1E6\U1F1FF\
BA \U1F1E7\U1F1E6\
BB \U1F1E7\U1F1E7\
BD \U1F1E7\U1F1E9\
BE \U1F1E7\U1F1EA\
BF \U1F1E7\U1F1EB\
BG \U1F1E7\U1F1EC\
BH \U1F1E7\U1F1ED\
BI \U1F1E7\U1F1EE\
BJ \U1F1E7\U1F1EF\
BL \U1F1E7\U1F1F1\
BM \U1F1E7\U1F1F2\
BN \U1F1E7\U1F1F3\
BO \U1F1E7\U1F1F4\
BQ \U1F1E7\U1F1F6\
BR \U1F1E7\U1F1F7\
BS \U1F1E7\U1F1F8\
BT \U1F1E7\U1F1F9\
BV \U1F1E7\U1F1FB\
BW \U1F1E7\U1F1FC\
BY \U1F1E7\U1F1FE\
BZ \U1F1E7\U1F1FF\
CA \U1F1E8\U1F1E6\
CC \U1F1E8\U1F1E8\
CD \U1F1E8\U1F1E9\
CF \U1F1E8\U1F1Eb\
CG \U1F1E8\U1F1Ec\
CH \U1F1E8\U1F1ED\
CI \U1F1E8\U1F1EE\
CK \U1F1E8\U1F1F0\
CL \U1F1E8\U1F1F1\
CM \U1F1E8\U1F1F2\
CN \U1F1E8\U1F1F3\
CO \U1F1E8\U1F1F4\
CP \U1F1E8\U1F1F5\
CR \U1F1E8\U1F1F7\
CS \U1F1E8\U1F1F8\
CU \U1F1E8\U1F1FA\
CV \U1F1E8\U1F1FB\
CW \U1F1E8\U1F1FC\
CX \U1F1E8\U1F1FE\
CY \U1F1E8\U1F1FE\
CZ \U1F1E8\U1F1FF\
DE \U1F1E9\U1F1EA\
DG \U1F1E9\U1F1EC\
DJ \U1F1E9\U1F1EF\
DK \U1F1E9\U1F1F0\
DM \U1F1E9\U1F1F2\
DO \U1F1E9\U1F1F4\
DZ \U1F1E9\U1F1FF\
EA \U1F1EA\U1F1E6\
EC \U1F1EA\U1F1E8\
EE \U1F1EA\U1F1EA\
EG \U1F1EA\U1F1EC\
EH \U1F1EA\U1F1ED\
ER \U1F1EA\U1F1F7\
ES \U1F1EA\U1F1F8\
ET \U1F1EA\U1F1F9\
EU \U1F1EA\U1F1FA\
FI \U1F1EB\U1F1EE\
FJ \U1F1EB\U1F1EF\
FK \U1F1EB\U1F1F0\
FM \U1F1EB\U1F1F2\
FO \U1F1EB\U1F1F4\
FR \U1F1EB\U1F1F7\
GA \U1F1EC\U1F1E6\
GB \U1F1EC\U1F1E7\
GD \U1F1EC\U1F1E9\
GE \U1F1EC\U1F1EA\
GF \U1F1EC\U1F1EB\
GG \U1F1EC\U1F1EC\
GH \U1F1EC\U1F1ED\
GI \U1F1EC\U1F1EE\
GL \U1F1EC\U1F1F1\
GM \U1F1EC\U1F1F2\
GN \U1F1EC\U1F1F3\
GP \U1F1EC\U1F1F5\
GQ \U1F1EC\U1F1F6\
GR \U1F1EC\U1F1F7\
GS \U1F1EC\U1F1F8\
GT \U1F1EC\U1F1F9\
GU \U1F1EC\U1F1FA\
GW \U1F1EC\U1F1FC\
GY \U1F1EC\U1F1FE\
HK \U1F1ED\U1F1F0\
HM \U1F1ED\U1F1F2\
HN \U1F1ED\U1F1F3\
HR \U1F1ED\U1F1F7\
HT \U1F1ED\U1F1F9\
HU \U1F1ED\U1F1FA\
IC \U1F1EE\U1F1E8\
ID \U1F1EE\U1F1E9\
IE \U1F1EE\U1F1EA\
IL \U1F1EE\U1F1F1\
IM \U1F1EE\U1F1F2\
IN \U1F1EE\U1F1F3\
IO \U1F1EE\U1F1F4\
IQ \U1F1EE\U1F1F6\
IR \U1F1EE\U1F1F7\
IS \U1F1EE\U1F1F8\
IT \U1F1EE\U1F1F9\
JE \U1F1EF\U1F1EA\
JM \U1F1EF\U1F1F2\
JO \U1F1EF\U1F1F4\
JP \U1F1EF\U1F1F5\
KE \U1F1F0\U1F1EA\
KG \U1F1F0\U1F1EC\
KH \U1F1F0\U1F1ED\
KI \U1F1F0\U1F1EE\
KM \U1F1F0\U1F1F2\
KN \U1F1F0\U1F1F3\
KP \U1F1F0\U1F1F5\
KR \U1F1F0\U1F1F7\
KW \U1F1F0\U1F1FC\
KY \U1F1F0\U1F1FE\
KZ \U1F1F0\U1F1FF\
LA \U1F1F1\U1F1E6\
LB \U1F1F1\U1F1E7\
LC \U1F1F1\U1F1E8\
LI \U1F1F1\U1F1EE\
LK \U1F1F1\U1F1F0\
LR \U1F1F1\U1F1F7\
LS \U1F1F1\U1F1F8\
LT \U1F1F1\U1F1F9\
LU \U1F1F1\U1F1FA\
LV \U1F1F1\U1F1FB\
LY \U1F1F1\U1F1FE\
MA \U1F1F2\U1F1E6\
MC \U1F1F2\U1F1E8\
MD \U1F1F2\U1F1E9\
ME \U1F1F2\U1F1EA\
MF \U1F1F2\U1F1EB\
MG \U1F1F2\U1F1EC\
MH \U1F1F2\U1F1ED\
MK \U1F1F2\U1F1F0\
ML \U1F1F2\U1F1F1\
MM \U1F1F2\U1F1F2\
MN \U1F1F2\U1F1F3\
MO \U1F1F2\U1F1F4\
MP \U1F1F2\U1F1F5\
MQ \U1F1F2\U1F1F6\
MR \U1F1F2\U1F1F7\
MS \U1F1F2\U1F1F8\
MT \U1F1F2\U1F1F9\
MU \U1F1F2\U1F1FA\
MV \U1F1F2\U1F1FB\
MW \U1F1F2\U1F1FC\
MX \U1F1F2\U1F1FD\
MY \U1F1F2\U1F1FE\
MZ \U1F1F2\U1F1FF\
NA \U1F1F3\U1F1E6\
NC \U1F1F3\U1F1E8\
NE \U1F1F3\U1F1EA\
NF \U1F1F3\U1F1EB\
NG \U1F1F3\U1F1EC\
NI \U1F1F3\U1F1EE\
NL \U1F1F3\U1F1F1\
NO \U1F1F3\U1F1F4\
NP \U1F1F3\U1F1F5\
NR \U1F1F3\U1F1F7\
NU \U1F1F3\U1F1F8\
NZ \U1F1F3\U1F1ff\
OM \U1F1F4\U1F1F2\
PA \U1F1F5\U1F1E6\
PE \U1F1F5\U1F1EA\
PF \U1F1F5\U1F1EB\
PG \U1F1F5\U1F1EC\
PH \U1F1F5\U1F1ED\
PK \U1F1F5\U1F1ED\
PL \U1F1F5\U1F1F1\
PM \U1F1F5\U1F1F3\
PN \U1F1F5\U1F1F3\
PR \U1F1F5\U1F1F7\
PS \U1F1F5\U1F1F8\
PT \U1F1F5\U1F1F9\
PW \U1F1F5\U1F1FC\
PY \U1F1F5\U1F1FE\
QA \U1F1F6\U1F1E6\
RE \U1F1F7\U1F1EA\
RO \U1F1F7\U1F1F4\
RS \U1F1F7\U1F1F7\
RU \U1F1F7\U1F1FA\
RW \U1F1F7\U1F1FC\
SA \U1F1F8\U1F1E6\
SB \U1F1F8\U1F1E7\
SC \U1F1F8\U1F1E8\
SD \U1F1F8\U1F1E9\
SE \U1F1F8\U1F1EA\
SG \U1F1F8\U1F1EC\
SH \U1F1F8\U1F1ED\
SI \U1F1F8\U1F1EE\
SJ \U1F1F8\U1F1EF\
SK \U1F1F8\U1F1F0\
SL \U1F1F8\U1F1F1\
SM \U1F1F8\U1F1F2\
SN \U1F1F8\U1F1F3\
SO \U1F1F8\U1F1F4\
SR \U1F1F8\U1F1F7\
SS \U1F1F8\U1F1F8\
ST \U1F1F8\U1F1F9\
SV \U1F1F8\U1F1F7\
SX \U1F1F8\U1F1F8\
SY \U1F1F8\U1F1FE\
SZ \U1F1F8\U1F1FF\
TA \U1F1F9\U1F1E6\
TC \U1F1F9\U1F1E8\
TD \U1F1F9\U1F1E9\
TF \U1F1F9\U1F1EB\
TG \U1F1F9\U1F1EC\
TH \U1F1F9\U1F1ED\
TJ \U1F1F9\U1F1EF\
TK \U1F1F9\U1F1F0\
TL \U1F1F9\U1F1F1\
TM \U1F1F9\U1F1F2\
TN \U1F1F9\U1F1F3\
TO \U1F1F9\U1F1F4\
TR \U1F1F9\U1F1F7\
TT \U1F1F9\U1F1F9\
TV \U1F1F9\U1F1FB\
TW \U1F1F9\U1F1FC\
TZ \U1F1F9\U1F1FF\
UA \U1F1FA\U1F1E6\
UG \U1F1FA\U1F1EC\
UM \U1F1FA\U1F1F2\
US \U1F1FA\U1F1F8\
ZW \U1F1FF\U1F1FC
UY \U1F1FA\U1F1FE\
UZ \U1F1FA\U1F1FF\
VA \U1F1FB\U1F1E6\
VC \U1F1FB\U1F1E8\
VE \U1F1FB\U1F1EA\
VG \U1F1FB\U1F1EC\
VI \U1F1FB\U1F1EE\
VN \U1F1FB\U1F1F3\
VU \U1F1FB\U1F1FA\
WF \U1F1FC\U1F1EB\
WS \U1F1FC\U1F1F8\
XK \U1F1FD\U1F1F3\
YE \U1F1FE\U1F1EA\
YT \U1F1FE\U1F1F9\
ZA \U1F1FF\U1F1E6\
ZM \U1F1FF\U1F1F2\
ZW \U1F1FF\U1F1FC\
]
variable rflags
dict for {k v} $flags {
@ -2825,6 +3165,15 @@ tcl::namespace::eval punk::char::lib {
}
}
# -----------------------------------------------------------------------------
# register namespace(s) to have PUNKARGS,PUNKARGS_aliases variables checked
# -----------------------------------------------------------------------------
# variable PUNKARGS
# variable PUNKARGS_aliases
namespace eval ::punk::args::register {
#use fully qualified so 8.6 doesn't find existing var in global namespace
lappend ::punk::args::register::NAMESPACES ::punk::char
}
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++

909
src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/punk/du-0.1.0.tm

File diff suppressed because it is too large Load Diff

7
src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/punk/mix/commandset/module-0.1.0.tm

@ -158,8 +158,11 @@ namespace eval punk::mix::commandset::module {
If a version is specified in the module argument as well as in -version
the higher version number will be used.
"
-license -default <unspecified>
-author -default <unspecified> -multiple 1
-license -default <unspecified> -help\
"License to be mentioned in the module file. e.g BSD, MIT"
-author -default <unspecified> -multiple 1 -help\
{Author(s) of the module. Multiple authors can be specified by repeating the -author option.
e.g -author {"John Smith" "jsmith@example.com"} -author {"Jane Doe" "jane@example.org"}}
-template -default punk.module
-type -default "[lindex $moduletypes 0]" -choices {$moduletypes}
-force -default 0 -type boolean -help\

399
src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/punk/nav/fs-0.1.0.tm

@ -233,6 +233,11 @@ tcl::namespace::eval punk::nav::fs {
set args [lrange $args 1 end]
}
if {$v eq "/"} {
#directory only listing - we can optimize this by not getting file sizes and times - as these are only used for file listings
#we can't completely skip iterating over files as we want to know if there are any files in the directory - as this affects the display of links/shortcuts that point to directories - but we can skip getting sizes and times for files.
#todo?
}
if {![llength $args]} {
#ls is too slow even over a fairly low-latency network
@ -243,12 +248,12 @@ tcl::namespace::eval punk::nav::fs {
commandstack::basecall cd $VIRTUAL_CWD
}
}
set matchinfo [dirfiles_dict -searchbase $VIRTUAL_CWD -with_times {f d l} -with_sizes {f d l}]
set matchinfo [dirfiles_dict -searchbase $VIRTUAL_CWD -with_times {f d l b c p s} -with_sizes {f d l b c p s}]
} else {
if {[pwd] ne $VIRTUAL_CWD} {
commandstack::basecall cd $VIRTUAL_CWD
}
set matchinfo [dirfiles_dict -searchbase [pwd] -with_times {f d l} -with_sizes {f d l}]
set matchinfo [dirfiles_dict -searchbase [pwd] -with_times {f d l b c p s} -with_sizes {f d l b c p s}]
}
set dircount [llength [dict get $matchinfo dirs]]
set filecount [llength [dict get $matchinfo files]]
@ -621,30 +626,41 @@ tcl::namespace::eval punk::nav::fs {
punk::args::define {
@id -id ::punk::nav::fs::d/new
-nonportable -type none -help\
"Allow creation of directories which may not be portable across platforms.
Use with caution and only when you know what you are doing.
This allows creation of directories with names that may be invalid on some
platforms, or that may have special meanings on some platforms
(e.g reserved device names on windows).
If -nonportable is not supplied, then an error will be raised if any supplied
path is non-portable as defined by punk::winpath::illegalname_test.
Regardless of whether -nonportable is supplied or not, some characters are not
suitable for windows or most other platforms and will be rejected with an error.
An example of this is the null character (\0)."
@cmd -name punk::nav::fs::d/new\
-summary\
"Create directory or directories at the specified path(s)."\
-help\
"This command creates directories at the specified path(s).
If any part of the specified path does not exist, then it will be created as well.
If a specified path already exists, then it will be left as-is and no error will be raised.
A summary line is returned for each created directory, with the full path of the created
directory and a status line indicating the number of dirs and files in the directory if
it already existed (or showing 0 for both if it was just created)."
-nonportable -type none\
-help\
"Allows creation of directories which may not be portable across platforms.
Use with caution and only when you know what you are doing.
This allows creation of directories with names that may be invalid on some
platforms, or that may have special meanings on some platforms
(e.g reserved device names on windows).
If -nonportable is not supplied, then an error will be raised if any supplied
path is non-portable as defined by punk::winpath::illegalname_test.
Regardless of whether -nonportable is supplied or not, some characters are not
suitable for windows or most other platforms and will be rejected with an error.
An example of this is the null character (\0)."
@values -min 1 -max -1 -type string
path -type string -multiple 1 -help\
"Path(s) to create. Can be absolute or relative.
If any path is rejected due to -nonportable or other invalid characters,
or because a parent directory is not writable, then no directories will be created.
If any path is rejected due to -nonportable or other invalid characters,
or because a parent directory is not writable, then no directories will be created.
If a path already exists, then it will be left as-is and no error will be raised.
If a path already exists, then it will be left as-is and no error will be raised.
If despite passing the name tests or writability tests, a directory cannot be
created for some reason (e.g other filesystem error) then an error will be raised
and processing of any remaining paths will be aborted."
If despite passing the name tests or writability tests, a directory cannot be
created for some reason (e.g other filesystem error) then an error will be raised
and processing of any remaining paths will be aborted."
}
#todo - synchronize overall behaviour of d/new with that of n/new (for namespaces)
proc d/new {args} {
@ -718,7 +734,7 @@ tcl::namespace::eval punk::nav::fs {
}
#display summaries of created directories (which may have already existed) by reusing d/ to get info on them.
set query_paths [lmap v $paths $v/*]
set query_paths [lmap v $paths {string cat $v "/*"}]
d/ / {*}$query_paths
}
@ -734,6 +750,10 @@ tcl::namespace::eval punk::nav::fs {
#run a file
#review. On unix the shebang should be able to choose the interpreter.
#on windows the ::env(PATHEXT) and file associations should be able to choose the interpreter. (see punk::auto_execok_better)
#when running using ./script_or_exe.name - the windows file association should be used.
#when running using x/ script_or_exe.name - we want to be able to run scripts even if no file association exists (or it is inappropriate for execution e.g ps1 defaults to open in editor)
proc x/ {args} {
if {![llength $args]} {
set result [d/]
@ -748,11 +768,16 @@ tcl::namespace::eval punk::nav::fs {
lua [list exe lua extensions [list ".lua"]]\
perl [list exe perl extensions [list ".pl"]]\
php [list exe php extensions [list ".php"]]\
ps1 [list exe pwsh extensions [list ".ps1"]]\
]
#todo - allow cofnig to specify arguments for the executable. eg pwsh -noprofile -executionpolicy remotesigned for running powershell scripts.
#this is a bit of a hack - fix
set tcl_extensions [list ".tcl" ".tm" ".kit" ".tk"] ;#todo - load from config
set py_extensions [list ".py"]
set lua_extensions [list ".lua"]
set perl_extensions [list ".pl"]
#set lua_extensions [list ".lua"]
#set perl_extensions [list ".pl"]
#set pwsh_extensions [list ".ps1"]
set script_extensions [list]
set extension_lookup [dict create]
@ -792,14 +817,12 @@ tcl::namespace::eval punk::nav::fs {
set ::argc [llength $newargs]
set ::argv $newargs
tailcall source $path
} elseif {$extlower in $py_extensions} {
set pycmd [auto_execok python]
tailcall {*}$pycmd {*}$args
} elseif {$extlower in $script_extensions} {
set exename [dict get $scriptconfig $scriptlang exe]
set cmd [auto_execok $exename]
tailcall {*}$cmd $args
} else {
#review
set fd [open $path r]
set chunk [read $fd 4000]; close $fd
#consider any commented line near top of file containing 'tcl' as likely to be a tcl script of some sort and attempt to source it.
@ -824,7 +847,11 @@ tcl::namespace::eval punk::nav::fs {
}
}
} else {
puts stderr "No script executable known for this"
if {$scriptfile eq ""} {
puts stderr "script not found"
} else {
puts stderr "No script executable known for files with extension [file extension $scriptfile]. Ensure file has a known extension ($script_extensions) or add a commented hint in the file such as #!/usr/bin/env tclsh"
}
}
}
@ -934,13 +961,96 @@ tcl::namespace::eval punk::nav::fs {
# -searchbase is always passed through - and is only used to construct a location path if a relative searchspec was supplied
punk::args::define {
@id -id ::punk::nav::fs::dirfiles_dict
@cmd -name punk::nav::fs::dirfiles_dict
@cmd -name punk::nav::fs::dirfiles_dict\
-summary\
""\
-help\
"This command performs a directory listing of the specified location and returns the results as a dictionary
containing lists of keys such as files and directories and their properties.
The results are returned as a dictionary with the following structure:
{
location <searched location>
searchbase <searchbase supplied>
dirs {<list of directories matching search>}
vfsmounts {<list of entries that are mount points for virtual filesystems>}
links {<list of links matching search>}
linkinfo {<list of link targets for links matching search, in same order as links list, or 'na'>}
files {<list of files matching search>}
filesizes {<list of file sizes for files matching search, in same order as files list, or 'na'>}
sizes {<dictionary keyed on entry name with sub-dictionary of size information keyed with bytes>}
times {<dictionary keyed on entry name with sub-dictionary of time properties keyed with c a m>}
flaggedhidden {<dictionary keyed on entry name with value of 1 if entry is hidden or 0, for entries matching search, or 'na'>}
flaggedsystem {<dictionary keyed on entry name with value of 1 if entry is a system file or 0, for entries matching search, or 'na'>}
flaggedreadonly {<dictionary keyed on entry name with value of 1 if entry is read-only or 0, for entries matching search, or 'na'>}
altnames {<dictionary keyed on entry name with value of list of alternate names for that entry, or 'na'>}
opts {
-glob <glob pattern used for search>
-filedebug <filedebug supplied>
-patterndebug <patterndebug supplied>
-types <types supplied>
-with_sizes <with_sizes supplied>
-with_times <with_times supplied>
}
debuginfo {
<any debug info that may be useful to caller>
}
errors {
<any errors encountered during search, e.g inaccessible folders etc>
}
nonportable {<dictionary keyed on entry name with value of 1 if entry is non-portable or 0>}
underlayfiles {<list of files that are underlay files for virtual filesystem mounts matching search>}
underlayfilesizes {<list of sizes for underlay files for virtual filesystem mounts matching search, in same order as underlayfiles list, or 'na'>}
timinginfo {
<any timing info that may be useful to caller>
}
}
"
@opts -any 0
-searchbase -default ""
-tailglob -default "\uFFFF"
-searchbase -default ""
-tailglob -default "\uFFFF"
-filedebug -default 0 -type boolean
-patterndebug -default 0 -type boolean
#with_sizes & with_times must accept 0|1|f|d|l where f = files d = dirs l = links (punk::du)
-with_sizes -default "\uFFFF" -type string
-with_times -default "\uFFFF" -type string
-with_sizes -default "\uFFFF" -type list
-with_times -default "\uFFFF" -type list
-link_info -default 1 -type boolean -help "When links are included in results, also include resparseinfo if present."
-portabilitycheck -default 1 -type boolean -help\
"Perform portability checks on entry names and flag non-portable entries in results.
Non-portable entries are those that may not be portable across platforms, for example due
to containing characters that are illegal on some platforms, or reserved device names on windows.
When this option is enabled, entries that are determined to be non-portable will be listed under
the 'nonportable' key."
-types -default {} -type list\
-help "Restrict results to specified types.
This uses the same basic types and mechanism as the Tcl 'glob' command.
entry:
{f}iles {d}irs {l}inks {s}ockets {p}ipes {b}lock devices {c}haracter special devices
attributes:
hidden readonly
permissions:
r w x
Note that on windows, Tcl's glob command uses very basic heuristics to determine the permissions - it doesn't
actually check the ACLs.
For example - the test for executable permission is just whether the file has a known executable extension
and the test for readonly is just whether the file has the read-only attribute set.
On unix, the permissions are likely to be determined by checking the actual permissions of the file against
the current user's uid and groups.
When entry types are given, results matching any of those types will be returned.
When attributes or permissions are given, only results matching all of the specified attributes or permissions
will be returned.
The default (empty list) is to return all types and ignore attributes and permissions.
If just attributes or permissions are given without entry types, then the types will be filtered according to
the specified attributes or permissions but not according to the directory entry type
- so for example if just 'hidden' attribute is given, then both files and folders with the hidden attribute
will be returned.
(todo - macintosh specific type handling)
"
@values -min 0 -max -1 -type string -unnamed true
}
proc dirfiles_dict {args} {
@ -955,23 +1065,29 @@ tcl::namespace::eval punk::nav::fs {
#review - spaced paths ?
error "dirfiles_dict: multiple listing not *yet* supported"
}
set searchspec [lindex $searchspecs 0]
# -- --- --- --- --- --- ---
set opt_searchbase [dict get $opts -searchbase]
set opt_tailglob [dict get $opts -tailglob]
set opt_with_sizes [dict get $opts -with_sizes]
set opt_with_times [dict get $opts -with_times]
set opt_searchbase [dict get $opts -searchbase]
set opt_tailglob [dict get $opts -tailglob]
set opt_with_sizes [dict get $opts -with_sizes]
set opt_with_times [dict get $opts -with_times]
set opt_link_info [dict get $opts -link_info]
set opt_types [dict get $opts -types]
set opt_filedebug [dict get $opts -filedebug]
set opt_patterndebug [dict get $opts -patterndebug]
set opt_portabilitycheck [dict get $opts -portabilitycheck]
# -- --- --- --- --- --- ---
set searchspec [lindex $searchspecs 0]
#we don't want to normalize..
#for example if the user supplies ../ we want to see ../result
set is_relativesearchspec [expr {[file pathtype $searchspec] eq "relative"}]
if {$opt_searchbase eq ""} {
set searchbase .
} else {
#if {$opt_searchbase eq ""} {
# set searchbase .
#} else {
set searchbase $opt_searchbase
}
#}
switch -- $opt_tailglob {
@ -979,7 +1095,7 @@ tcl::namespace::eval punk::nav::fs {
if {$searchspec eq ""} {
set location
} else {
if {$is_relativesarchspec} {
if {$is_relativesearchspec} {
#set location [file dirname [file join $opt_searchbase $searchspec]]
set location [punk::path::normjoin $searchbase $searchspec ..]
} else {
@ -1063,7 +1179,7 @@ tcl::namespace::eval punk::nav::fs {
set ts1 [clock clicks -milliseconds]
if {$is_in_vfs} {
set listing [punk::du::lib::du_dirlisting_tclvfs $location -glob $match_contents {*}$next_opt_with_sizes {*}$next_opt_with_times]
set listing [punk::du::lib::du_dirlisting_tclvfs $location -glob $match_contents -filedebug $opt_filedebug -patterndebug $opt_patterndebug {*}$next_opt_with_sizes {*}$next_opt_with_times -types $opt_types]
} else {
set invfs ""
switch -glob -- $location {
@ -1094,20 +1210,25 @@ tcl::namespace::eval punk::nav::fs {
switch -- $invfs {
zipfs {
#relative vs absolute? review - cwd valid for //zipfs:/ ??
set listing [punk::du::lib::du_dirlisting_zipfs $location -glob $match_contents {*}$next_opt_with_sizes {*}$next_opt_with_times]
set listing [punk::du::lib::du_dirlisting_zipfs $location -glob $match_contents -filedebug $opt_filedebug -patterndebug $opt_patterndebug {*}$next_opt_with_sizes {*}$next_opt_with_times -types $opt_types]
}
cookit {
#seems to be a vfs - except it mounts on a pseudo-volume path //cookit:/
#don't use twapi
#could possibly use du_dirlisting_tclvfs REVIEW
#files and folders are all returned with the -types hidden option for glob on windows
set listing [punk::du::lib::du_dirlisting_generic $location -glob $match_contents {*}$next_opt_with_sizes {*}$next_opt_with_times]
set listing [punk::du::lib::du_dirlisting_generic $location -glob $match_contents -filedebug $opt_filedebug -patterndebug $opt_patterndebug {*}$next_opt_with_sizes {*}$next_opt_with_times -types $opt_types]
}
pseudovol {
set listing [punk::du::lib::du_dirlisting_generic $location -glob $match_contents {*}$next_opt_with_sizes {*}$next_opt_with_times]
set listing [punk::du::lib::du_dirlisting_generic $location -glob $match_contents -filedebug $opt_filedebug -patterndube $opt_patterndebug {*}$next_opt_with_sizes {*}$next_opt_with_times -types $opt_types]
}
default {
set listing [punk::du::dirlisting $location -glob $match_contents {*}$next_opt_with_sizes {*}$next_opt_with_times]
#The default call to punk::du::dirlisting will use the most appropriate mechanism for the platform and path
#- e.g twapi for windows local paths (if twapi is available), tcl glob for unix etc
#- and will be able to handle vfs paths that are visible to the filesystem as well (e.g cookit)
#- but may not be able to handle some vfs paths that aren't visible as normal files/folders to the filesystem
#(e.g if the vfs doesn't report itself as a vfs in vfs::filesystem info)
set listing [punk::du::dirlisting $location -glob $match_contents -filedebug $opt_filedebug -patterndebug $opt_patterndebug {*}$next_opt_with_sizes {*}$next_opt_with_times -link_info $opt_link_info -types $opt_types]
}
}
}
@ -1157,6 +1278,10 @@ tcl::namespace::eval punk::nav::fs {
#we add dotfiles to flaggedhidden list in case there is some other mechanism that has flagged items as hidden
if {$::tcl_platform(platform) ne "windows"} {
#lappend flaggedhidden {*}[lsearch -all -inline [list {*}$dirs {*}$files] ".*"]
#todo - fix! we want to match any file or folder with a leading dot
#- but we also want to preserve the full path in the flaggedhidden list - so we need to check the tail of each entry for leading dot, rather than just doing a glob match on the whole path.
lappend flaggedhidden {*}[tcl::prefix::all [list {*}$dirs {*}$files] .]
#e.g we can have dupes in the case where there are vfs mounted files that appear as dirs
#as we will need to do a (nat)sort as a last step - it will be faster to not sort items prematurely
@ -1205,9 +1330,11 @@ tcl::namespace::eval punk::nav::fs {
#jmn
set ts1 [clock milliseconds]
foreach nm [list {*}$dirs {*}$files] {
if {[punk::winpath::illegalname_test $nm]} {
lappend nonportable $nm
if {$opt_portabilitycheck} {
foreach nm [list {*}$dirs {*}$files] {
if {[punk::winpath::illegalname_test $nm]} {
lappend nonportable $nm
}
}
}
set ts2 [clock milliseconds]
@ -1221,6 +1348,165 @@ tcl::namespace::eval punk::nav::fs {
return [dict merge $listing $updated]
}
#replacement for tcl's glob.
#we can document with punk::args - but we don't want the overhead of full option parsing for every glob call - so we'll just do the option parsing manually within the proc.
#todo - add alias for punk::args id ::punk::nav::fs::fglob to ::glob so documentation matches.
proc fglob_parse_test1 {args} {
#tcl's glob requires a -- when arguments in positions that could be an option begin with a dash.
#This means accounting for prefixes of valid options and determining if they accept a parameter (which is allowed to begin with a dash) or not.
#Any argument that is in a possible option position and begins with a dash but isn't a valid option (or the -- itself)
#should raise an error rather than being treated as a literal glob pattern.
set valid_options [list -directory -join -nocomplain -path -tails -types]
set solo_options [list -nocomplain -join -tails]
set eopts_reached 0
set options [dict create] ;#for solos we will set the value to 1, for options that take parameters we will set the value to the parameter
set patterns [list]
for {set i 0} {$i < [llength $args]} {incr i} {
set a [lindex $args $i]
if {[string match -* $a]} {
if {$a eq "--"} {
set eopts_reached 1
set patterns [lrange $args [expr {$i + 1}] end]
break
}
#before -- we need to check if arg is a valid option or not
set full_option_name [tcl::prefix::match -error "" $valid_options $a]
if {$full_option_name ne ""} {
if {$full_option_name ni $solo_options} {
#option takes a parameter - so next arg is parameter even if it looks like an option
incr i
if {$i < [llength $args]} {
set param [lindex $args $i]
dict set options $full_option_name $param
} else {
error "fglob: option \"$a\" requires a parameter but none found"
}
} else {
dict set options $full_option_name 1
}
} else {
error "fglob: bad option \"$a\": must be [join $valid_options ", "] or --"
}
} else {
set patterns [lrange $args $i end]
break
}
}
return [dict create options $options patterns $patterns]
}
proc fglob_parse_test {args} {
#tcl's glob requires a -- when arguments in positions that could be an option begin with a dash.
#This means accounting for prefixes of valid options and determining if they accept a parameter (which is allowed to begin with a dash) or not.
#Any argument that is in a possible option position and begins with a dash but isn't a valid option (or the -- itself)
#should raise an error rather than being treated as a literal glob pattern.
#set valid_options [list -directory -join -nocomplain -path -tails -types]
#set solo_options [list -nocomplain -join -tails]
set options [dict create] ;#for solos we will set the value to 1, for options that take parameters we will set the value to the parameter
set patterns [list]
set arglen [llength $args]
for {set i 0; set j 1; set a [lindex $args 0]} {$i < $arglen} {incr j; set a [lindex $args [incr i]]} {
if {![string match -* $a]} {
#encountered non-option-like argument. All subsequent arguments should be treated as patterns even if they look like options.
return [dict create options $options patterns [lrange $args $i end]]
}
if {$a eq "--"} {
return [dict create options $options patterns [lrange $args $j end]]
}
#flag-like argument before -- we need to check if arg is a valid option or not
set full_option_name [tcl::prefix::match {-directory -join -nocomplain -path -tails -types} $a]
switch -- $full_option_name {
-directory - -path - -types {
#option takes a parameter - so next arg is parameter even if it looks like an option
if {$j < $arglen} {
dict set options $full_option_name [lindex $args $j]
incr i
incr j
} else {
error "fglob: option \"$a\" requires a parameter but none found"
}
}
default {
dict set options $full_option_name 1
}
}
}
return [dict create options $options patterns $patterns]
}
proc fglob {args} {
#tcl's glob requires a -- when arguments in positions that could be an option begin with a dash.
#This means accounting for prefixes of valid options and determining if they accept a parameter (which is allowed to begin with a dash) or not.
#Any argument that is in a possible option position and begins with a dash but isn't a valid option (or the -- itself)
#should raise an error rather than being treated as a literal glob pattern.
#set valid_options [list -directory -join -nocomplain -path -tails -types]
#set solo_options [list -nocomplain -join -tails]
set options [dict create] ;#for solos we will set the value to 1, for options that take parameters we will set the value to the parameter
set patterns [list]
set arglen [llength $args]
for {set i 0; set j 1; set a [lindex $args 0]} {$i < $arglen} {incr j; set a [lindex $args [incr i]]} {
if {![string match -* $a]} {
#encountered non-option-like argument. All subsequent arguments should be treated as patterns even if they look like options.
#return [dict create options $options patterns [lrange $args $i end]]
set patterns [lrange $args $i end]
break
}
if {$a eq "--"} {
#return [dict create options $options patterns [lrange $args $j end]]
set patterns [lrange $args $j end]
break
}
#flag-like argument before -- we need to check if arg is a valid option or not
set full_option_name [tcl::prefix::match {-directory -join -nocomplain -path -tails -types} $a]
switch -- $full_option_name {
-directory - -path - -types {
#option takes a parameter - so next arg is parameter even if it looks like an option
if {$j < $arglen} {
dict set options $full_option_name [lindex $args $j]
incr i
incr j
} else {
error "fglob: option \"$a\" requires a parameter but none found"
}
}
default {
dict set options $full_option_name 1
}
}
}
#return [dict create options $options patterns $patterns]
#todo -join
# process each glob as a directory listing.
if {[dict exists $options -directory]} {
set basedir [dict get $options -directory]
} else {
set basedir ""
}
#ignore -nocomplain - like tcl9 glob - if no results - return empty list rather than error
if {[dict exists $options -types]} {
set types [dict get $options -types]
} else {
set types {}
}
#todo - fix for multiple absolute paths supplied.
#e.g c:/cmdfiles/*.exe c:/repo/jn/shellspy/bin/*.exe
#- we would want to treat as two separate globs with different basedirs
#for common basedir - we might be better off creating a single glob pattern using the brace syntax for alternatives.
#TCL's glob returns a single list when multiple patterns supplied.
#It does not seem to sort or de-dup, or group results by type.
set results [list]
foreach pattern $patterns {
set resultd [dirfiles_dict -portabilitycheck 0 -searchbase $basedir -with_times 0 -with_sizes 0 -link_info 0 -types $types -tailglob $pattern]
#puts [showdict $resultd]
#todo - other types?
lappend results {*}[dict get $resultd dirs] {*}[dict get $resultd files] {*}[dict get $resultd links]
}
return $results
}
punk::args::define {
@id -id ::punk::nav::fs::dirfiles_dict_as_lines
-stripbase -default 0 -type boolean
@ -1257,6 +1543,8 @@ tcl::namespace::eval punk::nav::fs {
# - we may be able to provide a reasonable default for windows vs other - but a proc option is likely needed to allow caller to override the default behaviour on a call by call basis,
# and a config option may be desirable for the user to override the platform default.
# The chosen defaults based on platform may be generally ok - but then not give the desired behaviour when accessing a particular filesystem/mount
# - note also that case-insensitivity can be configured per folder on windows via the fsutil.exe utility
# unfortunately we have no simple & fast way to query the case-sensitivity of a particular folder or filesystem.
if {$::tcl_platform(platform) eq "windows"} {
#case-preserving but case-insensitive matching is the default
foreach d $list_of_dicts {
@ -1704,9 +1992,13 @@ tcl::namespace::eval punk::nav::fs::lib {
punk::args::define {
@id -id ::punk::nav::fs::lib::is_fileglob
@cmd -name punk::nav::fs::lib::is_fileglob
@cmd -name punk::nav::fs::lib::is_fileglob\
-help\
"Determine if a string is a glob pattern as recognised by the tcl 'glob' command.
This is used for example to determine whether to treat a path component as a literal
or a glob pattern when processing paths with glob patterns in them (e.g for the ./ command)."
@values -min 1 -max 1
path -type string -required true -help\
path -type string -optional 0 -help\
{String to test for being a glob pattern as recognised by the tcl 'glob' command.
If the string represents a path with multiple segments, only the final component
of the path will be tested for glob characters.
@ -1799,6 +2091,7 @@ interp alias {} vwd {} punk::nav::fs::vwd ;#return punk::nav::fs::VI
interp alias {} dirlist {} punk::nav::fs::dirlist
interp alias {} dirfiles {} punk::nav::fs::dirfiles
interp alias {} dirfiles_dict {} punk::nav::fs::dirfiles_dict
interp alias {} fglob {} punk::nav::fs::fglob
interp alias {} ./new {} punk::nav::fs::d/new
interp alias {} d/new {} punk::nav::fs::d/new

5
src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/punk/path-0.1.0.tm

@ -116,6 +116,8 @@ namespace eval punk::path {
#(sometimes //server.com used as a short form for urls - which doesn't seem too incompatible with this anyway)
# -- ---
#This is intended to be purely a string analysis - without reference to filesystem volumes or vfs or zipfs mountpoints etc
#(but this also means we won't be able to resolve windows shortnames or dos device paths - so we will preserve those as they are) - review
#(It also means we can't resolve per drive working directories on windows - so we will preserve c: as is rather than converting to absolute - review)
#
#TODO - option for caller to provide a -base below which we can't backtrack.
#This is preferable to setting policy here for example regarding forcing no trackback below //servername/share
@ -179,6 +181,9 @@ namespace eval punk::path {
#relpaths all end up with leading . - while not always the simplest form, this is ok. (helps stop inadvertent conversions to absolutes)
# Tests - TODO
# normjoin /d:/..//vfs:/test -> /vfs:/test (good - not converted to //vfs:/test)
#normjoin c: should theoretically return current per drive working directory on c:
# - would need to use win32 GetFullPathName to resolve this.
proc normjoin {args} {
set args [lmap a $args {string map "\\\\ /" $a}]
set path [plainjoin {*}$args]

290
src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/punk/winlnk-0.1.1.tm

@ -21,11 +21,11 @@
#[manpage_begin punkshell_module_punk::winlnk 0 0.1.1]
#[copyright "2024"]
#[titledesc {windows shortcut .lnk library}] [comment {-- Name section and table of contents description --}]
#[moddesc {punk::winlnk}] [comment {-- Description at end of page heading --}]
#[moddesc {punk::winlnk}] [comment {-- Description at end of page heading --}]
#[require punk::winlnk]
#[keywords module shortcut lnk parse windows crossplatform]
#[description]
#[para] Tools for reading windows shortcuts (.lnk files) on any platform
#[para] Tools for reading windows shortcuts (.lnk files) on any platform
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
@ -164,6 +164,17 @@ tcl::namespace::eval punk::winlnk {
error "punk::winlnk::Header_Has_LinkFlag error - flagname $flagname not known"
}
}
proc Header_Get_LinkFlags_as_list {contents} {
variable LinkFlags
set allflags [Header_Get_LinkFlags $contents]
set setflags {}
dict for {flagname binflag} $LinkFlags {
if {$allflags & $binflag} {
lappend setflags $flagname
}
}
return $setflags
}
#MS-SHLLINK.pdf documents the .lnk file format in detail, but here is a brief overview of the structure of a .lnk file:
#protocol revision 10.0 (November 2025) https://winprotocoldocs-bhdugrdyduf5h2e4.b02.azurefd.net/MS-SHLLINK/%5bMS-SHLLINK%5d.pdf
@ -487,6 +498,89 @@ tcl::namespace::eval punk::winlnk {
}
}
variable known_guids [dict create\
"00021400-0000-0000-C000-000000000046" {name Desktop guidtype CLSID}\
"00021401-0000-0000-C000-000000000046" {name Shortcut guidtype CLSID}\
"20D04FE0-3AEA-1069-A2D8-08002B30309D" {name "This PC" guidtype CLSID}\
"F02C1A0D-BE21-4350-88B0-7367FC96EF3C" {name "Computers and Devices" guidtype CLSID}\
"645FF040-5081-101B-9F08-00AA002F954E" {name "Recycle Bin" guidtype CLSID}\
"26EE0668-A00A-44D7-9371-BEB064C98683" {name "Control Panel" guidtype CLSID}\
]
proc guid_lookup {guid} {
# This function looks up known GUIDs and returns their associated names or descriptions.
# In a real implementation, this could query a database of known GUIDs or use a predefined mapping of common GUIDs to their meanings.
# For example, it could recognize the CLSID for the "My Computer" folder, the "Network" folder, etc., and return human-readable names for those.
#
variable known_guids
if {[dict exists $known_guids $guid]} {
return [dict get $known_guids $guid]
} else {
if {"windows" eq $::tcl_platform(platform)} {
# On Windows, we can use the registry to look up GUIDs.
#for now we will just look up CLSIDs in HKEY_CLASSES_ROOT CLSID {guid}
package require registry
set reg_path [join [list HKEY_CLASSES_ROOT CLSID "{$guid}"] "\\"]
if {![catch {registry get $reg_path ""} name]} {
return [dict create name $name guidtype "CLSID"]
}
return ""
} else {
# On non-Windows platforms, we likely won't have a way to look up the GUID.
return ""
}
}
return ""
}
#some more hints: https://github.com/libyal/libfwsi/blob/main/documentation/Windows%20Shell%20Item%20format.asciidoc
proc Parse_LinkTargetID_typehex_1F {rawcontent} {
#The structure of this ItemID type is as follows:
#Offset 0: 2 bytes - size of the ItemID (including these 2 bytes)
#Offset 2: 1 byte - type byte (0x1F for file system objects)
#Offset 3: variable length - data specific to the item (e.g. file name, attributes, etc.)
set size_field [string range $rawcontent 0 1]
binary scan $size_field su size
set type_byte [string index $rawcontent 2]
if {[format %02X [scan $type_byte %c]] ne "1F"} {
error "punk::winlnk::Parse_LinkTargetID_typehex_1F error - expected type byte 0x1F but got [format %02X [scan $type_byte %c]]"
}
set parsed [dict create]
if {$size == 20} {
#when size is 20 and type is 0x1F - the data is assumed to be a GUID.
dict set parsed ident "GUID"
dict set parsed indicator [string range $rawcontent 3 3] ;#unknown - specific to shell implementation?
#structure is 4 bytes le, 2 bytes le, 2 bytes le, 8 bytes be
#the final hex format is 8-4-4-4-12 (e.g. 00021401-0000-0000-C000-000000000046) but the endianness is different for the first three parts vs the last two parts, which is a common pattern in Windows GUID/CLSID structures
set d1 [string range $rawcontent 4 7]
set d2 [string range $rawcontent 8 9]
set d3 [string range $rawcontent 10 11]
set d4 [string range $rawcontent 12 19]
#set d1_dec [scan $d1 i val1]
binary scan $d1 i d1_dec
binary scan $d2 s d2_dec
binary scan $d3 s d3_dec
#d4 is 8 bytes treated as individual bytes, so we can scan it as 8 individual bytes
set scan [string repeat %c 8]
set fmt [string repeat %02X 8]
set val4 [scan $d4 $scan]
set guid [format "%08X-%04X-%04X-%02X%02X-%02X%02X%02X%02X%02X%02X" $d1_dec $d2_dec $d3_dec {*}$val4]
dict set parsed guid $guid
set guid_info [guid_lookup $guid]
if {[dict size $guid_info]} {
dict set parsed name [dict get $guid_info name]
dict set parsed guidtype [dict get $guid_info guidtype]
}
return $parsed
} else {
#unknown
return $parsed
}
set data [string range $rawcontent 3 [expr {$size - 1}]]
#TODO - parse the data according to the structure of the ItemID type 0x1F
return [dict create size $size type "1F" rawdata $data]
}
#some clues on the structure of the IDList content and how to parse it can be found in the analysis of CVE-2020-0729,
#which is a remote code execution vulnerability in Windows that can be exploited through specially crafted .lnk files that contain malicious IDList content.
#The analysis of this vulnerability provides insights into how the IDList content is structured and how it can be parsed to extract information about the link target and potentially execute code.
@ -498,8 +592,8 @@ tcl::namespace::eval punk::winlnk {
set offset 0
while {$offset < [string length $idlist_content]} {
if {[string length $idlist_content] - $offset < 2} break
set size_bytes [string range $idlist_content $offset [expr {$offset + 1}]] ;#size including these 2 bytes
binary scan $size_bytes su size
set size_field [string range $idlist_content $offset $offset+1] ;#size including these 2 bytes
binary scan $size_field su size
if {$size == 0} break
if {$size < 2} {
# Invalid size, abort
@ -511,15 +605,24 @@ tcl::namespace::eval punk::winlnk {
break
}
set itemid [string range $idlist_content $offset [expr {$offset + $size - 1}]]
set itemid_bytes [string range $itemid 0 1]
binary scan $itemid_bytes su itemid_size
set itemid_size_field [string range $itemid 0 1]
binary scan $itemid_size_field su itemid_size
#in *general* byte 3 of the ItemID structure can be used to determine the type of the item
#(e.g. file, folder, network location, etc.) but this is not always reliable and can vary
#based on the specific structure of the ItemID and the context in which it is used
set itemid_type_byte [string index $itemid 2]
#puts stderr "ItemID size: $itemid_size, type byte: [format %02X [scan $itemid_type_byte %c]]"
set maybe_type [format %02X [scan $itemid_type_byte %c]]
lappend result [dict create size $itemid_size type $maybe_type rawcontent $itemid]
set typedec [scan $itemid_type_byte %c]
set typehex [format %02X $typedec]
set item_dict [dict create size $itemid_size typehex $typehex typedec $typedec rawbytes $itemid viewbytes [ansistring VIEW -lf 1 $itemid]]
switch -- $typehex {
"1F" {
set parsed [Parse_LinkTargetID_typehex_1F $itemid]
dict set item_dict parsed $parsed
}
}
lappend result $item_dict
incr offset $size
}
@ -615,9 +718,91 @@ tcl::namespace::eval punk::winlnk {
return [dict create localbasepath $localbasepath commonpathsuffix $commonpathsuffix note <incomplete>]
}
proc Contents_Get_DataStrings {contents start} {
set data_strings [string range $contents $start end]
return $data_strings
proc Contents_Get_StringData_String {contents start} {
#All StringData structures have a CountCharacters field of 2 bytes that is an unsigned integer specifying the number of characters
#in the string, followed by the string characters themselves.
#The string of variable length, must not be NULL-terminated.
#(which is 2 bytes of 0x00 if the IsUnicode flag is set in the LinkFlags field, or 1 byte of 0x00 if the IsUnicode flag is not set).
set lenfield [string range $contents $start $start+1]
set r [binary scan $lenfield su count_chars] ;# su is for unsigned short in little endian order
set string_value ""
if {[Header_Has_LinkFlag $contents "IsUnicode"]} {
#string is UTF-16LE encoded
set numbytes [expr {2 * $count_chars}]
set string_bytes [string range $contents $start+2 [expr {$start + 2 + $numbytes - 1}]]
#consider using tcl encoding convertfrom utf-16le instead of manually parsing the UTF-16LE bytes - this would be more robust and handle edge cases better (e.g. surrogate pairs, non-BMP characters, etc.)
set string_value [encoding convertfrom utf-16le $string_bytes]
#for {set i 0} {$i < [string length $string_bytes]} {
# set char_bytes [string range $string_bytes $i [expr {$i + 1}]]
# set r [binary scan $char_bytes su char] ;# s for unsigned short
# append string_value [format %c $char]
# incr i 1 ;# skip the next byte since it's part of the UTF-16LE encoding
#}
} else {
set numbytes $count_chars
set string_bytes [string range $contents $start+2 [expr {$start + 2 + $numbytes - 1}]]
#string is ANSI encoded
set string_value $string_bytes
}
return [dict create string $string_value next_start [expr {$start + 2 + $numbytes}]]
}
#MS-SHLLINK StringData
#https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-shllink/17b69472-0f34-4bcf-b290-eccdb8de224b
#stored in following order - each element is optional based on the presence of corresponding flags in the LinkFlags field of the header:
# NAME_STRING (description) - flag HasName
# RELATIVE_PATH - flag HasRelativePath
# WORKING_DIR - flag HasWorkingDir
# COMMAND_LINE_ARGUMENTS - flag HasArguments
# ICON_LOCATION - flag HasIconLocation
proc Contents_Get_StringData {contents start} {
set flags_enabled [Header_Get_LinkFlags_as_list $contents]
#puts stderr "flags_enabled: $flags_enabled"
#set data_strings [string range $contents $start end]
#return $data_strings
set current_offset $start
set result {}
if {"HasName" in $flags_enabled} {
set stringinfo [Contents_Get_StringData_String $contents $current_offset]
#set current_offset [expr {$current_offset + [string length $name] + 1}] ;#+1 for null terminator
#incr current_offset [string length $name]
set current_offset [dict get $stringinfo next_start]
set name [dict get $stringinfo string]
dict set result name_string $name
}
if {"HasRelativePath" in $flags_enabled} {
set stringinfo [Contents_Get_StringData_String $contents $current_offset]
set current_offset [dict get $stringinfo next_start]
set relative_path [dict get $stringinfo string]
#set current_offset [expr {$current_offset + [string length $relative_path] + 1}] ;#+1 for null terminator
dict set result relative_path $relative_path
}
if {"HasWorkingDir" in $flags_enabled} {
set stringinfo [Contents_Get_StringData_String $contents $current_offset]
set current_offset [dict get $stringinfo next_start]
set working_dir [dict get $stringinfo string]
#set current_offset [expr {$current_offset + [string length $working_dir] + 1}] ;#+1 for null terminator
dict set result working_dir $working_dir
}
if {"HasArguments" in $flags_enabled} {
set stringinfo [Contents_Get_StringData_String $contents $current_offset]
set current_offset [dict get $stringinfo next_start]
set arguments [dict get $stringinfo string]
#set current_offset [expr {$current_offset + [string length $arguments] + 1}] ;#+1 for null terminator
dict set result command_line_arguments $arguments
}
if {"HasIconLocation" in $flags_enabled} {
set stringinfo [Contents_Get_StringData_String $contents $current_offset]
set current_offset [dict get $stringinfo next_start]
set icon_location [dict get $stringinfo string]
#set current_offset [expr {$current_offset + [string length $icon_location] + 1}] ;#+1 for null terminator
dict set result icon_location $icon_location
}
return $result
}
proc Contents_Get_Info {contents} {
@ -639,12 +824,13 @@ tcl::namespace::eval punk::winlnk {
#Working Dir: C:\repo\jn\tclmodules\tomlish\src\modules\test\#modpod-tomlish-0.1.1\suites\roundtrip\roundtrip_files
variable LinkFlags
set flags_enabled [list]
dict for {k v} $LinkFlags {
if {[Header_Has_LinkFlag $contents $k] > 0} {
lappend flags_enabled $k
}
}
set flags_enabled [Header_Get_LinkFlags_as_list $contents]
#set flags_enabled [list]
#dict for {k v} $LinkFlags {
# if {[Header_Has_LinkFlag $contents $k] > 0} {
# lappend flags_enabled $k
# }
#}
set showcommand_val [Header_Get_ShowCommand $contents]
switch -- $showcommand_val {
@ -726,19 +912,13 @@ tcl::namespace::eval punk::winlnk {
}
# ----------------------------------------------------------------------
#todo - get Data strings by parsing contents starting at $next_start
#stored in following order:
# description
# relative path
# working directory
# command line arguments
# icon location
#get StringData by parsing contents starting at $next_start
#Data strings format:
# 2 bytes: number of characters in the string
# following: The string. ASCII or UTF-16 little-endian string
set datastring_dict [Contents_Get_DataStrings $contents $next_start]
#puts stderr "next_start: $next_start"
set datastring_dict [Contents_Get_StringData $contents $next_start]
# ----------------------------------------------------------------------
@ -770,18 +950,32 @@ tcl::namespace::eval punk::winlnk {
target_type_mech $target_type_mech\
idlist $linktargetidlist\
linkinfo $linkfields\
stringdata $datastring_dict\
]
#relative_path "?"
}
namespace eval argdoc {
variable PUNKARGS
lappend PUNKARGS [list {
@id -id ::punk::winlnk::file_check_header
@cmd -name punk::winlnk::file_check_header\
-summary\
"Test if .lnk file has a valid header for a windows shortcut."\
-help\
"Check the header of the file specified in path to see if it matches the expected
structure of a windows .lnk file header.
Returns a boolean.
If an invalid path is provided or the file cannot be read, an error will be raised."
@values -min 1 -max 1
path -type string -help "Path to the .lnk file to resolve"
}]
}
proc file_check_header {path} {
#*** !doctools
#[call [fun file_check_header] [arg path] ]
#[para]Return 0|1
#[para]Determines if the .lnk file specified in path has a valid header for a windows shortcut
set c [Get_contents $path 20]
return [Contents_check_header $c]
}
namespace eval argdoc {
variable PUNKARGS
lappend PUNKARGS [list {
@ -798,10 +992,6 @@ tcl::namespace::eval punk::winlnk {
}]
}
proc resolve {path} {
#*** !doctools
#[call [fun resolve] [arg path] ]
#[para] Return a dict of info obtained by parsing the binary data in a windows .lnk file
#[para] If the .lnk header check fails, then the .lnk file probably isn't really a shortcut file and the dictionary will contain an 'error' key
set c [Get_contents $path]
if {[Contents_check_header $c]} {
return [Contents_Get_Info $c]
@ -840,6 +1030,7 @@ tcl::namespace::eval punk::winlnk {
target_type target_type\
idlist idlist/@*/@*.@*\
linkinfo linkinfo/@*.@*\
stringdata stringdata/@*.@*\
]
set info [resolve $path]
if {[dict exists $info error]} {
@ -869,11 +1060,18 @@ tcl::namespace::eval punk::winlnk {
"Return the target path of the .lnk file specified in path.
This is a convenience function that extracts the target path from the .lnk file and returns it directly,
without all the additional information that resolve provides. If the .lnk header check fails, then
the .lnk file probably isn't really a shortcut file and an error message will be returned."
the .lnk file probably isn't really a shortcut file and an error message will be returned.
Incomplete! - needs to process arguments when HasArguments link flag is set and append the arguments to the target path.
e.g for a shortcut to 'START /D ...' the target will currently just return a path to cmd.exe - which is insufficient."
@values -min 1 -max 1
path -type string -help "Path to the .lnk file to resolve"
}]
}
#consider a shortcut to the following:
# START /D ^"C:\tcl\bin^" wish.exe c:\cmdfiles\ftp.tcl"
# the target currently only returns c:/Windows/System32/cmd.exe.
proc target {path} {
#*** !doctools
#[call [fun target] [arg path] ]
@ -941,19 +1139,6 @@ tcl::namespace::eval punk::winlnk {
}
#proc sample1 {p1 n args} {
# #*** !doctools
# #[call [fun sample1] [arg p1] [arg n] [opt {option value...}]]
# #[para]Description of sample1
# #[para] Arguments:
# # [list_begin arguments]
# # [arg_def tring p1] A description of string argument p1.
# # [arg_def integer n] A description of integer argument n.
# # [list_end]
# return "ok"
#}
#*** !doctools
@ -973,13 +1158,6 @@ tcl::namespace::eval punk::winlnk::lib {
#[para] Secondary functions that are part of the API
#[list_begin definitions]
#proc utility1 {p1 args} {
# #*** !doctools
# #[call lib::[fun utility1] [arg p1] [opt {?option value...?}]]
# #[para]Description of utility1
# return 1
#}
#*** !doctools
@ -1010,7 +1188,7 @@ namespace eval ::punk::args::register {
package provide punk::winlnk [tcl::namespace::eval punk::winlnk {
variable pkg punk::winlnk
variable version
set version 0.1.1
set version 0.1.1
}]
return

41
src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/punk/winpath-0.1.0.tm

@ -136,18 +136,30 @@ namespace eval punk::winpath {
proc illegal_char_map_to_ntfs {ch} {
#windows ntfs maps illegal chars to the PUA unicode range 0xF000-0xF0FF for characters that are illegal in windows API but can exist on NTFS or samba shares etc.
#see also: https://cygwin.com/cygwin-ug-net/using-specialnames.html#pathnames-specialchars
#see also: https://stackoverflow.com/questions/76738031/unicode-private-use-characters-in-ntfs-filenames#:~:text=map_dict%20=%20%7B%200xf009:'%5C,c%20return%20(output_name%2C%20mapped)
#see also: https://stackoverflow.com/questions/76738031/unicode-private-use-characters-in-ntfs-filenames
#formula is 0xF000 + ascii value of char.
#(decimal values are 61440 + ascii value of char)
#see also punk::char::ascii2NTFSPUA
# punk::char::codetable ascii
set map [dict create \
"\"" "\uF022" \
"*" "\uF02A" \
":" "\uF03A" \
"<" "\uF03C" \
">" "\uF03E" \
":" "\uF03A" \
"\"" "\uF022" \
"/" "unknown" \
"?" "\uF03F" \
"\\" "\uF05c" \
"|" "\uF07C" \
"?" "\uF03F" \
"*" "\uF02A"]
]
dict set map "/" "\uF02F"
#ESC (\x1b) is also mapped.
dict set map \x1b "\uF01B"
#note that : is used for NTFS alternate data streams - but the character is still illegal in the main filename - so we will map it to a glyph in the PUA range for display purposes - but it will still need dos device syntax to be accessed via windows API.
if {$ch in [dict keys $map]} {
return [dict get $map $ch]
@ -412,14 +424,15 @@ namespace eval punk::winpath::system {
}
# -----------------------------------------------------------------------------
# register namespace(s) to have PUNKARGS,PUNKARGS_aliases variables checked
# -----------------------------------------------------------------------------
# variable PUNKARGS
# variable PUNKARGS_aliases
namespace eval ::punk::args::register {
#use fully qualified so 8.6 doesn't find existing var in global namespace
lappend ::punk::args::register::NAMESPACES ::punk::winpath
}
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
## Ready

1
src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/include_modules.config

@ -54,6 +54,7 @@ set bootsupport_modules [list\
modules punk::assertion\
modules punk::args\
modules punk::args::moduledoc::tclcore\
modules punk::auto_exec\
modules punk::cap\
modules punk::cap::handlers::caphandler\
modules punk::cap::handlers::scriptlibs\

247
src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/punk-0.1.tm

@ -75,6 +75,76 @@ namespace eval punk {
#
}
if {![interp issafe] && $::tcl_platform(platform) eq "windows"} {
#return the raw command string from the registry for the association of the given extension, without processing the placeholders such as %1 %SystemRoot% etc.
#This is because we want to process these ourselves to be able to return a proper list of command and arguments.
#Note that the resulting string can't be directly treated as a tcl list because it has double quoted segments with characters that are literals (not escaped)
#Accessing it directly as a list will cause tcl to interpret the backslashes as escapes and lose the literal meaning values such as the path.
proc extension_open_association {ext} {
#look up assoc and ftype to find associated file type and application.
#Note that the windows 'ftype' and 'assoc' commands only examine the system's data - not the user-specific data which may override the system data.
#to get the user-specific associations we need to read the registry keys.
#extensions in the registry seem to be stored lower case wnd with a leading dot.
set lext [string tolower $ext]
package require registry
set user_assoc_path [join [list HKEY_CURRENT_USER Software Microsoft Windows CurrentVersion Explorer FileExts $lext UserChoice] "\\"]
#The UserChoice subkey under this key is where the user-specific association is stored when the user has made a choice. It contains a Progid value which points to the associated file type.
#It can return something like "Python.File" or "Applications\python.exe" or even tcl_auto_file (for .tcl files associated with tclsh by a tcl installer)
#The UserChoice key may not exist if the user hasn't made an explicit choice, in which case we fall back to the system association which is stored under HKEY_CLASSES_ROOT\$ext (which also contains user-specific overrides but is more cumbersome to query for them)
if {![catch {registry get $user_assoc_path Progid} user_choice]} {
if {$user_choice ne ""} {
#examples such as Applications\python.exe and tcl_auto_file are found under HKEY_CURRENT_USER\Software\Classes
#they then have a sub-path shell\open\command which contains the command to open files of that type, which is what we need to extract the associated application.
#it is faster to query for the key directly within a catch than to retrieve keys as lists and iterate through them to find the one we want.
if {![catch {registry get [join [list HKEY_CURRENT_USER Software Classes $user_choice shell open command] "\\"] ""} raw_assoc]} {
#The command string can contain placeholders like "%1" for the file name, so we need to extract just the executable path.
#e.g .py -> "c:\Program Files\Python\python.exe" "%1"
#e.g .rb -> "C:\tools\ruby31\bin\ruby.exe" "%1" %*
# e.g .vbs -> "%SystemRoot%\System32\WScript.exe" "%1" %*
#we need to process this without Tcl interpreting the backslashes as escapes.
#we will use double quotes to determine which entries belong together as a list item for the resulting list of command and arguments.
#set assoc [string map [list \\ \\\\] $raw_assoc]
set assoc $raw_assoc
} else {
#e.g Python.File
if {![catch {registry get [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes $user_choice shell open command] "\\"] ""} raw_assoc]} {
#set assoc [string map [list \\ \\\\] $raw_assoc]
set assoc $raw_assoc
} else {
set assoc ""
}
}
} else {
#review - is it possible for Progid to be empty string? If so we should probably fall back to system association instead of returning no association.
#alternatively it could mean the user has explicitly chosen to have no association for this file type - in which case returning an empty association may be the correct behaviour.
set assoc ""
}
} else {
#fall back to system association and ftype
if {![catch {registry get [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes $lext] "\\"] ""} ftype]} {
#ftype is the file type associated with the extension, e.g "Python.File"
#we then need to look up the command associated with this file type under the same path but with the file type instead of the extension and with the additional sub-path shell\open\command
if {![catch {registry get [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes $ftype shell open command] "\\"] ""} raw_assoc]} {
#set assoc [string map [list \\ \\\\] $raw_assoc]
set assoc $raw_assoc
} else {
set assoc ""
}
} else {
set assoc ""
}
}
return $assoc
}
}
if {![interp issafe] && $has_commandstack && $::tcl_platform(platform) eq "windows"} {
#still a caching version of auto_execok - but with proper(fixed) search order
@ -113,6 +183,8 @@ namespace eval punk {
proc auto_execok_better name {
global auto_execs env tcl_platform
#for now at least, auto_execok_better is windows-specific.
package require punk::auto_exec
if {[info exists auto_execs($name)]} {
return $auto_execs($name)
@ -141,11 +213,60 @@ namespace eval punk {
}
if {[llength [file split $name]] != 1} {
#has a path
#has a path component - could be relative or absolute.
if {[string tolower [file extension $name]] eq ".lnk"} {
#special case .lnk
#todo - consider working directory or other properties of link before launching?
package require punk::winlnk
if {![catch {punk::winlnk::target $name} linktarget]} {
if {$linktarget ne ""} {
set target $linktarget
} else {
return ""
}
} else {
set target $name
}
} else {
set target $name
}
#always store $name as the key when setting auto_execs.
foreach ext $execExtensions {
set file ${name}${ext}
set file ${target}${ext}
#first execExtension is empty string - ensure we test actual file as given before we try appending extensions.
if {$ext eq ""} {
set test_ext [file extension $file]
} else {
set test_ext $ext
}
if {[file exists $file] && ![file isdirectory $file]} {
return [set auto_execs($name) [list $file]]
#look up assoc and ftype to find associated file type and application.
#Note that the windows 'ftype' and 'assoc' commands only examine the system's data - not the user-specific data which may override the system data.
#set assoc [extension_open_association $ext]
set associnfo [punk::auto_exec::shell_open_command $test_ext]
set valuetype [dict get $associnfo type]
set assoc [dict get $associnfo value]
set ftype [dict get $associnfo filetype]
if {$assoc eq ""} {
return [set auto_execs($name) [list $file]]
} else {
if {[file pathtype $target] eq "relative" && $ftype eq "InternetShortcut"} {
#special case InternetShortcut - cannot accept relative path - so we can't cache it in auto_execs if we used a relative path to launch
#if we return an empty string - the auto_exec will fail to launch this every time.
#The best we can do is return a token for the 'unknown' process to detect and re-resolve the path every time.
#This requires cooperation from 'unknown' which may not be configured to handle this token if the default 'punk' version isn't installed.
#we can't resolve using absolute path here - because we don't want to lock in a specific file for a relative path.
#e.g ::auto_execs(./link.url) = some.exe c:/desktop/link.url
#this would be wrong if the user changed directory and tried to run ./link.url again on a different file with the same name
# - as the cached path would no longer be correct.
return [set auto_execs($name) "(resolve_in_unknown_handler) punk::auto_exec absolute_path required"]
}
puts stderr "auto_execok_better: (review required) assoc $assoc for file $file ext $test_ext"
set run [punk::auto_exec::shell_command_as_tcl_list -type $valuetype $assoc $file] ;# -workingdir [pwd] vs path of script?
return [set auto_execs($name) $run]
#return [set auto_execs($name) [list $file]]
}
}
}
return ""
@ -164,11 +285,20 @@ namespace eval punk {
append path "$windir/system32;$windir/system;$windir;"
}
foreach var {PATH Path path} {
if {[info exists env($var)]} {
append path ";$env($var)"
}
# ------------------------
#Note that unlike an ordinary Tcl array - the linked ::env behaves differently.
#e.g parray ::env Path will not find ::env(PATH) and yet 'info exists env(Path)' returns true.
#similarly 'set ::env(Path) ?newval?' or any case variation can set/get the value of ::env(PATH)
#Windows environment variables are case-insensitive.
#No evidence has been seen that any version of windows; current or historic since NT; can allow differently cased versions
# of an environment variable to exist concurrently in the same environment.
#for this reason we should be able to just use PATH.
#
if {[info exists env(PATH)]} {
append path ";$env(PATH)"
}
# ------------------------
#change2
if {[file extension $name] ne "" && [string tolower [file extension $name]] in [string tolower $execExtensions]} {
@ -177,6 +307,8 @@ namespace eval punk {
set lookfor [lmap ext $execExtensions {string cat ${name} ${ext}}]
}
#puts "-->$lookfor"
foreach dir [split $path {;}] {
set dir [string trim $dir {\\}] ;#trailing slash will result in a tail such as "/python.exe"
#set dir [file normalize $dir]
@ -207,7 +339,30 @@ namespace eval punk {
foreach match [glob -nocomplain -dir $dir -tail {*}$lookfor] {
set file [file join $dir $match]
if {[file exists $file] && ![file isdirectory $file]} {
return [set auto_execs($name) [list $file]]
#set assoc [extension_open_association [file extension $file]]
#todo - cache this lookup for each extension we encounter? maybe not, as the user might like changes reflected between runs. review.
if {"windows" ne $::tcl_platform(platform)} {
return [set auto_execs($name) [list $file]]
}
set associnfo [punk::auto_exec::shell_open_command [file extension $file]]
set assoc [dict get $associnfo value]
set type [dict get $associnfo type]
if {$assoc eq ""} {
return [set auto_execs($name) [list $file]]
} else {
puts stderr "auto_execok_better: assoc $assoc for file $file with type $type"
#return [set auto_execs($name) [list $file]]
#review - our stored auto_execs doesn't have any way to capture the full assoc info such as how subsequent arguments should be processed.
#This may need handling in our Tcl shell 'unknown' function when calls are actually made to these commands
#- we may need to re-process the assoc info at that point to determine how to combine all arguments with the calling specification in the assoc string.
#The workingdir for the command may also need to be determined at that point - should it be the dir of the script being called, or the current dir of the shell?
#The main point of Tcl's auto_execs is to avoid scanning the PATH entries every time a command is called,
#but we may want to keep some of the assoc info available for processing at call time.
set run [punk::auto_exec::shell_command_as_tcl_list -type $type $assoc $file] ;# -workingdir [pwd] vs path of script?
return [set auto_execs($name) $run]
}
}
}
}
@ -5203,11 +5358,11 @@ namespace eval punk {
#These can be stacked with shellfilter and operate as OS handles - which we can't do with fifo2 etc
#
if {[string first " " $new] > 0} {
set c1 $name
} else {
set c1 $new
}
#if {[string first " " $new] > 0} {
# set c1 $name
#} else {
# set c1 $new
#}
# -- --- --- --- ---
set idlist_stdout [list]
@ -5241,6 +5396,24 @@ namespace eval punk {
} else {
set repl_runid [punk::get_repl_runid]
#set ::punk::last_run_display [list]
if {$new eq "(resolve_in_unknown_handler) punk::auto_exec absolute_path required"} {
#re-resolve.
puts "(unknown-handler): auto_execok for $name requires absolute path. Re-resolving $name with absolute path."
set ext [file extension $name]
set associnfo [punk::auto_exec::shell_open_command $ext]
set valuetype [dict get $associnfo type]
set assoc [dict get $associnfo value]
set ftype [dict get $associnfo filetype]
set fullpath [file normalize $name]
#at least for .url files - long paths (paths with multiple spaces?) can fail to run. Using the short path seems to fix this.
#This seems hacky but anyway..
set attributes [file attributes $fullpath]
if {[dict exists $attributes -shortname]} {
set fullpath [dict get $attributes -shortname]
}
set new [punk::auto_exec::shell_command_as_tcl_list -type $valuetype $assoc $fullpath]
}
set redir ">&@stdout <@stdin"
uplevel 1 [list ::catch [concat exec $redir $new [lrange $args 1 end]] ::tcl::UnknownResult ::tcl::UnknownOptions]
@ -6108,6 +6281,16 @@ namespace eval punk {
}
}
#This should be designed to be useful on all platforms.
#Case sensitivity represents a difficulty because even on a particular platform
#- different filesystems or folders may have different case sensitivity configurations.
#as a first step - we can detect windows and mac platforms and treat paths as case-insensitive, vs case-sensitive on other unix-like platforms.
#as a second step - we will consider running a test on each path to determine if the folder at the leaf level is case-sensitive or not.
#- and then use that information to determine how to treat the executables in that path.
#This may be a bit of a performance hit - so we may want to cache the results of this test for each path - and provide a way to clear the cache if needed.
#Alternatively we could just provide an option to treat all paths as case-sensitive or case-insensitive.
#Windows executable search location order:
#1. The current directory.
#2. The directories that are listed in the PATH environment variable.
@ -6203,8 +6386,12 @@ namespace eval punk {
#(This seems to occur when the pattern does *not* contain a wildcard and is probably a bug)
#We would rather report results with the actual case as they appear on the filesystem - so we try to normalize the results.
#The normalization doesn't work as expected - but can be worked around by using 'string range $e 0 end' instead of just $e - which seems to force Tcl to give us the actual case from the filesystem rather than the case as globbed for.
#(another workaround is to use a degenerate case glob e.g when looking for 'SDX.exe' to glob for '[S]DX.exe')
#(another workaround is to use a degenerate case glob e.g when looking for 'SDX.exe' to glob for '[S]DX.exe'
# but tcl's glob does not respect the case of even the character-class pattern - so this is not a reliable workaround).
#see punk::fglob for a work-in-progress glob implementation which gives us more control over case sensitivity and the case of results on windows.
set globresults [lsort -unique [glob -nocomplain -directory $pnormglob -types {f x} {*}$binglobs]]
#JJJ
set executables [list]
foreach e $globresults {
puts stderr "glob result: $e"
@ -6596,6 +6783,29 @@ namespace eval punk {
#These aliases work fine for interactive use - but the result is always a string int-rep
#interp alias {} ~ {} file join $::env(HOME) ;#HOME must be capitalized to work cross platform (lowercase home works on windows - but probably not elsewhere)
#interp alias {} ~ {} apply {args {file join $::env(HOME) $args}}
namespace eval argdoc {
punk::args::define {
@id -id ::punk::~
@cmd -name "punk::~"\
-summary\
"Return home directory"\
-help\
"Return the home directory path. With additional arguments,
return the path obtained by joining the home directory with
the supplied arguments.
usage e.g
cd [~]
(change to home directory)
.// [~] .config
(change to .config directory within home directory
and list contents.)
"
@opts
@values -min 0 -max -1
arg -type any -optional 1 -multiple 1
}
}
proc ~ {args} {
set hdir [punk::valcopy $::env(HOME)]
file pathtype $hdir
@ -7811,9 +8021,10 @@ namespace eval punk {
# -------------------------------------------------------
set title "[a+ brightgreen] Filesystem navigation: "
set cmdinfo [list]
lappend cmdinfo [list ./ "?${I}subdir${NI}?" "view/change directory"]
lappend cmdinfo [list ../ "" "go up one directory"]
lappend cmdinfo [list ./new "${I}subdir${NI}" "make new directory and switch to it"]
lappend cmdinfo [list ./ "?${I}glob${NI}?" "view/change dir, list dirs."]
lappend cmdinfo [list ../ "?${I}path${NI}" "go up one dir, then to path if given"]
lappend cmdinfo [list .// "?${I}glob${NI}?" "view/change dir, list dirs and files"]
lappend cmdinfo [list ./new "${I}subdir${NI}..." "make new dir or dirs and show status"]
lappend cmdinfo [list fcat "${I}file ?file?...${NI}" "cat file(s)"]
set t [textblock::class::table new -minwidth 80 -show_seps 0]
foreach row $cmdinfo {
@ -7859,7 +8070,7 @@ namespace eval punk {
lappend cmdinfo [list i "${I}cmd${NI} ?${I}subcommand${NI}...?" "Show usage for a command or ensemble subcommand"]
lappend cmdinfo [list s "${I}cmd${NI} ?${I}subcommand${NI}...?" "Show synopsis for a command or ensemble subcommand"]
lappend cmdinfo [list eg "${I}cmd${NI} ?${I}subcommand${NI}...?" "Show example from manpage"]
lappend cmdinfo [list corp "${I}proc${NI}" "View proc body and arguments"]
lappend cmdinfo [list corp "${I}proc${NI}" "View proc body and arguments with basic highlighting"]
set t [textblock::class::table new -minwidth 80 -show_seps 0]
foreach row $cmdinfo {
$t add_row $row

24
src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/punk/ansi-0.1.1.tm

@ -1148,7 +1148,7 @@ tcl::namespace::eval punk::ansi {
"The grepstr command can find strings in ANSI text even if there are interspersed
ANSI colour codes etc. Even if a word has different coloured/styled letters, the
regex can match the plaintext. (Search is performed on ansistripped text, and then
the matched sections are highlighted and overlayed on the original styled/colourd
the matched sections are highlighted and overlayed on the original styled/coloured
input.
If the input string has ANSI movement codes - the resultant text may not be directly
@ -3524,9 +3524,9 @@ Brightblack 100 Brightred 101 Brightgreen 102 Brightyellow 103 Brightblu
underline {lappend t 4}
underlinedefault {lappend t 59}
underextendedoff {
#Remove any existing 4:1 etc extended underline codes
#NOTE: struct::set result order can differ depending on whether tcl/critcl impl is used.
#FIXED: Using punk::lib::ldiff instead of struct::set difference for consistent ordering.
#lremove any existing 4:1 etc
#NOTE struct::set result order can differ depending on whether tcl/critcl imp used
#set e [struct::set difference $e [list 4:1 4:2 4:3 4:4 4:5]]
set e [punk::lib::ldiff $e [list 4:1 4:2 4:3 4:4 4:5]]
lappend e 4:0
}
@ -3543,8 +3543,9 @@ Brightblack 100 Brightred 101 Brightgreen 102 Brightyellow 103 Brightblu
lappend e 4:4
}
underdashed - underdash {
# FIXED: Extended codes with colon suppress normal SGR attributes when in same escape sequence
# on terminals that don't support the extended codes. Emit as separate sequence if needed.
#TODO - fix
# extended codes with colon suppress normal SGR attributes when in same escape sequence on terminal that don't support the extended codes.
# need to emit in
lappend e 4:5
}
doubleunderline {lappend t 21}
@ -4732,11 +4733,7 @@ Brightblack 100 Brightred 101 Brightgreen 102 Brightyellow 103 Brightblu
if {[string length $uri] > 2083} {
error "punk::ansi::hyperlink uri too long: limit 2083"
}
#SECURITY: Sanitize hyperlink ID to prevent injection attacks
#Current mapping: : -> . ; -> , prevents common delimiter issues
#FUTURE: Consider additional restrictions on special characters (=, &, ?, #, etc.)
#to prevent URL parameter injection or other hyperlink protocol exploits
set id [string map {: . {;} ,} $id]
set id [string map {: . {;} ,} $id] ;#avoid some likely problematic ids. TODO - review, restrict further.
set params "id=$id"
return "\x1b\]8\;$params\;$uri\x1b\\"
}
@ -6829,9 +6826,8 @@ tcl::namespace::eval punk::ansi::ta {
# -- --- --- ---
#variable re_ansi_detect1 "${re_csi_code}|${re_esc_osc1}|${re_esc_osc2}|${re_esc_osc3}|${re_standalones}|${re_ST}|${re_g0_open}|${re_g0_close}"
# -- --- --- ---
#handrafted TRIE version of above. Somewhat difficult to construct and maintain.
#FUTURE: Consider using a regexp TRIE generator that works with Tcl regexes for maintainability.
#This does make things quicker - but it's too early to finalise the detect/split regexes (e.g missing \U0090 ) - will need to be redone.
#handrafted TRIE version of above. Somewhat difficult to construct and maintain. TODO - find a regexp TRIE generator that works with Tcl regexes
#This does make things quicker - but it's too early to finalise the detect/split regexes (e.g missing \U0090 ) - will need to be redone.
#variable re_ansi_detect {(?:\x1b(?:\((?:0|B)|\[(?:[\x20-\x2f\x30-\x3f]*[\x40-\x7e])|\](?:(?:[^\007]*)\007|(?:(?!\x1b\\).)*\x1b\\)|(?:P|X|\^|_)(?:(?:(?!\x1b\\|\007).)*(?:\x1b\\|\007))|c|7|8|M|E|D|H|=|>|(?:#(?:3|4|5|6|8))))|(?:\u0090|\u0098|\u009E|\u009F)(?:(?!\u009c).)*(?:\u009c)|(?:\u009b)[\x20-\x2f\x30-\x3f]*[\x40-\x7e]|(?:\u009d)(?:[^\u009c]*)?\u009c}
#NOTE - the literal # char can cause problems in expanded syntax - even though it's within a bracketed section. \# seems to work though.

35
src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/punk/args-0.2.1.tm

@ -6148,7 +6148,7 @@ tcl::namespace::eval punk::args {
### set e [lindex $clauseval $clausecolumn]
### set e_check [lindex $clauseval_check $clausecolumn]
### #//review - we may need '?' char on windows
### if {!([tcl::string::length $e_check]>0 && ![regexp {[\"*<>\;]} $e_check])} {
### if {!([tcl::string::length $e_check]>0 && ![regexp {[\"*<>]} $e_check])} {
### #what about special file names e.g on windows NUL ?
### set msg "$argclass $argname for %caller% requires type '$type'. Received: '$e' which doesn't look like it could be a file or directory"
### return -options [list -code error -errorcode [list PUNKARGS VALIDATION [list typemismatch $type] -badarg $argname -argspecs $argspecs]] $msg
@ -6671,9 +6671,12 @@ tcl::namespace::eval punk::args {
directory -
existingfile -
existingdirectory {
#//review - we may need '?' char on windows
if {!([tcl::string::length $e_check]>0 && ![regexp {[\"*<>\;]} $e_check])} {
#what about special file names e.g on windows NUL ?
#we have to support file and directory names on all platforms - and even characters illegal on a filesystem/platform may need to be passed.
#For example a file/folder may be created with an illegal name on a platform (or mounted on it) and be mapped to another string on the filesystem
#- yet it may remain accessible to commands such as file stat etc via the string with 'illegal' characters as well as its underlying stored (mapped) name.
#NUL is almost universally problematic - so we will reject.
if {[tcl::string::length $e_check]==0 || [string first \0 $e_check] >= 0} {
set msg "$argclass $argname for %caller% requires type '$type'. Received: '$e' which doesn't look like it could be a file or directory"
lset clause_results $c_idx $a_idx [list err [list typemismatch $type] msg $msg]
continue
@ -6693,6 +6696,30 @@ tcl::namespace::eval punk::args {
}
lset clause_results $c_idx $a_idx 1
}
existingportablefile -
existingportabledirectory -
portablefile -
portabledirectory {
if {[tcl::string::length $e_check]==0 || [string first \0 $e_check] >= 0 || [punk::winpath::illegalname_test $e_check]} {
set msg "$argclass $argname for %caller% requires type '$type'. Received: '$e' which doesn't look like it could be a portable file or directory (must pass punk::winpath::illegalname_test)"
lset clause_results $c_idx $a_idx [list err [list typemismatch $type] msg $msg]
continue
}
if {$type eq "existingportablefile"} {
if {![file exists $e_check]} {
set msg "$argclass $argname for %caller% requires type '$type'. Received: '$e' which is not an existing file"
lset clause_results $c_idx $a_idx [list err [list typemismatch $type] msg $msg]
continue
}
} elseif {$type eq "existingportabledirectory"} {
if {![file isdirectory $e_check]} {
set msg "$argclass $argname for %caller% requires type '$type'. Received: '$e' which is not an existing directory"
lset clause_results $c_idx $a_idx [list err [list typemismatch $type] msg $msg]
continue
}
}
lset clause_results $c_idx $a_idx 1
}
char {
#review - char vs unicode codepoint vs grapheme?
if {[tcl::string::length $e_check] != 1} {

33
src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/punk/args/moduledoc/tclcore-0.1.0.tm

@ -4490,17 +4490,15 @@ tcl::namespace::eval punk::args::moduledoc::tclcore {
# -- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---
# -- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---
#DOCUMENTED: CLOCK_ARITHMETIC and TIME ZONES references added to help text
#TODO - add CLOCK_ARITHMETIC documentation
#TODO - TIME ZONES documentation?
lappend PUNKARGS [list {
@id -id ::tcl::clock::add
@cmd -name "Built-in: tcl::clock::add"\
-summary\
"Add an offset to timeVal in seconds (base 1970-01-01 00:00 UTC)"\
-help\
"Adds a (possibly negative) offset to a time that is expressed as an integer number of seconds.
CLOCK ARITHMETIC: Supports count_unit pairs (e.g., 1 month, 2 weeks) for flexible date arithmetic.
TIME ZONES: Use -timezone option with values like :UTC, :localtime, or location-based zones (:America/New_York).
See the clock manpage for complete CLOCK ARITHMETIC and TIME ZONES documentation."
"Adds a (possibly negative) offset to a time that is expressed as an integer number of seconds. See CLOCK ARITHMETIC for a full description."
@leaders -min 1 -max -1
timeVal -type integer|literal(now) -help\
"Time value in integer number of seconds since epoch time.
@ -5427,6 +5425,23 @@ tcl::namespace::eval punk::args::moduledoc::tclcore {
glob */
}]}
except that the first case doesn't return the trailing “/” and is more platform independent."
#todo - review eopts handling.
#The actual glob function will treat the first non-dashed argument as the first pattern, whereas
#punk::args will treat the first invalid option or non-dashed as the end of options and the start of patterns.
#This means that if you have a pattern that starts with a dash, you must use -- to mark the end of options.
#We need a way to force punk::args to have that more restrictive behaviour in this case so that the help is aligned.
#ie tcl glob requires -- whenever values start with a dash, but punk::args only requires it only when the first of multiple values
#start with a dash or when a single has a prefix-match for a flag.
#Arguably punk::args should be more restrictive and require -- whenever any value starts with a dash.
#e.g punk::args will accept "glob -nonflag" but will warn of "glob -nonflag -nonflag" as an invalid option.
#This helps provide warnings for mistyped flags, but is potentially confusing.
#In any case, the help for glob should be consistent with the actual behaviour of glob, so we need to make sure that punk::args
#is configured to require -- whenever any value starts with a dash.
#This requires either a change to the default behaviour of punk::args when {-- -type none} is used,
#or a new option to control this behaviour.
#A new option may be preferable to avoid changing the default behaviour of punk::args in other cases where it may be desirable
#to allow values starting with dashes without requiring --.
#(e.g when a command only accepts a single value and that value may start with a dash)
-- -type none -help\
"Marks the end of switches. The argument following this one will be treated as a pattern even if it
starts with a -."
@ -5760,8 +5775,8 @@ tcl::namespace::eval punk::args::moduledoc::tclcore {
@form -form configure
@values -min 0 -max -1
#FUTURE: Implement choice-parameters for better validation
#Would allow: -choiceparameters {literalprefix type} for smarter option validation
#TODO - choice-parameters
#?? -choiceparameters {literalprefix type}
optionpair\
-type {string any}\
-typesynopsis {${$I}-option value${$NI}}\
@ -5769,7 +5784,7 @@ tcl::namespace::eval punk::args::moduledoc::tclcore {
-multiple 1\
-choicerestricted 0\
-choices {{-command string} {-granularity int} {-milliseconds int} {-seconds int} {-value any}}\
-help "Option-value pairs. Valid options: -command, -granularity, -milliseconds, -seconds, -value"
-help "(todo: adjust args definition to validate optionpairs properly)"
@form -form query
@values -min 0 -max 1
@ -6943,7 +6958,7 @@ tcl::namespace::eval punk::args::moduledoc::tclcore {
@opts -type string -parsekey "" -group "NESTED LIST OPTIONS" -grouphelp\
"These options are used to search lists of lists. They may be used with any other options."
-stride -type integer -default 1 -typesynopsis strideLength -help\
-stride -type integer -default 1 -range {1 ""} -typesynopsis strideLength -help\
"If this option is specified, the list is treated as consisting of groups of ${$I}strideLength${$NI} elements and the
groups are searched by either their first element or, if the ${$B}-index${$N} option is used, by the element within
each group given by the first index passed to -index (which is then ignored by -index). The resulting

786
src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/punk/auto_exec-0.1.0.tm

@ -0,0 +1,786 @@
# -*- tcl -*-
# Maintenance Instruction: leave the 999999.xxx.x as is and use punkshell 'dev make' or bin/punkmake to update from <pkg>-buildversion.txt
# module template: shellspy/src/decktemplates/vendor/punk/modules/template_module-0.0.4.tm
#
# Please consider using a BSD or MIT style license for greatest compatibility with the Tcl ecosystem.
# Code using preferred Tcl licenses can be eligible for inclusion in Tcllib, Tklib and the punk package repository.
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
# (C) 2026
#
# @@ Meta Begin
# Application punk::auto_exec 0.1.0
# Meta platform tcl
# Meta license <unspecified>
# @@ Meta End
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
## Requirements
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
package require Tcl 8.6-
#----------------------
# registry notes
#Computer\HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\ApplicationAssociationToasts
# - associates applications/file types with extensions and protocols.
#.cp items have a shell\cplopen\command subkey instead of shell\open\command
#- but the command string has the same format and placeholders as the shell\open\command entries for other file types,
#so we can handle them in the same way as other file types when we extract the associated command string.
#----------------------
tcl::namespace::eval punk::auto_exec {
namespace eval argdoc {
variable PUNKARGS
set I "\x1b\[3m" ;# [a+ italic]
set NI "\x1b\[23m" ;# [a+ noitalic]
set B "\x1b\[1m" ;# [a+ bold]
set N "\x1b\[22m" ;# [a+ normal]
set T "\x1b\[1\;4m" ;# [a+ bold underline]
set NT "\x1b\[22\;24m\x1b\[4:0m" ;# [a+ normal nounderline]
}
variable PUNKARGS
lappend PUNKARGS [list {
@id -id "::punk::auto_exec::shell_open_command"
@cmd -name "punk::auto_exec::assoc" -help\
{Returns the raw 'open' command string associated with the file type for the given file extension,
by looking up the user-specific association in the registry and falling back to the system
association if no user-specific association is found.
The resulting command string can contain placeholders like "%1" for the file name.
e.g .py -> "c:\Program Files\Python\python.exe" "%1"
e.g .rb -> "C:\tools\ruby31\bin\ruby.exe" "%1" %*
e.g .vbs -> "%SystemRoot%\System32\WScript.exe" "%1" %*
Note that the resulting string has unescaped backslashes within double quotes, so it is
not suitable for direct execution by Tcl without further processing to handle the backslashes
and placeholders.
see 'shell_command_as_tcl_list' for processing the command string into a Tcl list of command and
arguments with placeholders substituted.
}
@opts
@values -min 0 -max 1
ext -type string -default "" -optional true -help\
"File extension to look up, e.g .txt or .py"
}]
proc shell_open_command {ext} {
#look up assoc and ftype to find associated file type and application.
#Note that the windows 'ftype' and 'assoc' commands only examine the system's data - not the user-specific data which may override the system data.
#to get the user-specific associations we need to read the registry keys.
#extensions in the registry seem to be stored lower case wnd with a leading dot.
set lext [string tolower $ext]
package require registry
set user_assoc_path [join [list HKEY_CURRENT_USER Software Microsoft Windows CurrentVersion Explorer FileExts $lext UserChoice] "\\"]
#The UserChoice subkey under this key is where the user-specific association is stored when the user has made a choice. It contains a Progid value which points to the associated file type.
#It can return something like "Python.File" or "Applications\python.exe" or even tcl_auto_file (for .tcl files associated with tclsh by a tcl installer)
set openverb "open"
#The UserChoice key may not exist if the user hasn't made an explicit choice, in which case we fall back to the system association which is stored under HKEY_CLASSES_ROOT\$ext (which also contains user-specific overrides but is more cumbersome to query for them)
if {![catch {registry get $user_assoc_path Progid} user_choice]} {
if {$user_choice ne ""} {
#examples such as Applications\python.exe and tcl_auto_file are found under HKEY_CURRENT_USER\Software\Classes
#they then have a sub-path shell\open\command which contains the command to open files of that type, which is what we need to extract the associated application.
#it is faster to query for the key directly within a catch than to retrieve keys as lists and iterate through them to find the one we want.
#special case .cpl cplfile
if {$user_choice eq "cplfile"} {
set openverb "cplopen"
}
set verbinfo [ftype $user_choice]
if {[dict exists $verbinfo $openverb]} {
set ftypeinfo [dict get $verbinfo $openverb]
return [dict set ftypeinfo filetype $user_choice]
} else {
return [dict create type "notfound" value "" scope user filetype $user_choice]
}
} else {
#review - is it possible for Progid to be empty string? If so we should probably fall back to system association instead of returning no association.
#alternatively it could mean the user has explicitly chosen to have no association for this file type - in which case returning an empty association may be the correct behaviour.
return [dict create type "empty" value "" scope user filetype ""]
}
} else {
#fall back to system association and ftype
if {![catch {registry get [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes $lext] "\\"] ""} ftype]} {
#ftype is the file type associated with the extension, e.g "Python.File"
#we then need to look up the command associated with this file type under the same path but with the file type instead of the extension and with the additional sub-path shell\open\command
#special case .cpl cplfile
if {$ftype eq "cplfile"} {
set openverb "cplopen"
}
set verbinfo [ftype $ftype]
if {[dict exists $verbinfo $openverb]} {
set ftypeinfo [dict get $verbinfo $openverb]
return [dict set ftypeinfo filetype $ftype]
} else {
return [dict create type "notfound" value "" scope system filetype $ftype]
}
} else {
return [dict create type "notfound" value "" scope "" filetype ""]
}
}
#shouldn't get here - there is a return in each branch above.
return "no-result"
}
# %1 - standard placeholder for the first file parameter.
# %L (or %l) - Long File Name form of the path.
# %* - replaces with all parameters passed to the command. (ie not including the command itself)
# %W (or %w) - working directory.
# other placeholders that we won't handle for now:
# %I (or %i) - handle to an Item ID List (IDList)
# e.g for filetype ArchiveFolder (associated with .7z, .gz, .CPIO etc) we have "%SystemRoot%\Explorer.exe" /idlist,%I,%L
# %D (or %d) - Desktop absolute parsing name of the first parameter
# (for items that don't have file system paths) will be the same as %1 for file system items.
#todo - we need to substitute other placeholders such as %SystemRoot% etc. (case insensitively)
# - but only if the type of the key is expand_sz.
#These are environment variables that can be used in the command string.
#when type is sz - only the single letter placeholders are substituted, and environment variables are not substituted.
#when type is expand_sz - the single letter placeholders are substituted, and environment variables are also substituted
#(case insensitively) - but only if they are in the form %VAR%
#A matching environment variable will take precedence over a single letter placeholder if there is a conflict,
#e.g if the string is %1% and there is an environment variable named "1" then it will be substituted instead of the %1 placeholder.
#a non-existant environment variable will not be substituted and it's wrapping % characters will not be included in the output.
#similarly a single letter placeholder that is not recognised, such as %x, will result in only the x being included in the output without the leading % character.
#todo - tests for shell_command_as_tcl_list with various combinations of placeholders, quoted and unquoted,
#and with both reg_sz and reg_expand_sz types, and with various edge cases such as missing arguments, extra arguments,
#empty arguments, unrecognized placeholders, invalid env var references etc.
lappend PUNKARGS [list {
@id -id "::punk::auto_exec::shell_command_as_tcl_list"
@cmd -name "punk::auto_exec::shell_command_as_tcl_list" -help\
{}
@opts
-type -type string -default sz -choices {sz expand_sz} -help\
"The type of the registry value containing the command string, which determines how
environment variables are substituted. sz means environment variables will not
be substituted, and expand_sz means environment variables will be substituted
if they are in the form %VAR% (case insensitively) and will take precedence over
single letter placeholders if there is a conflict.
In either case, the single letter placeholders will be substituted as follows:
%1 - standard placeholder for the first file parameter.
%L (or %l) - Long File Name form of the path.
%* - replaced with all subsequent parameters passed to the command.
(but not including the script name itself)
%W (or %w) - working directory."
-workingdir -type string -default "" -help\
"The working directory to substitute for the %W (or %w) placeholder."
@values -min 1 -max -1
commandspec -type string -help\
"The command string to process, which can contain placeholders like %1 for the file name,
and a list of arguments to substitute for the placeholders. The command string is typically
obtained from the registry for a file type association, and the arguments are typically the
file name and other parameters to substitute into the command string."
arg -type any -multiple 1 -optional 1 -help\
{One or more arguments to substitute for the placeholders in the command string.
The first argument (often a script or document path) will be substituted for %1,
the second argument will be substituted for %2, and so on. If the command string
contains a %* placeholder, then all of the arguments will be substituted for that
placeholder starting from %2.
If there are more placeholders than arguments, then the extra placeholders will be
substituted with empty string.
If missing arguments are specified in the commandspec as quoted strings, eg "%3" then
corresponding empty strings as separate arguments will be included in the output.}
}]
proc shell_command_as_tcl_list {args} {
set argd [punk::args::parse $args withid ::punk::auto_exec::shell_command_as_tcl_list]
lassign [dict values $argd] _leaders opts values received
set type [dict get $opts -type]
set workingdir [dict get $opts -workingdir]
set commandspec [dict get $values commandspec]
if {[dict exists $received arg]} {
set arglist [dict get $values arg]
} else {
set arglist [list]
}
set result [list]
set quoted [list 0] ;#track whether the current chunk is quoted or not. Only the last item is relevant.
set chars [split [string trim $commandspec] ""]
set in_quote 0
set current_chunk {}
set new_chunk 1
set got_placeholder 0
for {set i 0} {$i < [llength $chars]} {incr i} {
set char [lindex $chars $i]
if {$in_quote} {
if {$char eq "\""} {
if {$got_placeholder} {
#wasn't a valid placeholder - % not emitted.
set got_placeholder 0
}
#The windows implementation doesn't seem to close off a chunk just because it encounters a closing quote,
#so we don't do that either.
#The closing quote just affects whether the next space will terminate the chunk or not.
set in_quote 0
} else {
if {$char eq "%"} {
#we do not handle the trailing % of an env var such as %VAR% here
#- as we scan for that in the default case of the switch below.
if {$got_placeholder} {
#this is a % escaped by doubling up ie a literal % in the output
append current_chunk "%"
set got_placeholder 0
} else {
set got_placeholder 1
}
} elseif {$got_placeholder} {
if {$type eq "expand_sz"} {
#we scan for environment variables in the form %VAR% (case insensitively) and substitute them if found, taking precedence over single letter placeholders if there is a conflict.
#a non-existant environment variable will not be substituted and it's wrapping % characters will not be included in the output.
set env_var_name $char
for {set j [expr {$i+1}]} {$j < [llength $chars]} {incr j} {
set next_char [lindex $chars $j]
if {$next_char in {" " \t}} {
#end of env var name - we don't expect to see spaces within environment variable names,
#treat space as terminator indicating failure to match.
break
} elseif {$next_char eq "\""} {
#end of env var name - we don't expect to see quotes within environment variable names,
#treat quote as terminator indicating failure to match.
break
} elseif {$next_char eq "%"} {
#end of *possible* env var name
break
} else {
append env_var_name $next_char
}
}
if {$next_char eq "%"} {
#we found a closing % character, so we have a possible env var name between the two % characters.
#we will substitute the env var value if it exists,
# or the value of any single letter placeholder if there is a match,
# or the original string between the two % characters if there is no match for either an env var or a single letter placeholder.
if {[info exists ::env($env_var_name)]} {
append current_chunk $::env($env_var_name)
set got_placeholder 0
set i $j ;#advance past the env var name and closing % character for next iteration of main loop
continue
}
} else {
#we didn't find a closing % character, so this isn't a valid env var reference.
set env_var_name ""
#fall through to single % placeholder handling below,
#which will treat the first character after the % as a single letter placeholder and include the rest of the string as literal characters in the output.
}
}
switch -- $char {
1 - L - l {
append current_chunk [lindex $arglist 0]
}
2 - 3 - 4 - 5 - 6 - 7 - 8 - 9 {
append current_chunk [lindex $arglist $char-1]
}
D - d {
#review
#we don't have a way to represent an Item ID List (IDList) in our API, so we'll just substitute the same value as %1 for now,
#which is what windows seems to do for file system items that have file paths.
append current_chunk [lindex $arglist 0]
}
I - i {
#we don't have a way to represent an Item ID List (IDList) in our API, so we'll just substitute something for now.
#format seems to be 9 and 5 digits :nnnnnnnnn:nnnnn
#REVIEW! unknown consequences!
append current_chunk ":000000000:00000"
}
"*" {
append current_chunk [lrange $arglist 1 end]
}
W - w {
append current_chunk $workingdir
}
default {
if {$type eq "expand_sz"} {
#if expand_sz we would have already scanned for environment variables in the form %VAR% and substituted them if found,
#taking precedence over single letter placeholders if there is a conflict.
append current_chunk "$char" [string range $env_var_name 1 end] ;#if env_var_name is empty string then this will just append the single char after the % as a literal,
#which is the correct behaviour for unrecognized placeholders such as %x and for invalid env var references such as %NONEXISTANT%
} else {
append current_chunk $char
}
}
}
set got_placeholder 0
} else {
append current_chunk $char
}
}
} else {
#NOT in quoted string
if {$char eq "\""} {
if {$got_placeholder} {
append current_chunk "%" ;#wasn't a valid placeholder char, so we need to add the % that we stripped off back into the output
set got_placeholder 0
}
set in_quote 1
set new_chunk 0
lset quoted end 1 ;#we'll mark as quoted if any quote found in it - review
} elseif {$char in [list " " \t ]} {
#we don't expect to see tabs or other whitespace characters as separators in the command string, but we'll treat tab the same as spaces just in case.
if {$got_placeholder} {
#wasn't a valid placeholder char. The % is stripped and not included in the output.
set got_placeholder 0
}
#space terminates an unquoted chunk, so we add it to the result list and start a new chunk.
#we also need to ensure that consecutive spaces are treated as a single separator and don't result in empty items in the output list,
if {!$new_chunk} {
if {$current_chunk ne "" || $current_chunk eq "" && [lindex $quoted end]} {
#we add the current chunk to the result list if it's not empty, or if it is empty but is quoted (because in that case we want to preserve it as an empty argument).
lappend result $current_chunk
lappend quoted 0
set current_chunk {}
}
set new_chunk 1
}
for {set j [expr {$i+1}]} {$j < [llength $chars]} {incr j} {
if {[lindex $chars $j] in {" " \t}} {
incr i
} else {
break
}
}
} else {
if {$got_placeholder} {
if {$type eq "expand_sz"} {
#we scan for environment variables in the form %VAR% (case insensitively) and substitute them if found, taking precedence over single letter placeholders if there is a conflict.
#a non-existant environment variable will not be substituted and it's wrapping % characters will not be included in the output.
set env_var_name $char
for {set j [expr {$i+1}]} {$j < [llength $chars]} {incr j} {
set next_char [lindex $chars $j]
if {$next_char in {" " \t}} {
#end of env var name - we don't expect to see spaces within environment variable names,
#treat space as terminator indicating failure to match.
break
} elseif {$next_char eq "\""} {
#end of env var name - we don't expect to see quotes within environment variable names,
#treat quote as terminator indicating failure to match.
break
} elseif {$next_char eq "%"} {
#end of *possible* env var name
break
} else {
append env_var_name $next_char
}
}
if {$next_char eq "%"} {
#we found a closing % character, so we have a possible env var name between the two % characters.
#we will substitute the env var value if it exists,
# or the value of any single letter placeholder if there is a match,
# or the original string between the two % characters if there is no match for either an env var or a single letter placeholder.
if {[info exists ::env($env_var_name)]} {
append current_chunk $::env($env_var_name)
set got_placeholder 0
set i $j ;#advance past the env var name and closing % character for next iteration of main loop
continue
}
} else {
#we didn't find a closing % character, so this isn't a valid env var reference.
set env_var_name ""
#fall through to single % placeholder handling below,
#which will treat the first character after the % as a single letter placeholder and include the rest of the string as literal characters in the output.
}
}
switch -- $char {
"%" {
append current_chunk "%"
set got_placeholder 1
continue
}
1 -
L - l {
#append current_chunk [lindex $arglist 0]
set append_value [string trim [lindex $arglist 0]]
foreach ch [split $append_value ""] {
if {$ch eq " "} {
#we need to split on spaces within arguments as well, because unquoted %1 can expand to multiple arguments if the first argument contains spaces, and we need to preserve the correct splitting of arguments in that case.
#e.g if %1 is substituted with "C:\My Documents\file.txt" then we need to split that into three items in the output list: "C:\My", "Documents\file.txt"
if {$current_chunk ne ""} {
lappend result $current_chunk
lappend quoted 0
set current_chunk {}
}
set new_chunk 1
} else {
append current_chunk $ch
set new_chunk 0
}
}
}
2 - 3 - 4 - 5 - 6 - 7 - 8 - 9 {
append current_chunk [lindex $arglist $char-1]
}
D - d {
#review
#we don't have a way to represent an Item ID List (IDList) in our API, so we'll just substitute the same value as %1 for now,
#which is what windows seems to do for file system items that have file paths.
append current_chunk [lindex $arglist 0]
}
I - i {
#we don't have a way to represent an Item ID List (IDList) in our API, so we'll just substitute something for now.
#format seems to be 9 and 5 digits :nnnnnnnnn:nnnnn
#REVIEW! unknown consequences!
append current_chunk ":000000000:00000"
}
"*" {
#the window implementation of unquoted %* always seems to emit the previous chunk before the %* placeholder as a separate item
#in the command line, even if there is no space between them, so we will do the same.
#Note that a following string (quoted or not) immediately after the %* will be appended to the last item in the output list rather than being a separate item,
#which is also consistent with the microsoft implementation.
if {!$new_chunk} {
if {$current_chunk ne "" || $current_chunk eq "" && [lindex $quoted end]} {
lappend result $current_chunk
lappend quoted 0
set current_chunk {}
}
}
lappend result {*}[lrange $arglist 1 end]
lappend quoted {*}[lrepeat [llength $arglist] 0]
}
W - w {
append current_chunk $workingdir
}
default {
if {$type eq "expand_sz"} {
#if expand_sz we would have already scanned for environment variables in the form %VAR% and substituted them if found,
#taking precedence over single letter placeholders if there is a conflict.
append current_chunk "$char" [string range $env_var_name 1 end] ;#if env_var_name is empty string then this will just append the single char after the % as a literal,
#which is the correct behaviour for unrecognized placeholders such as %x and for invalid env var references such as %NONEXISTANT%
} else {
append current_chunk $char
}
}
}
set got_placeholder 0
set new_chunk 0
} else {
if {$char eq "%"} {
set got_placeholder 1
} else {
append current_chunk $char
set new_chunk 0
}
}
}
}
}
if {$current_chunk ne "" || $current_chunk eq "" && [lindex $quoted end]} {
#review - edge case - if the command string ends with a space then we would have already added the last chunk to the result list and started a new chunk, so we shouldn't add an empty chunk to the result list in that case.
#however if the last chunk was quoted and is empty because the command string ends with a placeholder that is substituted with an empty string, then we should add the empty chunk to the result list.
lappend result $current_chunk
}
return $result
}
namespace eval punk::auto_exec::system {
proc assoc_get_info {ext} {
set lext [string tolower $ext]
set result [dict create system "" user ""]
set user_assoc_path [join [list HKEY_CURRENT_USER Software Microsoft Windows CurrentVersion Explorer FileExts $lext UserChoice] "\\"]
if {![catch {registry get $user_assoc_path Progid} user_choice]} {
dict set result user $user_choice
}
if {![catch {registry get [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes $lext] "\\"] ""} ftype]} {
dict set result system $ftype
}
return $result
}
}
lappend PUNKARGS [list {
@id -id "::punk::auto_exec::assoc"
@cmd -name "punk::auto_exec::assoc"\
-summary\
"Look up the associated file type (system and user) for a file extension"\
-help\
"Get the associated file type for a file extension by looking up the user-specific
file type in the registry and falling back to the system file type if no
user-specific association is found.
Returns a dict with keys ${$I}system${$NI} and ${$I}user${$NI}.
One or more of the key values may be empty string if there is no defined
file type for the extension.
This is somewhat like the windows 'assoc' command except that the windows command
only looks up the system association and does not take into account any user-specific
overrides.
This function returns both values in the result dictionary if they are available."
@opts
@values -min 0 -max 1
ext -type string -default "" -optional true -help\
"File extension to look up, e.g .txt or .py"
}]
proc assoc {args} {
package require registry
set argd [punk::args::parse $args withid ::punk::auto_exec::assoc]
set ext [dict get $argd values ext]
if {$ext ne ""} {
return [punk::auto_exec::system::assoc_get_info $ext]
} else {
#look up all associated ftypes
set user_ftypes [registry keys {HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts} .*]
set system_ftypes [registry keys {HKEY_LOCAL_MACHINE\SOFTWARE\Classes} .*]
set all_ftypes [lsort -unique [concat $user_ftypes $system_ftypes]]
set results [list]
foreach ftype $all_ftypes {
dict set results $ftype [punk::auto_exec::system::assoc_get_info $ftype]
}
return $results
}
}
namespace eval argdoc {
lappend PUNKARGS [list {
@id -id "::punk::auto_exec::ftype"
@cmd -name "punk::auto_exec::ftype"\
-summary\
"Look up shell verb command values from windows file type."\
-help\
"Get the associated shell verb information (such as open) for a file type by looking up the user-specific
association in the registry and falling back to the system association if no
user-specific association is found.
Returns a dict of dicts with toplevel keys for each shell verb (e.g open, runas) and values that are dicts with keys
${$I}type${$NI} and ${$I}value${$NI} and ${$I}scope${$NI}, where
type is determined from the registry value type (e.g sz or expand_sz)
string is the raw command string from the registry
scope is either "user" or "system" depending on whether the value was found in the user-specific registry keys or
the system registry keys.
This is somewhat like the windows 'ftype' command except that the windows command only looks for the 'open' verb and
only looks up the system association and does not take into account any user-specific
overrides.
The file type can be looked up using the ${$B}assoc${$N} function in this package.
The command string can contain placeholders like \"%1\" for the file name, and environment variables
in the form %VAR% (case insensitive) if the registry value type is reg_expand_sz (expand_sz),
which will be substituted by the ${$B}shell_command_as_tcl_list${$N} function when processing the command
string into a Tcl list of command and arguments with placeholders substituted."
@opts
@values -min 0 -max 1
filetype -type string -default "" -optional true -help\
"File type associated with a file extension, e.g Python.File.
This can be looked up using the 'assoc' function in this package."
}]
}
#proc ftype {filetype} {
# package require registry
# if {$filetype eq "cplfile"} {
# #special case for cplfile (associated with .cpl files) which doesn't follow the usual pattern of having the command string under shell\open\command,
# #but instead has it under HKEY_LOCAL_MACHINE\SOFTWARE\Classes\cplfile\shell\cplopen\command.
# #There doesn't seem to be any user-specific override for this file type
# #- but we will check for one under HKEY_CURRENT_USER\Software\Classes\cplfile\shell\cplopen\command anyway for consistency with the way we check
# #for user-specific overrides for other file types.
# set key [join [list HKEY_CURRENT_USER Software Classes cplfile shell cplopen command] "\\"]
# } else {
# set key [join [list HKEY_CURRENT_USER Software Classes $filetype shell open command] "\\"]
# }
# if {![catch {registry get $key ""} raw_assoc]} {
# set tp [registry type $key ""]
# return [dict create open [dict create type $tp string $raw_assoc]]
# } else {
# #e.g Python.File
# if {$filetype eq "cplfile"} {
# set key [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes cplfile shell cplopen command] "\\"]
# } else {
# set key [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes $filetype shell open command] "\\"]
# }
# if {![catch {registry get $key ""} raw_assoc]} {
# set tp [registry type $key ""]
# return [dict create type $tp string $raw_assoc]
# } else {
# return [dict create type "" string ""] ;#no association found
# }
# }
#}
proc ftype {filetype} {
package require registry
set resultdict [dict create]
#e.g Python.File
set shellpath [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes $filetype shell] "\\"]
if {![catch {registry keys $shellpath *} shellverbs]} {
foreach verb $shellverbs {
set commandkey [join [list $shellpath $verb command] "\\"]
if {![catch {registry get $commandkey ""} cmdstring]} {
#registry queryies are case insensitive but some are cased differently e.g Open vs open.
#when using the verb as a key in the output dict, we need to normalize so that it is useful for lookups. We'll use lowercase for that.
set verb [string tolower $verb]
set tp [registry type $commandkey ""]
dict set resultdict $verb [dict create type $tp value $cmdstring scope system]
}
}
}
#allow user-specific verbs to be overridden.
set shellpath [join [list HKEY_CURRENT_USER Software Classes $filetype shell] "\\"]
if {![catch {registry keys $shellpath *} shellverbs]} {
foreach verb $shellverbs {
set commandkey [join [list $shellpath $verb command] "\\"]
if {![catch {registry get $commandkey ""} cmdstring]} {
set verb [string tolower $verb]
set tp [registry type $commandkey ""]
dict set resultdict $verb [dict create type $tp value $cmdstring scope user]
}
}
}
return $resultdict
}
}
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
# Secondary API namespace
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
tcl::namespace::eval punk::auto_exec::lib {
tcl::namespace::export {[a-z]*} ;# Convention: export all lowercase
tcl::namespace::path [tcl::namespace::parent]
}
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
#tcl::namespace::eval punk::auto_exec::system {
#}
# == === === === === === === === === === === === === === ===
# Sample 'about' function with punk::args documentation
# == === === === === === === === === === === === === === ===
tcl::namespace::eval punk::auto_exec {
tcl::namespace::export {[a-z]*} ;# Convention: export all lowercase
variable PUNKARGS
variable PUNKARGS_aliases
lappend PUNKARGS [list {
@id -id "(package)punk::auto_exec"
@package -name "punk::auto_exec" -help\
"Package
Description"
}]
namespace eval argdoc {
#namespace for custom argument documentation
proc package_name {} {
return punk::auto_exec
}
proc about_topics {} {
#info commands results are returned in an arbitrary order (like array keys)
set topic_funs [info commands [namespace current]::get_topic_*]
set about_topics [list]
foreach f $topic_funs {
set tail [namespace tail $f]
lappend about_topics [string range $tail [string length get_topic_] end]
}
#Adjust this function or 'default_topics' if a different order is required
return [lsort $about_topics]
}
proc default_topics {} {return [list Description *]}
# -------------------------------------------------------------
# get_topic_ functions add more to auto-include in about topics
# -------------------------------------------------------------
proc get_topic_Description {} {
punk::args::lib::tstr [string trim {
package punk::auto_exec
description to come..
} \n]
}
proc get_topic_License {} {
return "<unspecified>"
}
proc get_topic_Version {} {
return "$::punk::auto_exec::version"
}
proc get_topic_Contributors {} {
set authors {{<julian@precisium.com.au> {Julian Noble}}}
set contributors ""
foreach a $authors {
append contributors $a \n
}
if {[string index $contributors end] eq "\n"} {
set contributors [string range $contributors 0 end-1]
}
return $contributors
}
proc get_topic_custom-topic {} {
punk::args::lib::tstr -return string {
A custom
topic
etc
}
}
# -------------------------------------------------------------
}
# we re-use the argument definition from punk::args::standard_about and override some items
set overrides [dict create]
dict set overrides @id -id "::punk::auto_exec::about"
dict set overrides @cmd -name "punk::auto_exec::about"
dict set overrides @cmd -help [string trim [punk::args::lib::tstr {
About punk::auto_exec
}] \n]
dict set overrides topic -choices [list {*}[punk::auto_exec::argdoc::about_topics] *]
dict set overrides topic -choicerestricted 1
dict set overrides topic -default [punk::auto_exec::argdoc::default_topics] ;#if -default is present 'topic' will always appear in parsed 'values' dict
set newdef [punk::args::resolved_def -antiglobs -package_about_namespace -override $overrides ::punk::args::package::standard_about *]
lappend PUNKARGS [list $newdef]
proc about {args} {
package require punk::args
#standard_about accepts additional choices for topic - but we need to normalize any abbreviations to full topic name before passing on
set argd [punk::args::parse $args withid ::punk::auto_exec::about]
lassign [dict values $argd] _leaders opts values _received
punk::args::package::standard_about -package_about_namespace ::punk::auto_exec::argdoc {*}$opts {*}[dict get $values topic]
}
}
# end of sample 'about' function
# == === === === === === === === === === === === === === ===
# -----------------------------------------------------------------------------
# register namespace(s) to have PUNKARGS,PUNKARGS_aliases variables checked
# -----------------------------------------------------------------------------
# variable PUNKARGS
# variable PUNKARGS_aliases
namespace eval ::punk::args::register {
#use fully qualified so 8.6 doesn't find existing var in global namespace
lappend ::punk::args::register::NAMESPACES ::punk::auto_exec
}
# -----------------------------------------------------------------------------
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
## Ready
package provide punk::auto_exec [tcl::namespace::eval punk::auto_exec {
variable pkg punk::auto_exec
variable version
set version 0.1.0
}]
return

353
src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/punk/char-0.1.0.tm

@ -237,6 +237,88 @@ tcl::namespace::eval punk::char {
return $out
}
namespace eval argdoc {
variable PUNKARGS
lappend PUNKARGS [list {
@id -id ::punk::char::ascii2doublewide
@cmd -name punk::char::ascii2doublewide\
-summary\
"string to double-wide unicode char"\
-help\
"Convert parts of string in the ascii range 21 to 7E to corresponding chars from the unicode
'Halfwidth and Fullwidth Forms' block.
The space character (0x20) is converted to the 'IDEOGRAPHIC SPACE' character (0x3000)
Control chars and chars outside the ascii range are not converted and passed through as-is."
@values -min 1 -max 1
str -type string
}]
}
proc ascii2doublewide {str} {
#set base to \UFEE0 and add ascii value of char to get the double-wide character in the 'Halfwidth and Fullwidth Forms' block of unicode
set out ""
foreach ch [split $str ""] {
set decval [scan $ch %c]
if {$decval == 0x20} {
#space char maps to ideographic space rather than the fullwidth space char - as the fullwidth space char is often rendered as a narrow space rather than a wide one - and the ideographic space is more likely to be rendered as a wide space.
set decval 0x3000
} elseif {$decval < 0x21 || $decval > 0x7E} {
#chars outside the ascii range are not converted - pass through as-is
set decval [scan $ch %c]
} else {
set decval [expr {0xFEE0 + $decval}]
}
append out [format %c $decval]
}
return $out
}
proc doublewide2ascii {str} {
set out ""
foreach ch [split $str ""] {
set decval [scan $ch %c]
if {$decval == 0x3000} {
#ideographic space maps to ascii space char
set decval 0x20
append out \U3000
} elseif {$decval < 0xFF01 || $decval > 0xFF5E} {
#chars outside the part of the 'Halfwidth and Fullwidth Forms' block that corresponds to ascii are not converted - pass through as-is
append out $ch
} else {
set decval [expr {$decval - 0xFEE0}]
append out [format %c $decval]
}
}
return $out
}
proc ascii2NTFSPUA {str} {
#set base to \uF000 and add ascii value of char to get the PUA character in the 'Private Use Area' block of unicode
set out ""
foreach ch [split $str ""] {
set decval [scan $ch %c]
if {$decval <= 0x7F} {
append out [format %c [expr {0xF000 + $decval}]]
} else {
#chars outside the ascii range are not converted - pass through as-is
append out $ch
}
}
return $out
}
proc NTFSPUA2ascii {str} {
set out ""
foreach ch [split $str ""] {
set decval [scan $ch %c]
if {$decval >= 0xF000 && $decval <= 0xF0FF} {
set decval [expr {$decval - 0xF000}]
append out [format %c $decval]
} else {
#chars outside the 'Private Use Area' block that corresponds to ascii are not converted - pass through as-is
append out $ch
}
}
return $out
}
proc symbol {} {
tailcall page symbol
@ -2573,11 +2655,269 @@ tcl::namespace::eval punk::char {
return [tcl::string::map $map $str]
}
#todo - lookup from unicode tables
#ISO-3166-1 alpha-2 country codes to flag emojis
#These can change over time.
#todo - lookup from official sources - and update as needed
#
variable flags [dict create\
AC \U1F1E6\U1F1E8\
AD \U1F1E6\U1F1E9\
AE \U1F1E6\U1F1EA\
AF \U1F1E6\U1F1EB\
AG \U1F1E6\U1F1EC\
AI \U1F1E6\U1F1EE\
AL \U1F1E6\U1F1F1\
AM \U1F1E6\U1F1F2\
AO \U1F1E6\U1F1F4\
AQ \U1F1E6\U1F1F6\
AR \U1F1E6\U1F1F7\
AS \U1F1E6\U1F1F8\
AT \U1F1E6\U1F1F9\
AU \U1F1E6\U1F1FA\
AW \U1F1E6\U1F1FC\
AX \U1F1E6\U1F1FD\
AZ \U1F1E6\U1F1FF\
BA \U1F1E7\U1F1E6\
BB \U1F1E7\U1F1E7\
BD \U1F1E7\U1F1E9\
BE \U1F1E7\U1F1EA\
BF \U1F1E7\U1F1EB\
BG \U1F1E7\U1F1EC\
BH \U1F1E7\U1F1ED\
BI \U1F1E7\U1F1EE\
BJ \U1F1E7\U1F1EF\
BL \U1F1E7\U1F1F1\
BM \U1F1E7\U1F1F2\
BN \U1F1E7\U1F1F3\
BO \U1F1E7\U1F1F4\
BQ \U1F1E7\U1F1F6\
BR \U1F1E7\U1F1F7\
BS \U1F1E7\U1F1F8\
BT \U1F1E7\U1F1F9\
BV \U1F1E7\U1F1FB\
BW \U1F1E7\U1F1FC\
BY \U1F1E7\U1F1FE\
BZ \U1F1E7\U1F1FF\
CA \U1F1E8\U1F1E6\
CC \U1F1E8\U1F1E8\
CD \U1F1E8\U1F1E9\
CF \U1F1E8\U1F1Eb\
CG \U1F1E8\U1F1Ec\
CH \U1F1E8\U1F1ED\
CI \U1F1E8\U1F1EE\
CK \U1F1E8\U1F1F0\
CL \U1F1E8\U1F1F1\
CM \U1F1E8\U1F1F2\
CN \U1F1E8\U1F1F3\
CO \U1F1E8\U1F1F4\
CP \U1F1E8\U1F1F5\
CR \U1F1E8\U1F1F7\
CS \U1F1E8\U1F1F8\
CU \U1F1E8\U1F1FA\
CV \U1F1E8\U1F1FB\
CW \U1F1E8\U1F1FC\
CX \U1F1E8\U1F1FE\
CY \U1F1E8\U1F1FE\
CZ \U1F1E8\U1F1FF\
DE \U1F1E9\U1F1EA\
DG \U1F1E9\U1F1EC\
DJ \U1F1E9\U1F1EF\
DK \U1F1E9\U1F1F0\
DM \U1F1E9\U1F1F2\
DO \U1F1E9\U1F1F4\
DZ \U1F1E9\U1F1FF\
EA \U1F1EA\U1F1E6\
EC \U1F1EA\U1F1E8\
EE \U1F1EA\U1F1EA\
EG \U1F1EA\U1F1EC\
EH \U1F1EA\U1F1ED\
ER \U1F1EA\U1F1F7\
ES \U1F1EA\U1F1F8\
ET \U1F1EA\U1F1F9\
EU \U1F1EA\U1F1FA\
FI \U1F1EB\U1F1EE\
FJ \U1F1EB\U1F1EF\
FK \U1F1EB\U1F1F0\
FM \U1F1EB\U1F1F2\
FO \U1F1EB\U1F1F4\
FR \U1F1EB\U1F1F7\
GA \U1F1EC\U1F1E6\
GB \U1F1EC\U1F1E7\
GD \U1F1EC\U1F1E9\
GE \U1F1EC\U1F1EA\
GF \U1F1EC\U1F1EB\
GG \U1F1EC\U1F1EC\
GH \U1F1EC\U1F1ED\
GI \U1F1EC\U1F1EE\
GL \U1F1EC\U1F1F1\
GM \U1F1EC\U1F1F2\
GN \U1F1EC\U1F1F3\
GP \U1F1EC\U1F1F5\
GQ \U1F1EC\U1F1F6\
GR \U1F1EC\U1F1F7\
GS \U1F1EC\U1F1F8\
GT \U1F1EC\U1F1F9\
GU \U1F1EC\U1F1FA\
GW \U1F1EC\U1F1FC\
GY \U1F1EC\U1F1FE\
HK \U1F1ED\U1F1F0\
HM \U1F1ED\U1F1F2\
HN \U1F1ED\U1F1F3\
HR \U1F1ED\U1F1F7\
HT \U1F1ED\U1F1F9\
HU \U1F1ED\U1F1FA\
IC \U1F1EE\U1F1E8\
ID \U1F1EE\U1F1E9\
IE \U1F1EE\U1F1EA\
IL \U1F1EE\U1F1F1\
IM \U1F1EE\U1F1F2\
IN \U1F1EE\U1F1F3\
IO \U1F1EE\U1F1F4\
IQ \U1F1EE\U1F1F6\
IR \U1F1EE\U1F1F7\
IS \U1F1EE\U1F1F8\
IT \U1F1EE\U1F1F9\
JE \U1F1EF\U1F1EA\
JM \U1F1EF\U1F1F2\
JO \U1F1EF\U1F1F4\
JP \U1F1EF\U1F1F5\
KE \U1F1F0\U1F1EA\
KG \U1F1F0\U1F1EC\
KH \U1F1F0\U1F1ED\
KI \U1F1F0\U1F1EE\
KM \U1F1F0\U1F1F2\
KN \U1F1F0\U1F1F3\
KP \U1F1F0\U1F1F5\
KR \U1F1F0\U1F1F7\
KW \U1F1F0\U1F1FC\
KY \U1F1F0\U1F1FE\
KZ \U1F1F0\U1F1FF\
LA \U1F1F1\U1F1E6\
LB \U1F1F1\U1F1E7\
LC \U1F1F1\U1F1E8\
LI \U1F1F1\U1F1EE\
LK \U1F1F1\U1F1F0\
LR \U1F1F1\U1F1F7\
LS \U1F1F1\U1F1F8\
LT \U1F1F1\U1F1F9\
LU \U1F1F1\U1F1FA\
LV \U1F1F1\U1F1FB\
LY \U1F1F1\U1F1FE\
MA \U1F1F2\U1F1E6\
MC \U1F1F2\U1F1E8\
MD \U1F1F2\U1F1E9\
ME \U1F1F2\U1F1EA\
MF \U1F1F2\U1F1EB\
MG \U1F1F2\U1F1EC\
MH \U1F1F2\U1F1ED\
MK \U1F1F2\U1F1F0\
ML \U1F1F2\U1F1F1\
MM \U1F1F2\U1F1F2\
MN \U1F1F2\U1F1F3\
MO \U1F1F2\U1F1F4\
MP \U1F1F2\U1F1F5\
MQ \U1F1F2\U1F1F6\
MR \U1F1F2\U1F1F7\
MS \U1F1F2\U1F1F8\
MT \U1F1F2\U1F1F9\
MU \U1F1F2\U1F1FA\
MV \U1F1F2\U1F1FB\
MW \U1F1F2\U1F1FC\
MX \U1F1F2\U1F1FD\
MY \U1F1F2\U1F1FE\
MZ \U1F1F2\U1F1FF\
NA \U1F1F3\U1F1E6\
NC \U1F1F3\U1F1E8\
NE \U1F1F3\U1F1EA\
NF \U1F1F3\U1F1EB\
NG \U1F1F3\U1F1EC\
NI \U1F1F3\U1F1EE\
NL \U1F1F3\U1F1F1\
NO \U1F1F3\U1F1F4\
NP \U1F1F3\U1F1F5\
NR \U1F1F3\U1F1F7\
NU \U1F1F3\U1F1F8\
NZ \U1F1F3\U1F1ff\
OM \U1F1F4\U1F1F2\
PA \U1F1F5\U1F1E6\
PE \U1F1F5\U1F1EA\
PF \U1F1F5\U1F1EB\
PG \U1F1F5\U1F1EC\
PH \U1F1F5\U1F1ED\
PK \U1F1F5\U1F1ED\
PL \U1F1F5\U1F1F1\
PM \U1F1F5\U1F1F3\
PN \U1F1F5\U1F1F3\
PR \U1F1F5\U1F1F7\
PS \U1F1F5\U1F1F8\
PT \U1F1F5\U1F1F9\
PW \U1F1F5\U1F1FC\
PY \U1F1F5\U1F1FE\
QA \U1F1F6\U1F1E6\
RE \U1F1F7\U1F1EA\
RO \U1F1F7\U1F1F4\
RS \U1F1F7\U1F1F7\
RU \U1F1F7\U1F1FA\
RW \U1F1F7\U1F1FC\
SA \U1F1F8\U1F1E6\
SB \U1F1F8\U1F1E7\
SC \U1F1F8\U1F1E8\
SD \U1F1F8\U1F1E9\
SE \U1F1F8\U1F1EA\
SG \U1F1F8\U1F1EC\
SH \U1F1F8\U1F1ED\
SI \U1F1F8\U1F1EE\
SJ \U1F1F8\U1F1EF\
SK \U1F1F8\U1F1F0\
SL \U1F1F8\U1F1F1\
SM \U1F1F8\U1F1F2\
SN \U1F1F8\U1F1F3\
SO \U1F1F8\U1F1F4\
SR \U1F1F8\U1F1F7\
SS \U1F1F8\U1F1F8\
ST \U1F1F8\U1F1F9\
SV \U1F1F8\U1F1F7\
SX \U1F1F8\U1F1F8\
SY \U1F1F8\U1F1FE\
SZ \U1F1F8\U1F1FF\
TA \U1F1F9\U1F1E6\
TC \U1F1F9\U1F1E8\
TD \U1F1F9\U1F1E9\
TF \U1F1F9\U1F1EB\
TG \U1F1F9\U1F1EC\
TH \U1F1F9\U1F1ED\
TJ \U1F1F9\U1F1EF\
TK \U1F1F9\U1F1F0\
TL \U1F1F9\U1F1F1\
TM \U1F1F9\U1F1F2\
TN \U1F1F9\U1F1F3\
TO \U1F1F9\U1F1F4\
TR \U1F1F9\U1F1F7\
TT \U1F1F9\U1F1F9\
TV \U1F1F9\U1F1FB\
TW \U1F1F9\U1F1FC\
TZ \U1F1F9\U1F1FF\
UA \U1F1FA\U1F1E6\
UG \U1F1FA\U1F1EC\
UM \U1F1FA\U1F1F2\
US \U1F1FA\U1F1F8\
ZW \U1F1FF\U1F1FC
UY \U1F1FA\U1F1FE\
UZ \U1F1FA\U1F1FF\
VA \U1F1FB\U1F1E6\
VC \U1F1FB\U1F1E8\
VE \U1F1FB\U1F1EA\
VG \U1F1FB\U1F1EC\
VI \U1F1FB\U1F1EE\
VN \U1F1FB\U1F1F3\
VU \U1F1FB\U1F1FA\
WF \U1F1FC\U1F1EB\
WS \U1F1FC\U1F1F8\
XK \U1F1FD\U1F1F3\
YE \U1F1FE\U1F1EA\
YT \U1F1FE\U1F1F9\
ZA \U1F1FF\U1F1E6\
ZM \U1F1FF\U1F1F2\
ZW \U1F1FF\U1F1FC\
]
variable rflags
dict for {k v} $flags {
@ -2825,6 +3165,15 @@ tcl::namespace::eval punk::char::lib {
}
}
# -----------------------------------------------------------------------------
# register namespace(s) to have PUNKARGS,PUNKARGS_aliases variables checked
# -----------------------------------------------------------------------------
# variable PUNKARGS
# variable PUNKARGS_aliases
namespace eval ::punk::args::register {
#use fully qualified so 8.6 doesn't find existing var in global namespace
lappend ::punk::args::register::NAMESPACES ::punk::char
}
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++

909
src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/punk/du-0.1.0.tm

File diff suppressed because it is too large Load Diff

7
src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/punk/mix/commandset/module-0.1.0.tm

@ -158,8 +158,11 @@ namespace eval punk::mix::commandset::module {
If a version is specified in the module argument as well as in -version
the higher version number will be used.
"
-license -default <unspecified>
-author -default <unspecified> -multiple 1
-license -default <unspecified> -help\
"License to be mentioned in the module file. e.g BSD, MIT"
-author -default <unspecified> -multiple 1 -help\
{Author(s) of the module. Multiple authors can be specified by repeating the -author option.
e.g -author {"John Smith" "jsmith@example.com"} -author {"Jane Doe" "jane@example.org"}}
-template -default punk.module
-type -default "[lindex $moduletypes 0]" -choices {$moduletypes}
-force -default 0 -type boolean -help\

399
src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/punk/nav/fs-0.1.0.tm

@ -233,6 +233,11 @@ tcl::namespace::eval punk::nav::fs {
set args [lrange $args 1 end]
}
if {$v eq "/"} {
#directory only listing - we can optimize this by not getting file sizes and times - as these are only used for file listings
#we can't completely skip iterating over files as we want to know if there are any files in the directory - as this affects the display of links/shortcuts that point to directories - but we can skip getting sizes and times for files.
#todo?
}
if {![llength $args]} {
#ls is too slow even over a fairly low-latency network
@ -243,12 +248,12 @@ tcl::namespace::eval punk::nav::fs {
commandstack::basecall cd $VIRTUAL_CWD
}
}
set matchinfo [dirfiles_dict -searchbase $VIRTUAL_CWD -with_times {f d l} -with_sizes {f d l}]
set matchinfo [dirfiles_dict -searchbase $VIRTUAL_CWD -with_times {f d l b c p s} -with_sizes {f d l b c p s}]
} else {
if {[pwd] ne $VIRTUAL_CWD} {
commandstack::basecall cd $VIRTUAL_CWD
}
set matchinfo [dirfiles_dict -searchbase [pwd] -with_times {f d l} -with_sizes {f d l}]
set matchinfo [dirfiles_dict -searchbase [pwd] -with_times {f d l b c p s} -with_sizes {f d l b c p s}]
}
set dircount [llength [dict get $matchinfo dirs]]
set filecount [llength [dict get $matchinfo files]]
@ -621,30 +626,41 @@ tcl::namespace::eval punk::nav::fs {
punk::args::define {
@id -id ::punk::nav::fs::d/new
-nonportable -type none -help\
"Allow creation of directories which may not be portable across platforms.
Use with caution and only when you know what you are doing.
This allows creation of directories with names that may be invalid on some
platforms, or that may have special meanings on some platforms
(e.g reserved device names on windows).
If -nonportable is not supplied, then an error will be raised if any supplied
path is non-portable as defined by punk::winpath::illegalname_test.
Regardless of whether -nonportable is supplied or not, some characters are not
suitable for windows or most other platforms and will be rejected with an error.
An example of this is the null character (\0)."
@cmd -name punk::nav::fs::d/new\
-summary\
"Create directory or directories at the specified path(s)."\
-help\
"This command creates directories at the specified path(s).
If any part of the specified path does not exist, then it will be created as well.
If a specified path already exists, then it will be left as-is and no error will be raised.
A summary line is returned for each created directory, with the full path of the created
directory and a status line indicating the number of dirs and files in the directory if
it already existed (or showing 0 for both if it was just created)."
-nonportable -type none\
-help\
"Allows creation of directories which may not be portable across platforms.
Use with caution and only when you know what you are doing.
This allows creation of directories with names that may be invalid on some
platforms, or that may have special meanings on some platforms
(e.g reserved device names on windows).
If -nonportable is not supplied, then an error will be raised if any supplied
path is non-portable as defined by punk::winpath::illegalname_test.
Regardless of whether -nonportable is supplied or not, some characters are not
suitable for windows or most other platforms and will be rejected with an error.
An example of this is the null character (\0)."
@values -min 1 -max -1 -type string
path -type string -multiple 1 -help\
"Path(s) to create. Can be absolute or relative.
If any path is rejected due to -nonportable or other invalid characters,
or because a parent directory is not writable, then no directories will be created.
If any path is rejected due to -nonportable or other invalid characters,
or because a parent directory is not writable, then no directories will be created.
If a path already exists, then it will be left as-is and no error will be raised.
If a path already exists, then it will be left as-is and no error will be raised.
If despite passing the name tests or writability tests, a directory cannot be
created for some reason (e.g other filesystem error) then an error will be raised
and processing of any remaining paths will be aborted."
If despite passing the name tests or writability tests, a directory cannot be
created for some reason (e.g other filesystem error) then an error will be raised
and processing of any remaining paths will be aborted."
}
#todo - synchronize overall behaviour of d/new with that of n/new (for namespaces)
proc d/new {args} {
@ -718,7 +734,7 @@ tcl::namespace::eval punk::nav::fs {
}
#display summaries of created directories (which may have already existed) by reusing d/ to get info on them.
set query_paths [lmap v $paths $v/*]
set query_paths [lmap v $paths {string cat $v "/*"}]
d/ / {*}$query_paths
}
@ -734,6 +750,10 @@ tcl::namespace::eval punk::nav::fs {
#run a file
#review. On unix the shebang should be able to choose the interpreter.
#on windows the ::env(PATHEXT) and file associations should be able to choose the interpreter. (see punk::auto_execok_better)
#when running using ./script_or_exe.name - the windows file association should be used.
#when running using x/ script_or_exe.name - we want to be able to run scripts even if no file association exists (or it is inappropriate for execution e.g ps1 defaults to open in editor)
proc x/ {args} {
if {![llength $args]} {
set result [d/]
@ -748,11 +768,16 @@ tcl::namespace::eval punk::nav::fs {
lua [list exe lua extensions [list ".lua"]]\
perl [list exe perl extensions [list ".pl"]]\
php [list exe php extensions [list ".php"]]\
ps1 [list exe pwsh extensions [list ".ps1"]]\
]
#todo - allow cofnig to specify arguments for the executable. eg pwsh -noprofile -executionpolicy remotesigned for running powershell scripts.
#this is a bit of a hack - fix
set tcl_extensions [list ".tcl" ".tm" ".kit" ".tk"] ;#todo - load from config
set py_extensions [list ".py"]
set lua_extensions [list ".lua"]
set perl_extensions [list ".pl"]
#set lua_extensions [list ".lua"]
#set perl_extensions [list ".pl"]
#set pwsh_extensions [list ".ps1"]
set script_extensions [list]
set extension_lookup [dict create]
@ -792,14 +817,12 @@ tcl::namespace::eval punk::nav::fs {
set ::argc [llength $newargs]
set ::argv $newargs
tailcall source $path
} elseif {$extlower in $py_extensions} {
set pycmd [auto_execok python]
tailcall {*}$pycmd {*}$args
} elseif {$extlower in $script_extensions} {
set exename [dict get $scriptconfig $scriptlang exe]
set cmd [auto_execok $exename]
tailcall {*}$cmd $args
} else {
#review
set fd [open $path r]
set chunk [read $fd 4000]; close $fd
#consider any commented line near top of file containing 'tcl' as likely to be a tcl script of some sort and attempt to source it.
@ -824,7 +847,11 @@ tcl::namespace::eval punk::nav::fs {
}
}
} else {
puts stderr "No script executable known for this"
if {$scriptfile eq ""} {
puts stderr "script not found"
} else {
puts stderr "No script executable known for files with extension [file extension $scriptfile]. Ensure file has a known extension ($script_extensions) or add a commented hint in the file such as #!/usr/bin/env tclsh"
}
}
}
@ -934,13 +961,96 @@ tcl::namespace::eval punk::nav::fs {
# -searchbase is always passed through - and is only used to construct a location path if a relative searchspec was supplied
punk::args::define {
@id -id ::punk::nav::fs::dirfiles_dict
@cmd -name punk::nav::fs::dirfiles_dict
@cmd -name punk::nav::fs::dirfiles_dict\
-summary\
""\
-help\
"This command performs a directory listing of the specified location and returns the results as a dictionary
containing lists of keys such as files and directories and their properties.
The results are returned as a dictionary with the following structure:
{
location <searched location>
searchbase <searchbase supplied>
dirs {<list of directories matching search>}
vfsmounts {<list of entries that are mount points for virtual filesystems>}
links {<list of links matching search>}
linkinfo {<list of link targets for links matching search, in same order as links list, or 'na'>}
files {<list of files matching search>}
filesizes {<list of file sizes for files matching search, in same order as files list, or 'na'>}
sizes {<dictionary keyed on entry name with sub-dictionary of size information keyed with bytes>}
times {<dictionary keyed on entry name with sub-dictionary of time properties keyed with c a m>}
flaggedhidden {<dictionary keyed on entry name with value of 1 if entry is hidden or 0, for entries matching search, or 'na'>}
flaggedsystem {<dictionary keyed on entry name with value of 1 if entry is a system file or 0, for entries matching search, or 'na'>}
flaggedreadonly {<dictionary keyed on entry name with value of 1 if entry is read-only or 0, for entries matching search, or 'na'>}
altnames {<dictionary keyed on entry name with value of list of alternate names for that entry, or 'na'>}
opts {
-glob <glob pattern used for search>
-filedebug <filedebug supplied>
-patterndebug <patterndebug supplied>
-types <types supplied>
-with_sizes <with_sizes supplied>
-with_times <with_times supplied>
}
debuginfo {
<any debug info that may be useful to caller>
}
errors {
<any errors encountered during search, e.g inaccessible folders etc>
}
nonportable {<dictionary keyed on entry name with value of 1 if entry is non-portable or 0>}
underlayfiles {<list of files that are underlay files for virtual filesystem mounts matching search>}
underlayfilesizes {<list of sizes for underlay files for virtual filesystem mounts matching search, in same order as underlayfiles list, or 'na'>}
timinginfo {
<any timing info that may be useful to caller>
}
}
"
@opts -any 0
-searchbase -default ""
-tailglob -default "\uFFFF"
-searchbase -default ""
-tailglob -default "\uFFFF"
-filedebug -default 0 -type boolean
-patterndebug -default 0 -type boolean
#with_sizes & with_times must accept 0|1|f|d|l where f = files d = dirs l = links (punk::du)
-with_sizes -default "\uFFFF" -type string
-with_times -default "\uFFFF" -type string
-with_sizes -default "\uFFFF" -type list
-with_times -default "\uFFFF" -type list
-link_info -default 1 -type boolean -help "When links are included in results, also include resparseinfo if present."
-portabilitycheck -default 1 -type boolean -help\
"Perform portability checks on entry names and flag non-portable entries in results.
Non-portable entries are those that may not be portable across platforms, for example due
to containing characters that are illegal on some platforms, or reserved device names on windows.
When this option is enabled, entries that are determined to be non-portable will be listed under
the 'nonportable' key."
-types -default {} -type list\
-help "Restrict results to specified types.
This uses the same basic types and mechanism as the Tcl 'glob' command.
entry:
{f}iles {d}irs {l}inks {s}ockets {p}ipes {b}lock devices {c}haracter special devices
attributes:
hidden readonly
permissions:
r w x
Note that on windows, Tcl's glob command uses very basic heuristics to determine the permissions - it doesn't
actually check the ACLs.
For example - the test for executable permission is just whether the file has a known executable extension
and the test for readonly is just whether the file has the read-only attribute set.
On unix, the permissions are likely to be determined by checking the actual permissions of the file against
the current user's uid and groups.
When entry types are given, results matching any of those types will be returned.
When attributes or permissions are given, only results matching all of the specified attributes or permissions
will be returned.
The default (empty list) is to return all types and ignore attributes and permissions.
If just attributes or permissions are given without entry types, then the types will be filtered according to
the specified attributes or permissions but not according to the directory entry type
- so for example if just 'hidden' attribute is given, then both files and folders with the hidden attribute
will be returned.
(todo - macintosh specific type handling)
"
@values -min 0 -max -1 -type string -unnamed true
}
proc dirfiles_dict {args} {
@ -955,23 +1065,29 @@ tcl::namespace::eval punk::nav::fs {
#review - spaced paths ?
error "dirfiles_dict: multiple listing not *yet* supported"
}
set searchspec [lindex $searchspecs 0]
# -- --- --- --- --- --- ---
set opt_searchbase [dict get $opts -searchbase]
set opt_tailglob [dict get $opts -tailglob]
set opt_with_sizes [dict get $opts -with_sizes]
set opt_with_times [dict get $opts -with_times]
set opt_searchbase [dict get $opts -searchbase]
set opt_tailglob [dict get $opts -tailglob]
set opt_with_sizes [dict get $opts -with_sizes]
set opt_with_times [dict get $opts -with_times]
set opt_link_info [dict get $opts -link_info]
set opt_types [dict get $opts -types]
set opt_filedebug [dict get $opts -filedebug]
set opt_patterndebug [dict get $opts -patterndebug]
set opt_portabilitycheck [dict get $opts -portabilitycheck]
# -- --- --- --- --- --- ---
set searchspec [lindex $searchspecs 0]
#we don't want to normalize..
#for example if the user supplies ../ we want to see ../result
set is_relativesearchspec [expr {[file pathtype $searchspec] eq "relative"}]
if {$opt_searchbase eq ""} {
set searchbase .
} else {
#if {$opt_searchbase eq ""} {
# set searchbase .
#} else {
set searchbase $opt_searchbase
}
#}
switch -- $opt_tailglob {
@ -979,7 +1095,7 @@ tcl::namespace::eval punk::nav::fs {
if {$searchspec eq ""} {
set location
} else {
if {$is_relativesarchspec} {
if {$is_relativesearchspec} {
#set location [file dirname [file join $opt_searchbase $searchspec]]
set location [punk::path::normjoin $searchbase $searchspec ..]
} else {
@ -1063,7 +1179,7 @@ tcl::namespace::eval punk::nav::fs {
set ts1 [clock clicks -milliseconds]
if {$is_in_vfs} {
set listing [punk::du::lib::du_dirlisting_tclvfs $location -glob $match_contents {*}$next_opt_with_sizes {*}$next_opt_with_times]
set listing [punk::du::lib::du_dirlisting_tclvfs $location -glob $match_contents -filedebug $opt_filedebug -patterndebug $opt_patterndebug {*}$next_opt_with_sizes {*}$next_opt_with_times -types $opt_types]
} else {
set invfs ""
switch -glob -- $location {
@ -1094,20 +1210,25 @@ tcl::namespace::eval punk::nav::fs {
switch -- $invfs {
zipfs {
#relative vs absolute? review - cwd valid for //zipfs:/ ??
set listing [punk::du::lib::du_dirlisting_zipfs $location -glob $match_contents {*}$next_opt_with_sizes {*}$next_opt_with_times]
set listing [punk::du::lib::du_dirlisting_zipfs $location -glob $match_contents -filedebug $opt_filedebug -patterndebug $opt_patterndebug {*}$next_opt_with_sizes {*}$next_opt_with_times -types $opt_types]
}
cookit {
#seems to be a vfs - except it mounts on a pseudo-volume path //cookit:/
#don't use twapi
#could possibly use du_dirlisting_tclvfs REVIEW
#files and folders are all returned with the -types hidden option for glob on windows
set listing [punk::du::lib::du_dirlisting_generic $location -glob $match_contents {*}$next_opt_with_sizes {*}$next_opt_with_times]
set listing [punk::du::lib::du_dirlisting_generic $location -glob $match_contents -filedebug $opt_filedebug -patterndebug $opt_patterndebug {*}$next_opt_with_sizes {*}$next_opt_with_times -types $opt_types]
}
pseudovol {
set listing [punk::du::lib::du_dirlisting_generic $location -glob $match_contents {*}$next_opt_with_sizes {*}$next_opt_with_times]
set listing [punk::du::lib::du_dirlisting_generic $location -glob $match_contents -filedebug $opt_filedebug -patterndube $opt_patterndebug {*}$next_opt_with_sizes {*}$next_opt_with_times -types $opt_types]
}
default {
set listing [punk::du::dirlisting $location -glob $match_contents {*}$next_opt_with_sizes {*}$next_opt_with_times]
#The default call to punk::du::dirlisting will use the most appropriate mechanism for the platform and path
#- e.g twapi for windows local paths (if twapi is available), tcl glob for unix etc
#- and will be able to handle vfs paths that are visible to the filesystem as well (e.g cookit)
#- but may not be able to handle some vfs paths that aren't visible as normal files/folders to the filesystem
#(e.g if the vfs doesn't report itself as a vfs in vfs::filesystem info)
set listing [punk::du::dirlisting $location -glob $match_contents -filedebug $opt_filedebug -patterndebug $opt_patterndebug {*}$next_opt_with_sizes {*}$next_opt_with_times -link_info $opt_link_info -types $opt_types]
}
}
}
@ -1157,6 +1278,10 @@ tcl::namespace::eval punk::nav::fs {
#we add dotfiles to flaggedhidden list in case there is some other mechanism that has flagged items as hidden
if {$::tcl_platform(platform) ne "windows"} {
#lappend flaggedhidden {*}[lsearch -all -inline [list {*}$dirs {*}$files] ".*"]
#todo - fix! we want to match any file or folder with a leading dot
#- but we also want to preserve the full path in the flaggedhidden list - so we need to check the tail of each entry for leading dot, rather than just doing a glob match on the whole path.
lappend flaggedhidden {*}[tcl::prefix::all [list {*}$dirs {*}$files] .]
#e.g we can have dupes in the case where there are vfs mounted files that appear as dirs
#as we will need to do a (nat)sort as a last step - it will be faster to not sort items prematurely
@ -1205,9 +1330,11 @@ tcl::namespace::eval punk::nav::fs {
#jmn
set ts1 [clock milliseconds]
foreach nm [list {*}$dirs {*}$files] {
if {[punk::winpath::illegalname_test $nm]} {
lappend nonportable $nm
if {$opt_portabilitycheck} {
foreach nm [list {*}$dirs {*}$files] {
if {[punk::winpath::illegalname_test $nm]} {
lappend nonportable $nm
}
}
}
set ts2 [clock milliseconds]
@ -1221,6 +1348,165 @@ tcl::namespace::eval punk::nav::fs {
return [dict merge $listing $updated]
}
#replacement for tcl's glob.
#we can document with punk::args - but we don't want the overhead of full option parsing for every glob call - so we'll just do the option parsing manually within the proc.
#todo - add alias for punk::args id ::punk::nav::fs::fglob to ::glob so documentation matches.
proc fglob_parse_test1 {args} {
#tcl's glob requires a -- when arguments in positions that could be an option begin with a dash.
#This means accounting for prefixes of valid options and determining if they accept a parameter (which is allowed to begin with a dash) or not.
#Any argument that is in a possible option position and begins with a dash but isn't a valid option (or the -- itself)
#should raise an error rather than being treated as a literal glob pattern.
set valid_options [list -directory -join -nocomplain -path -tails -types]
set solo_options [list -nocomplain -join -tails]
set eopts_reached 0
set options [dict create] ;#for solos we will set the value to 1, for options that take parameters we will set the value to the parameter
set patterns [list]
for {set i 0} {$i < [llength $args]} {incr i} {
set a [lindex $args $i]
if {[string match -* $a]} {
if {$a eq "--"} {
set eopts_reached 1
set patterns [lrange $args [expr {$i + 1}] end]
break
}
#before -- we need to check if arg is a valid option or not
set full_option_name [tcl::prefix::match -error "" $valid_options $a]
if {$full_option_name ne ""} {
if {$full_option_name ni $solo_options} {
#option takes a parameter - so next arg is parameter even if it looks like an option
incr i
if {$i < [llength $args]} {
set param [lindex $args $i]
dict set options $full_option_name $param
} else {
error "fglob: option \"$a\" requires a parameter but none found"
}
} else {
dict set options $full_option_name 1
}
} else {
error "fglob: bad option \"$a\": must be [join $valid_options ", "] or --"
}
} else {
set patterns [lrange $args $i end]
break
}
}
return [dict create options $options patterns $patterns]
}
proc fglob_parse_test {args} {
#tcl's glob requires a -- when arguments in positions that could be an option begin with a dash.
#This means accounting for prefixes of valid options and determining if they accept a parameter (which is allowed to begin with a dash) or not.
#Any argument that is in a possible option position and begins with a dash but isn't a valid option (or the -- itself)
#should raise an error rather than being treated as a literal glob pattern.
#set valid_options [list -directory -join -nocomplain -path -tails -types]
#set solo_options [list -nocomplain -join -tails]
set options [dict create] ;#for solos we will set the value to 1, for options that take parameters we will set the value to the parameter
set patterns [list]
set arglen [llength $args]
for {set i 0; set j 1; set a [lindex $args 0]} {$i < $arglen} {incr j; set a [lindex $args [incr i]]} {
if {![string match -* $a]} {
#encountered non-option-like argument. All subsequent arguments should be treated as patterns even if they look like options.
return [dict create options $options patterns [lrange $args $i end]]
}
if {$a eq "--"} {
return [dict create options $options patterns [lrange $args $j end]]
}
#flag-like argument before -- we need to check if arg is a valid option or not
set full_option_name [tcl::prefix::match {-directory -join -nocomplain -path -tails -types} $a]
switch -- $full_option_name {
-directory - -path - -types {
#option takes a parameter - so next arg is parameter even if it looks like an option
if {$j < $arglen} {
dict set options $full_option_name [lindex $args $j]
incr i
incr j
} else {
error "fglob: option \"$a\" requires a parameter but none found"
}
}
default {
dict set options $full_option_name 1
}
}
}
return [dict create options $options patterns $patterns]
}
proc fglob {args} {
#tcl's glob requires a -- when arguments in positions that could be an option begin with a dash.
#This means accounting for prefixes of valid options and determining if they accept a parameter (which is allowed to begin with a dash) or not.
#Any argument that is in a possible option position and begins with a dash but isn't a valid option (or the -- itself)
#should raise an error rather than being treated as a literal glob pattern.
#set valid_options [list -directory -join -nocomplain -path -tails -types]
#set solo_options [list -nocomplain -join -tails]
set options [dict create] ;#for solos we will set the value to 1, for options that take parameters we will set the value to the parameter
set patterns [list]
set arglen [llength $args]
for {set i 0; set j 1; set a [lindex $args 0]} {$i < $arglen} {incr j; set a [lindex $args [incr i]]} {
if {![string match -* $a]} {
#encountered non-option-like argument. All subsequent arguments should be treated as patterns even if they look like options.
#return [dict create options $options patterns [lrange $args $i end]]
set patterns [lrange $args $i end]
break
}
if {$a eq "--"} {
#return [dict create options $options patterns [lrange $args $j end]]
set patterns [lrange $args $j end]
break
}
#flag-like argument before -- we need to check if arg is a valid option or not
set full_option_name [tcl::prefix::match {-directory -join -nocomplain -path -tails -types} $a]
switch -- $full_option_name {
-directory - -path - -types {
#option takes a parameter - so next arg is parameter even if it looks like an option
if {$j < $arglen} {
dict set options $full_option_name [lindex $args $j]
incr i
incr j
} else {
error "fglob: option \"$a\" requires a parameter but none found"
}
}
default {
dict set options $full_option_name 1
}
}
}
#return [dict create options $options patterns $patterns]
#todo -join
# process each glob as a directory listing.
if {[dict exists $options -directory]} {
set basedir [dict get $options -directory]
} else {
set basedir ""
}
#ignore -nocomplain - like tcl9 glob - if no results - return empty list rather than error
if {[dict exists $options -types]} {
set types [dict get $options -types]
} else {
set types {}
}
#todo - fix for multiple absolute paths supplied.
#e.g c:/cmdfiles/*.exe c:/repo/jn/shellspy/bin/*.exe
#- we would want to treat as two separate globs with different basedirs
#for common basedir - we might be better off creating a single glob pattern using the brace syntax for alternatives.
#TCL's glob returns a single list when multiple patterns supplied.
#It does not seem to sort or de-dup, or group results by type.
set results [list]
foreach pattern $patterns {
set resultd [dirfiles_dict -portabilitycheck 0 -searchbase $basedir -with_times 0 -with_sizes 0 -link_info 0 -types $types -tailglob $pattern]
#puts [showdict $resultd]
#todo - other types?
lappend results {*}[dict get $resultd dirs] {*}[dict get $resultd files] {*}[dict get $resultd links]
}
return $results
}
punk::args::define {
@id -id ::punk::nav::fs::dirfiles_dict_as_lines
-stripbase -default 0 -type boolean
@ -1257,6 +1543,8 @@ tcl::namespace::eval punk::nav::fs {
# - we may be able to provide a reasonable default for windows vs other - but a proc option is likely needed to allow caller to override the default behaviour on a call by call basis,
# and a config option may be desirable for the user to override the platform default.
# The chosen defaults based on platform may be generally ok - but then not give the desired behaviour when accessing a particular filesystem/mount
# - note also that case-insensitivity can be configured per folder on windows via the fsutil.exe utility
# unfortunately we have no simple & fast way to query the case-sensitivity of a particular folder or filesystem.
if {$::tcl_platform(platform) eq "windows"} {
#case-preserving but case-insensitive matching is the default
foreach d $list_of_dicts {
@ -1704,9 +1992,13 @@ tcl::namespace::eval punk::nav::fs::lib {
punk::args::define {
@id -id ::punk::nav::fs::lib::is_fileglob
@cmd -name punk::nav::fs::lib::is_fileglob
@cmd -name punk::nav::fs::lib::is_fileglob\
-help\
"Determine if a string is a glob pattern as recognised by the tcl 'glob' command.
This is used for example to determine whether to treat a path component as a literal
or a glob pattern when processing paths with glob patterns in them (e.g for the ./ command)."
@values -min 1 -max 1
path -type string -required true -help\
path -type string -optional 0 -help\
{String to test for being a glob pattern as recognised by the tcl 'glob' command.
If the string represents a path with multiple segments, only the final component
of the path will be tested for glob characters.
@ -1799,6 +2091,7 @@ interp alias {} vwd {} punk::nav::fs::vwd ;#return punk::nav::fs::VI
interp alias {} dirlist {} punk::nav::fs::dirlist
interp alias {} dirfiles {} punk::nav::fs::dirfiles
interp alias {} dirfiles_dict {} punk::nav::fs::dirfiles_dict
interp alias {} fglob {} punk::nav::fs::fglob
interp alias {} ./new {} punk::nav::fs::d/new
interp alias {} d/new {} punk::nav::fs::d/new

5
src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/punk/path-0.1.0.tm

@ -116,6 +116,8 @@ namespace eval punk::path {
#(sometimes //server.com used as a short form for urls - which doesn't seem too incompatible with this anyway)
# -- ---
#This is intended to be purely a string analysis - without reference to filesystem volumes or vfs or zipfs mountpoints etc
#(but this also means we won't be able to resolve windows shortnames or dos device paths - so we will preserve those as they are) - review
#(It also means we can't resolve per drive working directories on windows - so we will preserve c: as is rather than converting to absolute - review)
#
#TODO - option for caller to provide a -base below which we can't backtrack.
#This is preferable to setting policy here for example regarding forcing no trackback below //servername/share
@ -179,6 +181,9 @@ namespace eval punk::path {
#relpaths all end up with leading . - while not always the simplest form, this is ok. (helps stop inadvertent conversions to absolutes)
# Tests - TODO
# normjoin /d:/..//vfs:/test -> /vfs:/test (good - not converted to //vfs:/test)
#normjoin c: should theoretically return current per drive working directory on c:
# - would need to use win32 GetFullPathName to resolve this.
proc normjoin {args} {
set args [lmap a $args {string map "\\\\ /" $a}]
set path [plainjoin {*}$args]

290
src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/punk/winlnk-0.1.1.tm

@ -21,11 +21,11 @@
#[manpage_begin punkshell_module_punk::winlnk 0 0.1.1]
#[copyright "2024"]
#[titledesc {windows shortcut .lnk library}] [comment {-- Name section and table of contents description --}]
#[moddesc {punk::winlnk}] [comment {-- Description at end of page heading --}]
#[moddesc {punk::winlnk}] [comment {-- Description at end of page heading --}]
#[require punk::winlnk]
#[keywords module shortcut lnk parse windows crossplatform]
#[description]
#[para] Tools for reading windows shortcuts (.lnk files) on any platform
#[para] Tools for reading windows shortcuts (.lnk files) on any platform
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
@ -164,6 +164,17 @@ tcl::namespace::eval punk::winlnk {
error "punk::winlnk::Header_Has_LinkFlag error - flagname $flagname not known"
}
}
proc Header_Get_LinkFlags_as_list {contents} {
variable LinkFlags
set allflags [Header_Get_LinkFlags $contents]
set setflags {}
dict for {flagname binflag} $LinkFlags {
if {$allflags & $binflag} {
lappend setflags $flagname
}
}
return $setflags
}
#MS-SHLLINK.pdf documents the .lnk file format in detail, but here is a brief overview of the structure of a .lnk file:
#protocol revision 10.0 (November 2025) https://winprotocoldocs-bhdugrdyduf5h2e4.b02.azurefd.net/MS-SHLLINK/%5bMS-SHLLINK%5d.pdf
@ -487,6 +498,89 @@ tcl::namespace::eval punk::winlnk {
}
}
variable known_guids [dict create\
"00021400-0000-0000-C000-000000000046" {name Desktop guidtype CLSID}\
"00021401-0000-0000-C000-000000000046" {name Shortcut guidtype CLSID}\
"20D04FE0-3AEA-1069-A2D8-08002B30309D" {name "This PC" guidtype CLSID}\
"F02C1A0D-BE21-4350-88B0-7367FC96EF3C" {name "Computers and Devices" guidtype CLSID}\
"645FF040-5081-101B-9F08-00AA002F954E" {name "Recycle Bin" guidtype CLSID}\
"26EE0668-A00A-44D7-9371-BEB064C98683" {name "Control Panel" guidtype CLSID}\
]
proc guid_lookup {guid} {
# This function looks up known GUIDs and returns their associated names or descriptions.
# In a real implementation, this could query a database of known GUIDs or use a predefined mapping of common GUIDs to their meanings.
# For example, it could recognize the CLSID for the "My Computer" folder, the "Network" folder, etc., and return human-readable names for those.
#
variable known_guids
if {[dict exists $known_guids $guid]} {
return [dict get $known_guids $guid]
} else {
if {"windows" eq $::tcl_platform(platform)} {
# On Windows, we can use the registry to look up GUIDs.
#for now we will just look up CLSIDs in HKEY_CLASSES_ROOT CLSID {guid}
package require registry
set reg_path [join [list HKEY_CLASSES_ROOT CLSID "{$guid}"] "\\"]
if {![catch {registry get $reg_path ""} name]} {
return [dict create name $name guidtype "CLSID"]
}
return ""
} else {
# On non-Windows platforms, we likely won't have a way to look up the GUID.
return ""
}
}
return ""
}
#some more hints: https://github.com/libyal/libfwsi/blob/main/documentation/Windows%20Shell%20Item%20format.asciidoc
proc Parse_LinkTargetID_typehex_1F {rawcontent} {
#The structure of this ItemID type is as follows:
#Offset 0: 2 bytes - size of the ItemID (including these 2 bytes)
#Offset 2: 1 byte - type byte (0x1F for file system objects)
#Offset 3: variable length - data specific to the item (e.g. file name, attributes, etc.)
set size_field [string range $rawcontent 0 1]
binary scan $size_field su size
set type_byte [string index $rawcontent 2]
if {[format %02X [scan $type_byte %c]] ne "1F"} {
error "punk::winlnk::Parse_LinkTargetID_typehex_1F error - expected type byte 0x1F but got [format %02X [scan $type_byte %c]]"
}
set parsed [dict create]
if {$size == 20} {
#when size is 20 and type is 0x1F - the data is assumed to be a GUID.
dict set parsed ident "GUID"
dict set parsed indicator [string range $rawcontent 3 3] ;#unknown - specific to shell implementation?
#structure is 4 bytes le, 2 bytes le, 2 bytes le, 8 bytes be
#the final hex format is 8-4-4-4-12 (e.g. 00021401-0000-0000-C000-000000000046) but the endianness is different for the first three parts vs the last two parts, which is a common pattern in Windows GUID/CLSID structures
set d1 [string range $rawcontent 4 7]
set d2 [string range $rawcontent 8 9]
set d3 [string range $rawcontent 10 11]
set d4 [string range $rawcontent 12 19]
#set d1_dec [scan $d1 i val1]
binary scan $d1 i d1_dec
binary scan $d2 s d2_dec
binary scan $d3 s d3_dec
#d4 is 8 bytes treated as individual bytes, so we can scan it as 8 individual bytes
set scan [string repeat %c 8]
set fmt [string repeat %02X 8]
set val4 [scan $d4 $scan]
set guid [format "%08X-%04X-%04X-%02X%02X-%02X%02X%02X%02X%02X%02X" $d1_dec $d2_dec $d3_dec {*}$val4]
dict set parsed guid $guid
set guid_info [guid_lookup $guid]
if {[dict size $guid_info]} {
dict set parsed name [dict get $guid_info name]
dict set parsed guidtype [dict get $guid_info guidtype]
}
return $parsed
} else {
#unknown
return $parsed
}
set data [string range $rawcontent 3 [expr {$size - 1}]]
#TODO - parse the data according to the structure of the ItemID type 0x1F
return [dict create size $size type "1F" rawdata $data]
}
#some clues on the structure of the IDList content and how to parse it can be found in the analysis of CVE-2020-0729,
#which is a remote code execution vulnerability in Windows that can be exploited through specially crafted .lnk files that contain malicious IDList content.
#The analysis of this vulnerability provides insights into how the IDList content is structured and how it can be parsed to extract information about the link target and potentially execute code.
@ -498,8 +592,8 @@ tcl::namespace::eval punk::winlnk {
set offset 0
while {$offset < [string length $idlist_content]} {
if {[string length $idlist_content] - $offset < 2} break
set size_bytes [string range $idlist_content $offset [expr {$offset + 1}]] ;#size including these 2 bytes
binary scan $size_bytes su size
set size_field [string range $idlist_content $offset $offset+1] ;#size including these 2 bytes
binary scan $size_field su size
if {$size == 0} break
if {$size < 2} {
# Invalid size, abort
@ -511,15 +605,24 @@ tcl::namespace::eval punk::winlnk {
break
}
set itemid [string range $idlist_content $offset [expr {$offset + $size - 1}]]
set itemid_bytes [string range $itemid 0 1]
binary scan $itemid_bytes su itemid_size
set itemid_size_field [string range $itemid 0 1]
binary scan $itemid_size_field su itemid_size
#in *general* byte 3 of the ItemID structure can be used to determine the type of the item
#(e.g. file, folder, network location, etc.) but this is not always reliable and can vary
#based on the specific structure of the ItemID and the context in which it is used
set itemid_type_byte [string index $itemid 2]
#puts stderr "ItemID size: $itemid_size, type byte: [format %02X [scan $itemid_type_byte %c]]"
set maybe_type [format %02X [scan $itemid_type_byte %c]]
lappend result [dict create size $itemid_size type $maybe_type rawcontent $itemid]
set typedec [scan $itemid_type_byte %c]
set typehex [format %02X $typedec]
set item_dict [dict create size $itemid_size typehex $typehex typedec $typedec rawbytes $itemid viewbytes [ansistring VIEW -lf 1 $itemid]]
switch -- $typehex {
"1F" {
set parsed [Parse_LinkTargetID_typehex_1F $itemid]
dict set item_dict parsed $parsed
}
}
lappend result $item_dict
incr offset $size
}
@ -615,9 +718,91 @@ tcl::namespace::eval punk::winlnk {
return [dict create localbasepath $localbasepath commonpathsuffix $commonpathsuffix note <incomplete>]
}
proc Contents_Get_DataStrings {contents start} {
set data_strings [string range $contents $start end]
return $data_strings
proc Contents_Get_StringData_String {contents start} {
#All StringData structures have a CountCharacters field of 2 bytes that is an unsigned integer specifying the number of characters
#in the string, followed by the string characters themselves.
#The string of variable length, must not be NULL-terminated.
#(which is 2 bytes of 0x00 if the IsUnicode flag is set in the LinkFlags field, or 1 byte of 0x00 if the IsUnicode flag is not set).
set lenfield [string range $contents $start $start+1]
set r [binary scan $lenfield su count_chars] ;# su is for unsigned short in little endian order
set string_value ""
if {[Header_Has_LinkFlag $contents "IsUnicode"]} {
#string is UTF-16LE encoded
set numbytes [expr {2 * $count_chars}]
set string_bytes [string range $contents $start+2 [expr {$start + 2 + $numbytes - 1}]]
#consider using tcl encoding convertfrom utf-16le instead of manually parsing the UTF-16LE bytes - this would be more robust and handle edge cases better (e.g. surrogate pairs, non-BMP characters, etc.)
set string_value [encoding convertfrom utf-16le $string_bytes]
#for {set i 0} {$i < [string length $string_bytes]} {
# set char_bytes [string range $string_bytes $i [expr {$i + 1}]]
# set r [binary scan $char_bytes su char] ;# s for unsigned short
# append string_value [format %c $char]
# incr i 1 ;# skip the next byte since it's part of the UTF-16LE encoding
#}
} else {
set numbytes $count_chars
set string_bytes [string range $contents $start+2 [expr {$start + 2 + $numbytes - 1}]]
#string is ANSI encoded
set string_value $string_bytes
}
return [dict create string $string_value next_start [expr {$start + 2 + $numbytes}]]
}
#MS-SHLLINK StringData
#https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-shllink/17b69472-0f34-4bcf-b290-eccdb8de224b
#stored in following order - each element is optional based on the presence of corresponding flags in the LinkFlags field of the header:
# NAME_STRING (description) - flag HasName
# RELATIVE_PATH - flag HasRelativePath
# WORKING_DIR - flag HasWorkingDir
# COMMAND_LINE_ARGUMENTS - flag HasArguments
# ICON_LOCATION - flag HasIconLocation
proc Contents_Get_StringData {contents start} {
set flags_enabled [Header_Get_LinkFlags_as_list $contents]
#puts stderr "flags_enabled: $flags_enabled"
#set data_strings [string range $contents $start end]
#return $data_strings
set current_offset $start
set result {}
if {"HasName" in $flags_enabled} {
set stringinfo [Contents_Get_StringData_String $contents $current_offset]
#set current_offset [expr {$current_offset + [string length $name] + 1}] ;#+1 for null terminator
#incr current_offset [string length $name]
set current_offset [dict get $stringinfo next_start]
set name [dict get $stringinfo string]
dict set result name_string $name
}
if {"HasRelativePath" in $flags_enabled} {
set stringinfo [Contents_Get_StringData_String $contents $current_offset]
set current_offset [dict get $stringinfo next_start]
set relative_path [dict get $stringinfo string]
#set current_offset [expr {$current_offset + [string length $relative_path] + 1}] ;#+1 for null terminator
dict set result relative_path $relative_path
}
if {"HasWorkingDir" in $flags_enabled} {
set stringinfo [Contents_Get_StringData_String $contents $current_offset]
set current_offset [dict get $stringinfo next_start]
set working_dir [dict get $stringinfo string]
#set current_offset [expr {$current_offset + [string length $working_dir] + 1}] ;#+1 for null terminator
dict set result working_dir $working_dir
}
if {"HasArguments" in $flags_enabled} {
set stringinfo [Contents_Get_StringData_String $contents $current_offset]
set current_offset [dict get $stringinfo next_start]
set arguments [dict get $stringinfo string]
#set current_offset [expr {$current_offset + [string length $arguments] + 1}] ;#+1 for null terminator
dict set result command_line_arguments $arguments
}
if {"HasIconLocation" in $flags_enabled} {
set stringinfo [Contents_Get_StringData_String $contents $current_offset]
set current_offset [dict get $stringinfo next_start]
set icon_location [dict get $stringinfo string]
#set current_offset [expr {$current_offset + [string length $icon_location] + 1}] ;#+1 for null terminator
dict set result icon_location $icon_location
}
return $result
}
proc Contents_Get_Info {contents} {
@ -639,12 +824,13 @@ tcl::namespace::eval punk::winlnk {
#Working Dir: C:\repo\jn\tclmodules\tomlish\src\modules\test\#modpod-tomlish-0.1.1\suites\roundtrip\roundtrip_files
variable LinkFlags
set flags_enabled [list]
dict for {k v} $LinkFlags {
if {[Header_Has_LinkFlag $contents $k] > 0} {
lappend flags_enabled $k
}
}
set flags_enabled [Header_Get_LinkFlags_as_list $contents]
#set flags_enabled [list]
#dict for {k v} $LinkFlags {
# if {[Header_Has_LinkFlag $contents $k] > 0} {
# lappend flags_enabled $k
# }
#}
set showcommand_val [Header_Get_ShowCommand $contents]
switch -- $showcommand_val {
@ -726,19 +912,13 @@ tcl::namespace::eval punk::winlnk {
}
# ----------------------------------------------------------------------
#todo - get Data strings by parsing contents starting at $next_start
#stored in following order:
# description
# relative path
# working directory
# command line arguments
# icon location
#get StringData by parsing contents starting at $next_start
#Data strings format:
# 2 bytes: number of characters in the string
# following: The string. ASCII or UTF-16 little-endian string
set datastring_dict [Contents_Get_DataStrings $contents $next_start]
#puts stderr "next_start: $next_start"
set datastring_dict [Contents_Get_StringData $contents $next_start]
# ----------------------------------------------------------------------
@ -770,18 +950,32 @@ tcl::namespace::eval punk::winlnk {
target_type_mech $target_type_mech\
idlist $linktargetidlist\
linkinfo $linkfields\
stringdata $datastring_dict\
]
#relative_path "?"
}
namespace eval argdoc {
variable PUNKARGS
lappend PUNKARGS [list {
@id -id ::punk::winlnk::file_check_header
@cmd -name punk::winlnk::file_check_header\
-summary\
"Test if .lnk file has a valid header for a windows shortcut."\
-help\
"Check the header of the file specified in path to see if it matches the expected
structure of a windows .lnk file header.
Returns a boolean.
If an invalid path is provided or the file cannot be read, an error will be raised."
@values -min 1 -max 1
path -type string -help "Path to the .lnk file to resolve"
}]
}
proc file_check_header {path} {
#*** !doctools
#[call [fun file_check_header] [arg path] ]
#[para]Return 0|1
#[para]Determines if the .lnk file specified in path has a valid header for a windows shortcut
set c [Get_contents $path 20]
return [Contents_check_header $c]
}
namespace eval argdoc {
variable PUNKARGS
lappend PUNKARGS [list {
@ -798,10 +992,6 @@ tcl::namespace::eval punk::winlnk {
}]
}
proc resolve {path} {
#*** !doctools
#[call [fun resolve] [arg path] ]
#[para] Return a dict of info obtained by parsing the binary data in a windows .lnk file
#[para] If the .lnk header check fails, then the .lnk file probably isn't really a shortcut file and the dictionary will contain an 'error' key
set c [Get_contents $path]
if {[Contents_check_header $c]} {
return [Contents_Get_Info $c]
@ -840,6 +1030,7 @@ tcl::namespace::eval punk::winlnk {
target_type target_type\
idlist idlist/@*/@*.@*\
linkinfo linkinfo/@*.@*\
stringdata stringdata/@*.@*\
]
set info [resolve $path]
if {[dict exists $info error]} {
@ -869,11 +1060,18 @@ tcl::namespace::eval punk::winlnk {
"Return the target path of the .lnk file specified in path.
This is a convenience function that extracts the target path from the .lnk file and returns it directly,
without all the additional information that resolve provides. If the .lnk header check fails, then
the .lnk file probably isn't really a shortcut file and an error message will be returned."
the .lnk file probably isn't really a shortcut file and an error message will be returned.
Incomplete! - needs to process arguments when HasArguments link flag is set and append the arguments to the target path.
e.g for a shortcut to 'START /D ...' the target will currently just return a path to cmd.exe - which is insufficient."
@values -min 1 -max 1
path -type string -help "Path to the .lnk file to resolve"
}]
}
#consider a shortcut to the following:
# START /D ^"C:\tcl\bin^" wish.exe c:\cmdfiles\ftp.tcl"
# the target currently only returns c:/Windows/System32/cmd.exe.
proc target {path} {
#*** !doctools
#[call [fun target] [arg path] ]
@ -941,19 +1139,6 @@ tcl::namespace::eval punk::winlnk {
}
#proc sample1 {p1 n args} {
# #*** !doctools
# #[call [fun sample1] [arg p1] [arg n] [opt {option value...}]]
# #[para]Description of sample1
# #[para] Arguments:
# # [list_begin arguments]
# # [arg_def tring p1] A description of string argument p1.
# # [arg_def integer n] A description of integer argument n.
# # [list_end]
# return "ok"
#}
#*** !doctools
@ -973,13 +1158,6 @@ tcl::namespace::eval punk::winlnk::lib {
#[para] Secondary functions that are part of the API
#[list_begin definitions]
#proc utility1 {p1 args} {
# #*** !doctools
# #[call lib::[fun utility1] [arg p1] [opt {?option value...?}]]
# #[para]Description of utility1
# return 1
#}
#*** !doctools
@ -1010,7 +1188,7 @@ namespace eval ::punk::args::register {
package provide punk::winlnk [tcl::namespace::eval punk::winlnk {
variable pkg punk::winlnk
variable version
set version 0.1.1
set version 0.1.1
}]
return

41
src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/punk/winpath-0.1.0.tm

@ -136,18 +136,30 @@ namespace eval punk::winpath {
proc illegal_char_map_to_ntfs {ch} {
#windows ntfs maps illegal chars to the PUA unicode range 0xF000-0xF0FF for characters that are illegal in windows API but can exist on NTFS or samba shares etc.
#see also: https://cygwin.com/cygwin-ug-net/using-specialnames.html#pathnames-specialchars
#see also: https://stackoverflow.com/questions/76738031/unicode-private-use-characters-in-ntfs-filenames#:~:text=map_dict%20=%20%7B%200xf009:'%5C,c%20return%20(output_name%2C%20mapped)
#see also: https://stackoverflow.com/questions/76738031/unicode-private-use-characters-in-ntfs-filenames
#formula is 0xF000 + ascii value of char.
#(decimal values are 61440 + ascii value of char)
#see also punk::char::ascii2NTFSPUA
# punk::char::codetable ascii
set map [dict create \
"\"" "\uF022" \
"*" "\uF02A" \
":" "\uF03A" \
"<" "\uF03C" \
">" "\uF03E" \
":" "\uF03A" \
"\"" "\uF022" \
"/" "unknown" \
"?" "\uF03F" \
"\\" "\uF05c" \
"|" "\uF07C" \
"?" "\uF03F" \
"*" "\uF02A"]
]
dict set map "/" "\uF02F"
#ESC (\x1b) is also mapped.
dict set map \x1b "\uF01B"
#note that : is used for NTFS alternate data streams - but the character is still illegal in the main filename - so we will map it to a glyph in the PUA range for display purposes - but it will still need dos device syntax to be accessed via windows API.
if {$ch in [dict keys $map]} {
return [dict get $map $ch]
@ -412,14 +424,15 @@ namespace eval punk::winpath::system {
}
# -----------------------------------------------------------------------------
# register namespace(s) to have PUNKARGS,PUNKARGS_aliases variables checked
# -----------------------------------------------------------------------------
# variable PUNKARGS
# variable PUNKARGS_aliases
namespace eval ::punk::args::register {
#use fully qualified so 8.6 doesn't find existing var in global namespace
lappend ::punk::args::register::NAMESPACES ::punk::winpath
}
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
## Ready

247
src/vfs/_vfscommon.vfs/modules/punk-0.1.tm

@ -75,6 +75,76 @@ namespace eval punk {
#
}
if {![interp issafe] && $::tcl_platform(platform) eq "windows"} {
#return the raw command string from the registry for the association of the given extension, without processing the placeholders such as %1 %SystemRoot% etc.
#This is because we want to process these ourselves to be able to return a proper list of command and arguments.
#Note that the resulting string can't be directly treated as a tcl list because it has double quoted segments with characters that are literals (not escaped)
#Accessing it directly as a list will cause tcl to interpret the backslashes as escapes and lose the literal meaning values such as the path.
proc extension_open_association {ext} {
#look up assoc and ftype to find associated file type and application.
#Note that the windows 'ftype' and 'assoc' commands only examine the system's data - not the user-specific data which may override the system data.
#to get the user-specific associations we need to read the registry keys.
#extensions in the registry seem to be stored lower case wnd with a leading dot.
set lext [string tolower $ext]
package require registry
set user_assoc_path [join [list HKEY_CURRENT_USER Software Microsoft Windows CurrentVersion Explorer FileExts $lext UserChoice] "\\"]
#The UserChoice subkey under this key is where the user-specific association is stored when the user has made a choice. It contains a Progid value which points to the associated file type.
#It can return something like "Python.File" or "Applications\python.exe" or even tcl_auto_file (for .tcl files associated with tclsh by a tcl installer)
#The UserChoice key may not exist if the user hasn't made an explicit choice, in which case we fall back to the system association which is stored under HKEY_CLASSES_ROOT\$ext (which also contains user-specific overrides but is more cumbersome to query for them)
if {![catch {registry get $user_assoc_path Progid} user_choice]} {
if {$user_choice ne ""} {
#examples such as Applications\python.exe and tcl_auto_file are found under HKEY_CURRENT_USER\Software\Classes
#they then have a sub-path shell\open\command which contains the command to open files of that type, which is what we need to extract the associated application.
#it is faster to query for the key directly within a catch than to retrieve keys as lists and iterate through them to find the one we want.
if {![catch {registry get [join [list HKEY_CURRENT_USER Software Classes $user_choice shell open command] "\\"] ""} raw_assoc]} {
#The command string can contain placeholders like "%1" for the file name, so we need to extract just the executable path.
#e.g .py -> "c:\Program Files\Python\python.exe" "%1"
#e.g .rb -> "C:\tools\ruby31\bin\ruby.exe" "%1" %*
# e.g .vbs -> "%SystemRoot%\System32\WScript.exe" "%1" %*
#we need to process this without Tcl interpreting the backslashes as escapes.
#we will use double quotes to determine which entries belong together as a list item for the resulting list of command and arguments.
#set assoc [string map [list \\ \\\\] $raw_assoc]
set assoc $raw_assoc
} else {
#e.g Python.File
if {![catch {registry get [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes $user_choice shell open command] "\\"] ""} raw_assoc]} {
#set assoc [string map [list \\ \\\\] $raw_assoc]
set assoc $raw_assoc
} else {
set assoc ""
}
}
} else {
#review - is it possible for Progid to be empty string? If so we should probably fall back to system association instead of returning no association.
#alternatively it could mean the user has explicitly chosen to have no association for this file type - in which case returning an empty association may be the correct behaviour.
set assoc ""
}
} else {
#fall back to system association and ftype
if {![catch {registry get [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes $lext] "\\"] ""} ftype]} {
#ftype is the file type associated with the extension, e.g "Python.File"
#we then need to look up the command associated with this file type under the same path but with the file type instead of the extension and with the additional sub-path shell\open\command
if {![catch {registry get [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes $ftype shell open command] "\\"] ""} raw_assoc]} {
#set assoc [string map [list \\ \\\\] $raw_assoc]
set assoc $raw_assoc
} else {
set assoc ""
}
} else {
set assoc ""
}
}
return $assoc
}
}
if {![interp issafe] && $has_commandstack && $::tcl_platform(platform) eq "windows"} {
#still a caching version of auto_execok - but with proper(fixed) search order
@ -113,6 +183,8 @@ namespace eval punk {
proc auto_execok_better name {
global auto_execs env tcl_platform
#for now at least, auto_execok_better is windows-specific.
package require punk::auto_exec
if {[info exists auto_execs($name)]} {
return $auto_execs($name)
@ -141,11 +213,60 @@ namespace eval punk {
}
if {[llength [file split $name]] != 1} {
#has a path
#has a path component - could be relative or absolute.
if {[string tolower [file extension $name]] eq ".lnk"} {
#special case .lnk
#todo - consider working directory or other properties of link before launching?
package require punk::winlnk
if {![catch {punk::winlnk::target $name} linktarget]} {
if {$linktarget ne ""} {
set target $linktarget
} else {
return ""
}
} else {
set target $name
}
} else {
set target $name
}
#always store $name as the key when setting auto_execs.
foreach ext $execExtensions {
set file ${name}${ext}
set file ${target}${ext}
#first execExtension is empty string - ensure we test actual file as given before we try appending extensions.
if {$ext eq ""} {
set test_ext [file extension $file]
} else {
set test_ext $ext
}
if {[file exists $file] && ![file isdirectory $file]} {
return [set auto_execs($name) [list $file]]
#look up assoc and ftype to find associated file type and application.
#Note that the windows 'ftype' and 'assoc' commands only examine the system's data - not the user-specific data which may override the system data.
#set assoc [extension_open_association $ext]
set associnfo [punk::auto_exec::shell_open_command $test_ext]
set valuetype [dict get $associnfo type]
set assoc [dict get $associnfo value]
set ftype [dict get $associnfo filetype]
if {$assoc eq ""} {
return [set auto_execs($name) [list $file]]
} else {
if {[file pathtype $target] eq "relative" && $ftype eq "InternetShortcut"} {
#special case InternetShortcut - cannot accept relative path - so we can't cache it in auto_execs if we used a relative path to launch
#if we return an empty string - the auto_exec will fail to launch this every time.
#The best we can do is return a token for the 'unknown' process to detect and re-resolve the path every time.
#This requires cooperation from 'unknown' which may not be configured to handle this token if the default 'punk' version isn't installed.
#we can't resolve using absolute path here - because we don't want to lock in a specific file for a relative path.
#e.g ::auto_execs(./link.url) = some.exe c:/desktop/link.url
#this would be wrong if the user changed directory and tried to run ./link.url again on a different file with the same name
# - as the cached path would no longer be correct.
return [set auto_execs($name) "(resolve_in_unknown_handler) punk::auto_exec absolute_path required"]
}
puts stderr "auto_execok_better: (review required) assoc $assoc for file $file ext $test_ext"
set run [punk::auto_exec::shell_command_as_tcl_list -type $valuetype $assoc $file] ;# -workingdir [pwd] vs path of script?
return [set auto_execs($name) $run]
#return [set auto_execs($name) [list $file]]
}
}
}
return ""
@ -164,11 +285,20 @@ namespace eval punk {
append path "$windir/system32;$windir/system;$windir;"
}
foreach var {PATH Path path} {
if {[info exists env($var)]} {
append path ";$env($var)"
}
# ------------------------
#Note that unlike an ordinary Tcl array - the linked ::env behaves differently.
#e.g parray ::env Path will not find ::env(PATH) and yet 'info exists env(Path)' returns true.
#similarly 'set ::env(Path) ?newval?' or any case variation can set/get the value of ::env(PATH)
#Windows environment variables are case-insensitive.
#No evidence has been seen that any version of windows; current or historic since NT; can allow differently cased versions
# of an environment variable to exist concurrently in the same environment.
#for this reason we should be able to just use PATH.
#
if {[info exists env(PATH)]} {
append path ";$env(PATH)"
}
# ------------------------
#change2
if {[file extension $name] ne "" && [string tolower [file extension $name]] in [string tolower $execExtensions]} {
@ -177,6 +307,8 @@ namespace eval punk {
set lookfor [lmap ext $execExtensions {string cat ${name} ${ext}}]
}
#puts "-->$lookfor"
foreach dir [split $path {;}] {
set dir [string trim $dir {\\}] ;#trailing slash will result in a tail such as "/python.exe"
#set dir [file normalize $dir]
@ -207,7 +339,30 @@ namespace eval punk {
foreach match [glob -nocomplain -dir $dir -tail {*}$lookfor] {
set file [file join $dir $match]
if {[file exists $file] && ![file isdirectory $file]} {
return [set auto_execs($name) [list $file]]
#set assoc [extension_open_association [file extension $file]]
#todo - cache this lookup for each extension we encounter? maybe not, as the user might like changes reflected between runs. review.
if {"windows" ne $::tcl_platform(platform)} {
return [set auto_execs($name) [list $file]]
}
set associnfo [punk::auto_exec::shell_open_command [file extension $file]]
set assoc [dict get $associnfo value]
set type [dict get $associnfo type]
if {$assoc eq ""} {
return [set auto_execs($name) [list $file]]
} else {
puts stderr "auto_execok_better: assoc $assoc for file $file with type $type"
#return [set auto_execs($name) [list $file]]
#review - our stored auto_execs doesn't have any way to capture the full assoc info such as how subsequent arguments should be processed.
#This may need handling in our Tcl shell 'unknown' function when calls are actually made to these commands
#- we may need to re-process the assoc info at that point to determine how to combine all arguments with the calling specification in the assoc string.
#The workingdir for the command may also need to be determined at that point - should it be the dir of the script being called, or the current dir of the shell?
#The main point of Tcl's auto_execs is to avoid scanning the PATH entries every time a command is called,
#but we may want to keep some of the assoc info available for processing at call time.
set run [punk::auto_exec::shell_command_as_tcl_list -type $type $assoc $file] ;# -workingdir [pwd] vs path of script?
return [set auto_execs($name) $run]
}
}
}
}
@ -5203,11 +5358,11 @@ namespace eval punk {
#These can be stacked with shellfilter and operate as OS handles - which we can't do with fifo2 etc
#
if {[string first " " $new] > 0} {
set c1 $name
} else {
set c1 $new
}
#if {[string first " " $new] > 0} {
# set c1 $name
#} else {
# set c1 $new
#}
# -- --- --- --- ---
set idlist_stdout [list]
@ -5241,6 +5396,24 @@ namespace eval punk {
} else {
set repl_runid [punk::get_repl_runid]
#set ::punk::last_run_display [list]
if {$new eq "(resolve_in_unknown_handler) punk::auto_exec absolute_path required"} {
#re-resolve.
puts "(unknown-handler): auto_execok for $name requires absolute path. Re-resolving $name with absolute path."
set ext [file extension $name]
set associnfo [punk::auto_exec::shell_open_command $ext]
set valuetype [dict get $associnfo type]
set assoc [dict get $associnfo value]
set ftype [dict get $associnfo filetype]
set fullpath [file normalize $name]
#at least for .url files - long paths (paths with multiple spaces?) can fail to run. Using the short path seems to fix this.
#This seems hacky but anyway..
set attributes [file attributes $fullpath]
if {[dict exists $attributes -shortname]} {
set fullpath [dict get $attributes -shortname]
}
set new [punk::auto_exec::shell_command_as_tcl_list -type $valuetype $assoc $fullpath]
}
set redir ">&@stdout <@stdin"
uplevel 1 [list ::catch [concat exec $redir $new [lrange $args 1 end]] ::tcl::UnknownResult ::tcl::UnknownOptions]
@ -6108,6 +6281,16 @@ namespace eval punk {
}
}
#This should be designed to be useful on all platforms.
#Case sensitivity represents a difficulty because even on a particular platform
#- different filesystems or folders may have different case sensitivity configurations.
#as a first step - we can detect windows and mac platforms and treat paths as case-insensitive, vs case-sensitive on other unix-like platforms.
#as a second step - we will consider running a test on each path to determine if the folder at the leaf level is case-sensitive or not.
#- and then use that information to determine how to treat the executables in that path.
#This may be a bit of a performance hit - so we may want to cache the results of this test for each path - and provide a way to clear the cache if needed.
#Alternatively we could just provide an option to treat all paths as case-sensitive or case-insensitive.
#Windows executable search location order:
#1. The current directory.
#2. The directories that are listed in the PATH environment variable.
@ -6203,8 +6386,12 @@ namespace eval punk {
#(This seems to occur when the pattern does *not* contain a wildcard and is probably a bug)
#We would rather report results with the actual case as they appear on the filesystem - so we try to normalize the results.
#The normalization doesn't work as expected - but can be worked around by using 'string range $e 0 end' instead of just $e - which seems to force Tcl to give us the actual case from the filesystem rather than the case as globbed for.
#(another workaround is to use a degenerate case glob e.g when looking for 'SDX.exe' to glob for '[S]DX.exe')
#(another workaround is to use a degenerate case glob e.g when looking for 'SDX.exe' to glob for '[S]DX.exe'
# but tcl's glob does not respect the case of even the character-class pattern - so this is not a reliable workaround).
#see punk::fglob for a work-in-progress glob implementation which gives us more control over case sensitivity and the case of results on windows.
set globresults [lsort -unique [glob -nocomplain -directory $pnormglob -types {f x} {*}$binglobs]]
#JJJ
set executables [list]
foreach e $globresults {
puts stderr "glob result: $e"
@ -6596,6 +6783,29 @@ namespace eval punk {
#These aliases work fine for interactive use - but the result is always a string int-rep
#interp alias {} ~ {} file join $::env(HOME) ;#HOME must be capitalized to work cross platform (lowercase home works on windows - but probably not elsewhere)
#interp alias {} ~ {} apply {args {file join $::env(HOME) $args}}
namespace eval argdoc {
punk::args::define {
@id -id ::punk::~
@cmd -name "punk::~"\
-summary\
"Return home directory"\
-help\
"Return the home directory path. With additional arguments,
return the path obtained by joining the home directory with
the supplied arguments.
usage e.g
cd [~]
(change to home directory)
.// [~] .config
(change to .config directory within home directory
and list contents.)
"
@opts
@values -min 0 -max -1
arg -type any -optional 1 -multiple 1
}
}
proc ~ {args} {
set hdir [punk::valcopy $::env(HOME)]
file pathtype $hdir
@ -7811,9 +8021,10 @@ namespace eval punk {
# -------------------------------------------------------
set title "[a+ brightgreen] Filesystem navigation: "
set cmdinfo [list]
lappend cmdinfo [list ./ "?${I}subdir${NI}?" "view/change directory"]
lappend cmdinfo [list ../ "" "go up one directory"]
lappend cmdinfo [list ./new "${I}subdir${NI}" "make new directory and switch to it"]
lappend cmdinfo [list ./ "?${I}glob${NI}?" "view/change dir, list dirs."]
lappend cmdinfo [list ../ "?${I}path${NI}" "go up one dir, then to path if given"]
lappend cmdinfo [list .// "?${I}glob${NI}?" "view/change dir, list dirs and files"]
lappend cmdinfo [list ./new "${I}subdir${NI}..." "make new dir or dirs and show status"]
lappend cmdinfo [list fcat "${I}file ?file?...${NI}" "cat file(s)"]
set t [textblock::class::table new -minwidth 80 -show_seps 0]
foreach row $cmdinfo {
@ -7859,7 +8070,7 @@ namespace eval punk {
lappend cmdinfo [list i "${I}cmd${NI} ?${I}subcommand${NI}...?" "Show usage for a command or ensemble subcommand"]
lappend cmdinfo [list s "${I}cmd${NI} ?${I}subcommand${NI}...?" "Show synopsis for a command or ensemble subcommand"]
lappend cmdinfo [list eg "${I}cmd${NI} ?${I}subcommand${NI}...?" "Show example from manpage"]
lappend cmdinfo [list corp "${I}proc${NI}" "View proc body and arguments"]
lappend cmdinfo [list corp "${I}proc${NI}" "View proc body and arguments with basic highlighting"]
set t [textblock::class::table new -minwidth 80 -show_seps 0]
foreach row $cmdinfo {
$t add_row $row

2
src/vfs/_vfscommon.vfs/modules/punk/ansi-0.1.1.tm

@ -1148,7 +1148,7 @@ tcl::namespace::eval punk::ansi {
"The grepstr command can find strings in ANSI text even if there are interspersed
ANSI colour codes etc. Even if a word has different coloured/styled letters, the
regex can match the plaintext. (Search is performed on ansistripped text, and then
the matched sections are highlighted and overlayed on the original styled/colourd
the matched sections are highlighted and overlayed on the original styled/coloured
input.
If the input string has ANSI movement codes - the resultant text may not be directly

35
src/vfs/_vfscommon.vfs/modules/punk/args-0.2.1.tm

@ -6148,7 +6148,7 @@ tcl::namespace::eval punk::args {
### set e [lindex $clauseval $clausecolumn]
### set e_check [lindex $clauseval_check $clausecolumn]
### #//review - we may need '?' char on windows
### if {!([tcl::string::length $e_check]>0 && ![regexp {[\"*<>\;]} $e_check])} {
### if {!([tcl::string::length $e_check]>0 && ![regexp {[\"*<>]} $e_check])} {
### #what about special file names e.g on windows NUL ?
### set msg "$argclass $argname for %caller% requires type '$type'. Received: '$e' which doesn't look like it could be a file or directory"
### return -options [list -code error -errorcode [list PUNKARGS VALIDATION [list typemismatch $type] -badarg $argname -argspecs $argspecs]] $msg
@ -6671,9 +6671,12 @@ tcl::namespace::eval punk::args {
directory -
existingfile -
existingdirectory {
#//review - we may need '?' char on windows
if {!([tcl::string::length $e_check]>0 && ![regexp {[\"*<>\;]} $e_check])} {
#what about special file names e.g on windows NUL ?
#we have to support file and directory names on all platforms - and even characters illegal on a filesystem/platform may need to be passed.
#For example a file/folder may be created with an illegal name on a platform (or mounted on it) and be mapped to another string on the filesystem
#- yet it may remain accessible to commands such as file stat etc via the string with 'illegal' characters as well as its underlying stored (mapped) name.
#NUL is almost universally problematic - so we will reject.
if {[tcl::string::length $e_check]==0 || [string first \0 $e_check] >= 0} {
set msg "$argclass $argname for %caller% requires type '$type'. Received: '$e' which doesn't look like it could be a file or directory"
lset clause_results $c_idx $a_idx [list err [list typemismatch $type] msg $msg]
continue
@ -6693,6 +6696,30 @@ tcl::namespace::eval punk::args {
}
lset clause_results $c_idx $a_idx 1
}
existingportablefile -
existingportabledirectory -
portablefile -
portabledirectory {
if {[tcl::string::length $e_check]==0 || [string first \0 $e_check] >= 0 || [punk::winpath::illegalname_test $e_check]} {
set msg "$argclass $argname for %caller% requires type '$type'. Received: '$e' which doesn't look like it could be a portable file or directory (must pass punk::winpath::illegalname_test)"
lset clause_results $c_idx $a_idx [list err [list typemismatch $type] msg $msg]
continue
}
if {$type eq "existingportablefile"} {
if {![file exists $e_check]} {
set msg "$argclass $argname for %caller% requires type '$type'. Received: '$e' which is not an existing file"
lset clause_results $c_idx $a_idx [list err [list typemismatch $type] msg $msg]
continue
}
} elseif {$type eq "existingportabledirectory"} {
if {![file isdirectory $e_check]} {
set msg "$argclass $argname for %caller% requires type '$type'. Received: '$e' which is not an existing directory"
lset clause_results $c_idx $a_idx [list err [list typemismatch $type] msg $msg]
continue
}
}
lset clause_results $c_idx $a_idx 1
}
char {
#review - char vs unicode codepoint vs grapheme?
if {[tcl::string::length $e_check] != 1} {

19
src/vfs/_vfscommon.vfs/modules/punk/args/moduledoc/tclcore-0.1.0.tm

@ -5425,6 +5425,23 @@ tcl::namespace::eval punk::args::moduledoc::tclcore {
glob */
}]}
except that the first case doesn't return the trailing “/” and is more platform independent."
#todo - review eopts handling.
#The actual glob function will treat the first non-dashed argument as the first pattern, whereas
#punk::args will treat the first invalid option or non-dashed as the end of options and the start of patterns.
#This means that if you have a pattern that starts with a dash, you must use -- to mark the end of options.
#We need a way to force punk::args to have that more restrictive behaviour in this case so that the help is aligned.
#ie tcl glob requires -- whenever values start with a dash, but punk::args only requires it only when the first of multiple values
#start with a dash or when a single has a prefix-match for a flag.
#Arguably punk::args should be more restrictive and require -- whenever any value starts with a dash.
#e.g punk::args will accept "glob -nonflag" but will warn of "glob -nonflag -nonflag" as an invalid option.
#This helps provide warnings for mistyped flags, but is potentially confusing.
#In any case, the help for glob should be consistent with the actual behaviour of glob, so we need to make sure that punk::args
#is configured to require -- whenever any value starts with a dash.
#This requires either a change to the default behaviour of punk::args when {-- -type none} is used,
#or a new option to control this behaviour.
#A new option may be preferable to avoid changing the default behaviour of punk::args in other cases where it may be desirable
#to allow values starting with dashes without requiring --.
#(e.g when a command only accepts a single value and that value may start with a dash)
-- -type none -help\
"Marks the end of switches. The argument following this one will be treated as a pattern even if it
starts with a -."
@ -6941,7 +6958,7 @@ tcl::namespace::eval punk::args::moduledoc::tclcore {
@opts -type string -parsekey "" -group "NESTED LIST OPTIONS" -grouphelp\
"These options are used to search lists of lists. They may be used with any other options."
-stride -type integer -default 1 -typesynopsis strideLength -help\
-stride -type integer -default 1 -range {1 ""} -typesynopsis strideLength -help\
"If this option is specified, the list is treated as consisting of groups of ${$I}strideLength${$NI} elements and the
groups are searched by either their first element or, if the ${$B}-index${$N} option is used, by the element within
each group given by the first index passed to -index (which is then ignored by -index). The resulting

786
src/vfs/_vfscommon.vfs/modules/punk/auto_exec-0.1.0.tm

@ -0,0 +1,786 @@
# -*- tcl -*-
# Maintenance Instruction: leave the 999999.xxx.x as is and use punkshell 'dev make' or bin/punkmake to update from <pkg>-buildversion.txt
# module template: shellspy/src/decktemplates/vendor/punk/modules/template_module-0.0.4.tm
#
# Please consider using a BSD or MIT style license for greatest compatibility with the Tcl ecosystem.
# Code using preferred Tcl licenses can be eligible for inclusion in Tcllib, Tklib and the punk package repository.
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
# (C) 2026
#
# @@ Meta Begin
# Application punk::auto_exec 0.1.0
# Meta platform tcl
# Meta license <unspecified>
# @@ Meta End
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
## Requirements
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
package require Tcl 8.6-
#----------------------
# registry notes
#Computer\HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\ApplicationAssociationToasts
# - associates applications/file types with extensions and protocols.
#.cp items have a shell\cplopen\command subkey instead of shell\open\command
#- but the command string has the same format and placeholders as the shell\open\command entries for other file types,
#so we can handle them in the same way as other file types when we extract the associated command string.
#----------------------
tcl::namespace::eval punk::auto_exec {
namespace eval argdoc {
variable PUNKARGS
set I "\x1b\[3m" ;# [a+ italic]
set NI "\x1b\[23m" ;# [a+ noitalic]
set B "\x1b\[1m" ;# [a+ bold]
set N "\x1b\[22m" ;# [a+ normal]
set T "\x1b\[1\;4m" ;# [a+ bold underline]
set NT "\x1b\[22\;24m\x1b\[4:0m" ;# [a+ normal nounderline]
}
variable PUNKARGS
lappend PUNKARGS [list {
@id -id "::punk::auto_exec::shell_open_command"
@cmd -name "punk::auto_exec::assoc" -help\
{Returns the raw 'open' command string associated with the file type for the given file extension,
by looking up the user-specific association in the registry and falling back to the system
association if no user-specific association is found.
The resulting command string can contain placeholders like "%1" for the file name.
e.g .py -> "c:\Program Files\Python\python.exe" "%1"
e.g .rb -> "C:\tools\ruby31\bin\ruby.exe" "%1" %*
e.g .vbs -> "%SystemRoot%\System32\WScript.exe" "%1" %*
Note that the resulting string has unescaped backslashes within double quotes, so it is
not suitable for direct execution by Tcl without further processing to handle the backslashes
and placeholders.
see 'shell_command_as_tcl_list' for processing the command string into a Tcl list of command and
arguments with placeholders substituted.
}
@opts
@values -min 0 -max 1
ext -type string -default "" -optional true -help\
"File extension to look up, e.g .txt or .py"
}]
proc shell_open_command {ext} {
#look up assoc and ftype to find associated file type and application.
#Note that the windows 'ftype' and 'assoc' commands only examine the system's data - not the user-specific data which may override the system data.
#to get the user-specific associations we need to read the registry keys.
#extensions in the registry seem to be stored lower case wnd with a leading dot.
set lext [string tolower $ext]
package require registry
set user_assoc_path [join [list HKEY_CURRENT_USER Software Microsoft Windows CurrentVersion Explorer FileExts $lext UserChoice] "\\"]
#The UserChoice subkey under this key is where the user-specific association is stored when the user has made a choice. It contains a Progid value which points to the associated file type.
#It can return something like "Python.File" or "Applications\python.exe" or even tcl_auto_file (for .tcl files associated with tclsh by a tcl installer)
set openverb "open"
#The UserChoice key may not exist if the user hasn't made an explicit choice, in which case we fall back to the system association which is stored under HKEY_CLASSES_ROOT\$ext (which also contains user-specific overrides but is more cumbersome to query for them)
if {![catch {registry get $user_assoc_path Progid} user_choice]} {
if {$user_choice ne ""} {
#examples such as Applications\python.exe and tcl_auto_file are found under HKEY_CURRENT_USER\Software\Classes
#they then have a sub-path shell\open\command which contains the command to open files of that type, which is what we need to extract the associated application.
#it is faster to query for the key directly within a catch than to retrieve keys as lists and iterate through them to find the one we want.
#special case .cpl cplfile
if {$user_choice eq "cplfile"} {
set openverb "cplopen"
}
set verbinfo [ftype $user_choice]
if {[dict exists $verbinfo $openverb]} {
set ftypeinfo [dict get $verbinfo $openverb]
return [dict set ftypeinfo filetype $user_choice]
} else {
return [dict create type "notfound" value "" scope user filetype $user_choice]
}
} else {
#review - is it possible for Progid to be empty string? If so we should probably fall back to system association instead of returning no association.
#alternatively it could mean the user has explicitly chosen to have no association for this file type - in which case returning an empty association may be the correct behaviour.
return [dict create type "empty" value "" scope user filetype ""]
}
} else {
#fall back to system association and ftype
if {![catch {registry get [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes $lext] "\\"] ""} ftype]} {
#ftype is the file type associated with the extension, e.g "Python.File"
#we then need to look up the command associated with this file type under the same path but with the file type instead of the extension and with the additional sub-path shell\open\command
#special case .cpl cplfile
if {$ftype eq "cplfile"} {
set openverb "cplopen"
}
set verbinfo [ftype $ftype]
if {[dict exists $verbinfo $openverb]} {
set ftypeinfo [dict get $verbinfo $openverb]
return [dict set ftypeinfo filetype $ftype]
} else {
return [dict create type "notfound" value "" scope system filetype $ftype]
}
} else {
return [dict create type "notfound" value "" scope "" filetype ""]
}
}
#shouldn't get here - there is a return in each branch above.
return "no-result"
}
# %1 - standard placeholder for the first file parameter.
# %L (or %l) - Long File Name form of the path.
# %* - replaces with all parameters passed to the command. (ie not including the command itself)
# %W (or %w) - working directory.
# other placeholders that we won't handle for now:
# %I (or %i) - handle to an Item ID List (IDList)
# e.g for filetype ArchiveFolder (associated with .7z, .gz, .CPIO etc) we have "%SystemRoot%\Explorer.exe" /idlist,%I,%L
# %D (or %d) - Desktop absolute parsing name of the first parameter
# (for items that don't have file system paths) will be the same as %1 for file system items.
#todo - we need to substitute other placeholders such as %SystemRoot% etc. (case insensitively)
# - but only if the type of the key is expand_sz.
#These are environment variables that can be used in the command string.
#when type is sz - only the single letter placeholders are substituted, and environment variables are not substituted.
#when type is expand_sz - the single letter placeholders are substituted, and environment variables are also substituted
#(case insensitively) - but only if they are in the form %VAR%
#A matching environment variable will take precedence over a single letter placeholder if there is a conflict,
#e.g if the string is %1% and there is an environment variable named "1" then it will be substituted instead of the %1 placeholder.
#a non-existant environment variable will not be substituted and it's wrapping % characters will not be included in the output.
#similarly a single letter placeholder that is not recognised, such as %x, will result in only the x being included in the output without the leading % character.
#todo - tests for shell_command_as_tcl_list with various combinations of placeholders, quoted and unquoted,
#and with both reg_sz and reg_expand_sz types, and with various edge cases such as missing arguments, extra arguments,
#empty arguments, unrecognized placeholders, invalid env var references etc.
lappend PUNKARGS [list {
@id -id "::punk::auto_exec::shell_command_as_tcl_list"
@cmd -name "punk::auto_exec::shell_command_as_tcl_list" -help\
{}
@opts
-type -type string -default sz -choices {sz expand_sz} -help\
"The type of the registry value containing the command string, which determines how
environment variables are substituted. sz means environment variables will not
be substituted, and expand_sz means environment variables will be substituted
if they are in the form %VAR% (case insensitively) and will take precedence over
single letter placeholders if there is a conflict.
In either case, the single letter placeholders will be substituted as follows:
%1 - standard placeholder for the first file parameter.
%L (or %l) - Long File Name form of the path.
%* - replaced with all subsequent parameters passed to the command.
(but not including the script name itself)
%W (or %w) - working directory."
-workingdir -type string -default "" -help\
"The working directory to substitute for the %W (or %w) placeholder."
@values -min 1 -max -1
commandspec -type string -help\
"The command string to process, which can contain placeholders like %1 for the file name,
and a list of arguments to substitute for the placeholders. The command string is typically
obtained from the registry for a file type association, and the arguments are typically the
file name and other parameters to substitute into the command string."
arg -type any -multiple 1 -optional 1 -help\
{One or more arguments to substitute for the placeholders in the command string.
The first argument (often a script or document path) will be substituted for %1,
the second argument will be substituted for %2, and so on. If the command string
contains a %* placeholder, then all of the arguments will be substituted for that
placeholder starting from %2.
If there are more placeholders than arguments, then the extra placeholders will be
substituted with empty string.
If missing arguments are specified in the commandspec as quoted strings, eg "%3" then
corresponding empty strings as separate arguments will be included in the output.}
}]
proc shell_command_as_tcl_list {args} {
set argd [punk::args::parse $args withid ::punk::auto_exec::shell_command_as_tcl_list]
lassign [dict values $argd] _leaders opts values received
set type [dict get $opts -type]
set workingdir [dict get $opts -workingdir]
set commandspec [dict get $values commandspec]
if {[dict exists $received arg]} {
set arglist [dict get $values arg]
} else {
set arglist [list]
}
set result [list]
set quoted [list 0] ;#track whether the current chunk is quoted or not. Only the last item is relevant.
set chars [split [string trim $commandspec] ""]
set in_quote 0
set current_chunk {}
set new_chunk 1
set got_placeholder 0
for {set i 0} {$i < [llength $chars]} {incr i} {
set char [lindex $chars $i]
if {$in_quote} {
if {$char eq "\""} {
if {$got_placeholder} {
#wasn't a valid placeholder - % not emitted.
set got_placeholder 0
}
#The windows implementation doesn't seem to close off a chunk just because it encounters a closing quote,
#so we don't do that either.
#The closing quote just affects whether the next space will terminate the chunk or not.
set in_quote 0
} else {
if {$char eq "%"} {
#we do not handle the trailing % of an env var such as %VAR% here
#- as we scan for that in the default case of the switch below.
if {$got_placeholder} {
#this is a % escaped by doubling up ie a literal % in the output
append current_chunk "%"
set got_placeholder 0
} else {
set got_placeholder 1
}
} elseif {$got_placeholder} {
if {$type eq "expand_sz"} {
#we scan for environment variables in the form %VAR% (case insensitively) and substitute them if found, taking precedence over single letter placeholders if there is a conflict.
#a non-existant environment variable will not be substituted and it's wrapping % characters will not be included in the output.
set env_var_name $char
for {set j [expr {$i+1}]} {$j < [llength $chars]} {incr j} {
set next_char [lindex $chars $j]
if {$next_char in {" " \t}} {
#end of env var name - we don't expect to see spaces within environment variable names,
#treat space as terminator indicating failure to match.
break
} elseif {$next_char eq "\""} {
#end of env var name - we don't expect to see quotes within environment variable names,
#treat quote as terminator indicating failure to match.
break
} elseif {$next_char eq "%"} {
#end of *possible* env var name
break
} else {
append env_var_name $next_char
}
}
if {$next_char eq "%"} {
#we found a closing % character, so we have a possible env var name between the two % characters.
#we will substitute the env var value if it exists,
# or the value of any single letter placeholder if there is a match,
# or the original string between the two % characters if there is no match for either an env var or a single letter placeholder.
if {[info exists ::env($env_var_name)]} {
append current_chunk $::env($env_var_name)
set got_placeholder 0
set i $j ;#advance past the env var name and closing % character for next iteration of main loop
continue
}
} else {
#we didn't find a closing % character, so this isn't a valid env var reference.
set env_var_name ""
#fall through to single % placeholder handling below,
#which will treat the first character after the % as a single letter placeholder and include the rest of the string as literal characters in the output.
}
}
switch -- $char {
1 - L - l {
append current_chunk [lindex $arglist 0]
}
2 - 3 - 4 - 5 - 6 - 7 - 8 - 9 {
append current_chunk [lindex $arglist $char-1]
}
D - d {
#review
#we don't have a way to represent an Item ID List (IDList) in our API, so we'll just substitute the same value as %1 for now,
#which is what windows seems to do for file system items that have file paths.
append current_chunk [lindex $arglist 0]
}
I - i {
#we don't have a way to represent an Item ID List (IDList) in our API, so we'll just substitute something for now.
#format seems to be 9 and 5 digits :nnnnnnnnn:nnnnn
#REVIEW! unknown consequences!
append current_chunk ":000000000:00000"
}
"*" {
append current_chunk [lrange $arglist 1 end]
}
W - w {
append current_chunk $workingdir
}
default {
if {$type eq "expand_sz"} {
#if expand_sz we would have already scanned for environment variables in the form %VAR% and substituted them if found,
#taking precedence over single letter placeholders if there is a conflict.
append current_chunk "$char" [string range $env_var_name 1 end] ;#if env_var_name is empty string then this will just append the single char after the % as a literal,
#which is the correct behaviour for unrecognized placeholders such as %x and for invalid env var references such as %NONEXISTANT%
} else {
append current_chunk $char
}
}
}
set got_placeholder 0
} else {
append current_chunk $char
}
}
} else {
#NOT in quoted string
if {$char eq "\""} {
if {$got_placeholder} {
append current_chunk "%" ;#wasn't a valid placeholder char, so we need to add the % that we stripped off back into the output
set got_placeholder 0
}
set in_quote 1
set new_chunk 0
lset quoted end 1 ;#we'll mark as quoted if any quote found in it - review
} elseif {$char in [list " " \t ]} {
#we don't expect to see tabs or other whitespace characters as separators in the command string, but we'll treat tab the same as spaces just in case.
if {$got_placeholder} {
#wasn't a valid placeholder char. The % is stripped and not included in the output.
set got_placeholder 0
}
#space terminates an unquoted chunk, so we add it to the result list and start a new chunk.
#we also need to ensure that consecutive spaces are treated as a single separator and don't result in empty items in the output list,
if {!$new_chunk} {
if {$current_chunk ne "" || $current_chunk eq "" && [lindex $quoted end]} {
#we add the current chunk to the result list if it's not empty, or if it is empty but is quoted (because in that case we want to preserve it as an empty argument).
lappend result $current_chunk
lappend quoted 0
set current_chunk {}
}
set new_chunk 1
}
for {set j [expr {$i+1}]} {$j < [llength $chars]} {incr j} {
if {[lindex $chars $j] in {" " \t}} {
incr i
} else {
break
}
}
} else {
if {$got_placeholder} {
if {$type eq "expand_sz"} {
#we scan for environment variables in the form %VAR% (case insensitively) and substitute them if found, taking precedence over single letter placeholders if there is a conflict.
#a non-existant environment variable will not be substituted and it's wrapping % characters will not be included in the output.
set env_var_name $char
for {set j [expr {$i+1}]} {$j < [llength $chars]} {incr j} {
set next_char [lindex $chars $j]
if {$next_char in {" " \t}} {
#end of env var name - we don't expect to see spaces within environment variable names,
#treat space as terminator indicating failure to match.
break
} elseif {$next_char eq "\""} {
#end of env var name - we don't expect to see quotes within environment variable names,
#treat quote as terminator indicating failure to match.
break
} elseif {$next_char eq "%"} {
#end of *possible* env var name
break
} else {
append env_var_name $next_char
}
}
if {$next_char eq "%"} {
#we found a closing % character, so we have a possible env var name between the two % characters.
#we will substitute the env var value if it exists,
# or the value of any single letter placeholder if there is a match,
# or the original string between the two % characters if there is no match for either an env var or a single letter placeholder.
if {[info exists ::env($env_var_name)]} {
append current_chunk $::env($env_var_name)
set got_placeholder 0
set i $j ;#advance past the env var name and closing % character for next iteration of main loop
continue
}
} else {
#we didn't find a closing % character, so this isn't a valid env var reference.
set env_var_name ""
#fall through to single % placeholder handling below,
#which will treat the first character after the % as a single letter placeholder and include the rest of the string as literal characters in the output.
}
}
switch -- $char {
"%" {
append current_chunk "%"
set got_placeholder 1
continue
}
1 -
L - l {
#append current_chunk [lindex $arglist 0]
set append_value [string trim [lindex $arglist 0]]
foreach ch [split $append_value ""] {
if {$ch eq " "} {
#we need to split on spaces within arguments as well, because unquoted %1 can expand to multiple arguments if the first argument contains spaces, and we need to preserve the correct splitting of arguments in that case.
#e.g if %1 is substituted with "C:\My Documents\file.txt" then we need to split that into three items in the output list: "C:\My", "Documents\file.txt"
if {$current_chunk ne ""} {
lappend result $current_chunk
lappend quoted 0
set current_chunk {}
}
set new_chunk 1
} else {
append current_chunk $ch
set new_chunk 0
}
}
}
2 - 3 - 4 - 5 - 6 - 7 - 8 - 9 {
append current_chunk [lindex $arglist $char-1]
}
D - d {
#review
#we don't have a way to represent an Item ID List (IDList) in our API, so we'll just substitute the same value as %1 for now,
#which is what windows seems to do for file system items that have file paths.
append current_chunk [lindex $arglist 0]
}
I - i {
#we don't have a way to represent an Item ID List (IDList) in our API, so we'll just substitute something for now.
#format seems to be 9 and 5 digits :nnnnnnnnn:nnnnn
#REVIEW! unknown consequences!
append current_chunk ":000000000:00000"
}
"*" {
#the window implementation of unquoted %* always seems to emit the previous chunk before the %* placeholder as a separate item
#in the command line, even if there is no space between them, so we will do the same.
#Note that a following string (quoted or not) immediately after the %* will be appended to the last item in the output list rather than being a separate item,
#which is also consistent with the microsoft implementation.
if {!$new_chunk} {
if {$current_chunk ne "" || $current_chunk eq "" && [lindex $quoted end]} {
lappend result $current_chunk
lappend quoted 0
set current_chunk {}
}
}
lappend result {*}[lrange $arglist 1 end]
lappend quoted {*}[lrepeat [llength $arglist] 0]
}
W - w {
append current_chunk $workingdir
}
default {
if {$type eq "expand_sz"} {
#if expand_sz we would have already scanned for environment variables in the form %VAR% and substituted them if found,
#taking precedence over single letter placeholders if there is a conflict.
append current_chunk "$char" [string range $env_var_name 1 end] ;#if env_var_name is empty string then this will just append the single char after the % as a literal,
#which is the correct behaviour for unrecognized placeholders such as %x and for invalid env var references such as %NONEXISTANT%
} else {
append current_chunk $char
}
}
}
set got_placeholder 0
set new_chunk 0
} else {
if {$char eq "%"} {
set got_placeholder 1
} else {
append current_chunk $char
set new_chunk 0
}
}
}
}
}
if {$current_chunk ne "" || $current_chunk eq "" && [lindex $quoted end]} {
#review - edge case - if the command string ends with a space then we would have already added the last chunk to the result list and started a new chunk, so we shouldn't add an empty chunk to the result list in that case.
#however if the last chunk was quoted and is empty because the command string ends with a placeholder that is substituted with an empty string, then we should add the empty chunk to the result list.
lappend result $current_chunk
}
return $result
}
namespace eval punk::auto_exec::system {
proc assoc_get_info {ext} {
set lext [string tolower $ext]
set result [dict create system "" user ""]
set user_assoc_path [join [list HKEY_CURRENT_USER Software Microsoft Windows CurrentVersion Explorer FileExts $lext UserChoice] "\\"]
if {![catch {registry get $user_assoc_path Progid} user_choice]} {
dict set result user $user_choice
}
if {![catch {registry get [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes $lext] "\\"] ""} ftype]} {
dict set result system $ftype
}
return $result
}
}
lappend PUNKARGS [list {
@id -id "::punk::auto_exec::assoc"
@cmd -name "punk::auto_exec::assoc"\
-summary\
"Look up the associated file type (system and user) for a file extension"\
-help\
"Get the associated file type for a file extension by looking up the user-specific
file type in the registry and falling back to the system file type if no
user-specific association is found.
Returns a dict with keys ${$I}system${$NI} and ${$I}user${$NI}.
One or more of the key values may be empty string if there is no defined
file type for the extension.
This is somewhat like the windows 'assoc' command except that the windows command
only looks up the system association and does not take into account any user-specific
overrides.
This function returns both values in the result dictionary if they are available."
@opts
@values -min 0 -max 1
ext -type string -default "" -optional true -help\
"File extension to look up, e.g .txt or .py"
}]
proc assoc {args} {
package require registry
set argd [punk::args::parse $args withid ::punk::auto_exec::assoc]
set ext [dict get $argd values ext]
if {$ext ne ""} {
return [punk::auto_exec::system::assoc_get_info $ext]
} else {
#look up all associated ftypes
set user_ftypes [registry keys {HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts} .*]
set system_ftypes [registry keys {HKEY_LOCAL_MACHINE\SOFTWARE\Classes} .*]
set all_ftypes [lsort -unique [concat $user_ftypes $system_ftypes]]
set results [list]
foreach ftype $all_ftypes {
dict set results $ftype [punk::auto_exec::system::assoc_get_info $ftype]
}
return $results
}
}
namespace eval argdoc {
lappend PUNKARGS [list {
@id -id "::punk::auto_exec::ftype"
@cmd -name "punk::auto_exec::ftype"\
-summary\
"Look up shell verb command values from windows file type."\
-help\
"Get the associated shell verb information (such as open) for a file type by looking up the user-specific
association in the registry and falling back to the system association if no
user-specific association is found.
Returns a dict of dicts with toplevel keys for each shell verb (e.g open, runas) and values that are dicts with keys
${$I}type${$NI} and ${$I}value${$NI} and ${$I}scope${$NI}, where
type is determined from the registry value type (e.g sz or expand_sz)
string is the raw command string from the registry
scope is either "user" or "system" depending on whether the value was found in the user-specific registry keys or
the system registry keys.
This is somewhat like the windows 'ftype' command except that the windows command only looks for the 'open' verb and
only looks up the system association and does not take into account any user-specific
overrides.
The file type can be looked up using the ${$B}assoc${$N} function in this package.
The command string can contain placeholders like \"%1\" for the file name, and environment variables
in the form %VAR% (case insensitive) if the registry value type is reg_expand_sz (expand_sz),
which will be substituted by the ${$B}shell_command_as_tcl_list${$N} function when processing the command
string into a Tcl list of command and arguments with placeholders substituted."
@opts
@values -min 0 -max 1
filetype -type string -default "" -optional true -help\
"File type associated with a file extension, e.g Python.File.
This can be looked up using the 'assoc' function in this package."
}]
}
#proc ftype {filetype} {
# package require registry
# if {$filetype eq "cplfile"} {
# #special case for cplfile (associated with .cpl files) which doesn't follow the usual pattern of having the command string under shell\open\command,
# #but instead has it under HKEY_LOCAL_MACHINE\SOFTWARE\Classes\cplfile\shell\cplopen\command.
# #There doesn't seem to be any user-specific override for this file type
# #- but we will check for one under HKEY_CURRENT_USER\Software\Classes\cplfile\shell\cplopen\command anyway for consistency with the way we check
# #for user-specific overrides for other file types.
# set key [join [list HKEY_CURRENT_USER Software Classes cplfile shell cplopen command] "\\"]
# } else {
# set key [join [list HKEY_CURRENT_USER Software Classes $filetype shell open command] "\\"]
# }
# if {![catch {registry get $key ""} raw_assoc]} {
# set tp [registry type $key ""]
# return [dict create open [dict create type $tp string $raw_assoc]]
# } else {
# #e.g Python.File
# if {$filetype eq "cplfile"} {
# set key [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes cplfile shell cplopen command] "\\"]
# } else {
# set key [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes $filetype shell open command] "\\"]
# }
# if {![catch {registry get $key ""} raw_assoc]} {
# set tp [registry type $key ""]
# return [dict create type $tp string $raw_assoc]
# } else {
# return [dict create type "" string ""] ;#no association found
# }
# }
#}
proc ftype {filetype} {
package require registry
set resultdict [dict create]
#e.g Python.File
set shellpath [join [list HKEY_LOCAL_MACHINE SOFTWARE Classes $filetype shell] "\\"]
if {![catch {registry keys $shellpath *} shellverbs]} {
foreach verb $shellverbs {
set commandkey [join [list $shellpath $verb command] "\\"]
if {![catch {registry get $commandkey ""} cmdstring]} {
#registry queryies are case insensitive but some are cased differently e.g Open vs open.
#when using the verb as a key in the output dict, we need to normalize so that it is useful for lookups. We'll use lowercase for that.
set verb [string tolower $verb]
set tp [registry type $commandkey ""]
dict set resultdict $verb [dict create type $tp value $cmdstring scope system]
}
}
}
#allow user-specific verbs to be overridden.
set shellpath [join [list HKEY_CURRENT_USER Software Classes $filetype shell] "\\"]
if {![catch {registry keys $shellpath *} shellverbs]} {
foreach verb $shellverbs {
set commandkey [join [list $shellpath $verb command] "\\"]
if {![catch {registry get $commandkey ""} cmdstring]} {
set verb [string tolower $verb]
set tp [registry type $commandkey ""]
dict set resultdict $verb [dict create type $tp value $cmdstring scope user]
}
}
}
return $resultdict
}
}
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
# Secondary API namespace
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
tcl::namespace::eval punk::auto_exec::lib {
tcl::namespace::export {[a-z]*} ;# Convention: export all lowercase
tcl::namespace::path [tcl::namespace::parent]
}
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
#tcl::namespace::eval punk::auto_exec::system {
#}
# == === === === === === === === === === === === === === ===
# Sample 'about' function with punk::args documentation
# == === === === === === === === === === === === === === ===
tcl::namespace::eval punk::auto_exec {
tcl::namespace::export {[a-z]*} ;# Convention: export all lowercase
variable PUNKARGS
variable PUNKARGS_aliases
lappend PUNKARGS [list {
@id -id "(package)punk::auto_exec"
@package -name "punk::auto_exec" -help\
"Package
Description"
}]
namespace eval argdoc {
#namespace for custom argument documentation
proc package_name {} {
return punk::auto_exec
}
proc about_topics {} {
#info commands results are returned in an arbitrary order (like array keys)
set topic_funs [info commands [namespace current]::get_topic_*]
set about_topics [list]
foreach f $topic_funs {
set tail [namespace tail $f]
lappend about_topics [string range $tail [string length get_topic_] end]
}
#Adjust this function or 'default_topics' if a different order is required
return [lsort $about_topics]
}
proc default_topics {} {return [list Description *]}
# -------------------------------------------------------------
# get_topic_ functions add more to auto-include in about topics
# -------------------------------------------------------------
proc get_topic_Description {} {
punk::args::lib::tstr [string trim {
package punk::auto_exec
description to come..
} \n]
}
proc get_topic_License {} {
return "<unspecified>"
}
proc get_topic_Version {} {
return "$::punk::auto_exec::version"
}
proc get_topic_Contributors {} {
set authors {{<julian@precisium.com.au> {Julian Noble}}}
set contributors ""
foreach a $authors {
append contributors $a \n
}
if {[string index $contributors end] eq "\n"} {
set contributors [string range $contributors 0 end-1]
}
return $contributors
}
proc get_topic_custom-topic {} {
punk::args::lib::tstr -return string {
A custom
topic
etc
}
}
# -------------------------------------------------------------
}
# we re-use the argument definition from punk::args::standard_about and override some items
set overrides [dict create]
dict set overrides @id -id "::punk::auto_exec::about"
dict set overrides @cmd -name "punk::auto_exec::about"
dict set overrides @cmd -help [string trim [punk::args::lib::tstr {
About punk::auto_exec
}] \n]
dict set overrides topic -choices [list {*}[punk::auto_exec::argdoc::about_topics] *]
dict set overrides topic -choicerestricted 1
dict set overrides topic -default [punk::auto_exec::argdoc::default_topics] ;#if -default is present 'topic' will always appear in parsed 'values' dict
set newdef [punk::args::resolved_def -antiglobs -package_about_namespace -override $overrides ::punk::args::package::standard_about *]
lappend PUNKARGS [list $newdef]
proc about {args} {
package require punk::args
#standard_about accepts additional choices for topic - but we need to normalize any abbreviations to full topic name before passing on
set argd [punk::args::parse $args withid ::punk::auto_exec::about]
lassign [dict values $argd] _leaders opts values _received
punk::args::package::standard_about -package_about_namespace ::punk::auto_exec::argdoc {*}$opts {*}[dict get $values topic]
}
}
# end of sample 'about' function
# == === === === === === === === === === === === === === ===
# -----------------------------------------------------------------------------
# register namespace(s) to have PUNKARGS,PUNKARGS_aliases variables checked
# -----------------------------------------------------------------------------
# variable PUNKARGS
# variable PUNKARGS_aliases
namespace eval ::punk::args::register {
#use fully qualified so 8.6 doesn't find existing var in global namespace
lappend ::punk::args::register::NAMESPACES ::punk::auto_exec
}
# -----------------------------------------------------------------------------
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
## Ready
package provide punk::auto_exec [tcl::namespace::eval punk::auto_exec {
variable pkg punk::auto_exec
variable version
set version 0.1.0
}]
return

353
src/vfs/_vfscommon.vfs/modules/punk/char-0.1.0.tm

@ -237,6 +237,88 @@ tcl::namespace::eval punk::char {
return $out
}
namespace eval argdoc {
variable PUNKARGS
lappend PUNKARGS [list {
@id -id ::punk::char::ascii2doublewide
@cmd -name punk::char::ascii2doublewide\
-summary\
"string to double-wide unicode char"\
-help\
"Convert parts of string in the ascii range 21 to 7E to corresponding chars from the unicode
'Halfwidth and Fullwidth Forms' block.
The space character (0x20) is converted to the 'IDEOGRAPHIC SPACE' character (0x3000)
Control chars and chars outside the ascii range are not converted and passed through as-is."
@values -min 1 -max 1
str -type string
}]
}
proc ascii2doublewide {str} {
#set base to \UFEE0 and add ascii value of char to get the double-wide character in the 'Halfwidth and Fullwidth Forms' block of unicode
set out ""
foreach ch [split $str ""] {
set decval [scan $ch %c]
if {$decval == 0x20} {
#space char maps to ideographic space rather than the fullwidth space char - as the fullwidth space char is often rendered as a narrow space rather than a wide one - and the ideographic space is more likely to be rendered as a wide space.
set decval 0x3000
} elseif {$decval < 0x21 || $decval > 0x7E} {
#chars outside the ascii range are not converted - pass through as-is
set decval [scan $ch %c]
} else {
set decval [expr {0xFEE0 + $decval}]
}
append out [format %c $decval]
}
return $out
}
proc doublewide2ascii {str} {
set out ""
foreach ch [split $str ""] {
set decval [scan $ch %c]
if {$decval == 0x3000} {
#ideographic space maps to ascii space char
set decval 0x20
append out \U3000
} elseif {$decval < 0xFF01 || $decval > 0xFF5E} {
#chars outside the part of the 'Halfwidth and Fullwidth Forms' block that corresponds to ascii are not converted - pass through as-is
append out $ch
} else {
set decval [expr {$decval - 0xFEE0}]
append out [format %c $decval]
}
}
return $out
}
proc ascii2NTFSPUA {str} {
#set base to \uF000 and add ascii value of char to get the PUA character in the 'Private Use Area' block of unicode
set out ""
foreach ch [split $str ""] {
set decval [scan $ch %c]
if {$decval <= 0x7F} {
append out [format %c [expr {0xF000 + $decval}]]
} else {
#chars outside the ascii range are not converted - pass through as-is
append out $ch
}
}
return $out
}
proc NTFSPUA2ascii {str} {
set out ""
foreach ch [split $str ""] {
set decval [scan $ch %c]
if {$decval >= 0xF000 && $decval <= 0xF0FF} {
set decval [expr {$decval - 0xF000}]
append out [format %c $decval]
} else {
#chars outside the 'Private Use Area' block that corresponds to ascii are not converted - pass through as-is
append out $ch
}
}
return $out
}
proc symbol {} {
tailcall page symbol
@ -2573,11 +2655,269 @@ tcl::namespace::eval punk::char {
return [tcl::string::map $map $str]
}
#todo - lookup from unicode tables
#ISO-3166-1 alpha-2 country codes to flag emojis
#These can change over time.
#todo - lookup from official sources - and update as needed
#
variable flags [dict create\
AC \U1F1E6\U1F1E8\
AD \U1F1E6\U1F1E9\
AE \U1F1E6\U1F1EA\
AF \U1F1E6\U1F1EB\
AG \U1F1E6\U1F1EC\
AI \U1F1E6\U1F1EE\
AL \U1F1E6\U1F1F1\
AM \U1F1E6\U1F1F2\
AO \U1F1E6\U1F1F4\
AQ \U1F1E6\U1F1F6\
AR \U1F1E6\U1F1F7\
AS \U1F1E6\U1F1F8\
AT \U1F1E6\U1F1F9\
AU \U1F1E6\U1F1FA\
AW \U1F1E6\U1F1FC\
AX \U1F1E6\U1F1FD\
AZ \U1F1E6\U1F1FF\
BA \U1F1E7\U1F1E6\
BB \U1F1E7\U1F1E7\
BD \U1F1E7\U1F1E9\
BE \U1F1E7\U1F1EA\
BF \U1F1E7\U1F1EB\
BG \U1F1E7\U1F1EC\
BH \U1F1E7\U1F1ED\
BI \U1F1E7\U1F1EE\
BJ \U1F1E7\U1F1EF\
BL \U1F1E7\U1F1F1\
BM \U1F1E7\U1F1F2\
BN \U1F1E7\U1F1F3\
BO \U1F1E7\U1F1F4\
BQ \U1F1E7\U1F1F6\
BR \U1F1E7\U1F1F7\
BS \U1F1E7\U1F1F8\
BT \U1F1E7\U1F1F9\
BV \U1F1E7\U1F1FB\
BW \U1F1E7\U1F1FC\
BY \U1F1E7\U1F1FE\
BZ \U1F1E7\U1F1FF\
CA \U1F1E8\U1F1E6\
CC \U1F1E8\U1F1E8\
CD \U1F1E8\U1F1E9\
CF \U1F1E8\U1F1Eb\
CG \U1F1E8\U1F1Ec\
CH \U1F1E8\U1F1ED\
CI \U1F1E8\U1F1EE\
CK \U1F1E8\U1F1F0\
CL \U1F1E8\U1F1F1\
CM \U1F1E8\U1F1F2\
CN \U1F1E8\U1F1F3\
CO \U1F1E8\U1F1F4\
CP \U1F1E8\U1F1F5\
CR \U1F1E8\U1F1F7\
CS \U1F1E8\U1F1F8\
CU \U1F1E8\U1F1FA\
CV \U1F1E8\U1F1FB\
CW \U1F1E8\U1F1FC\
CX \U1F1E8\U1F1FE\
CY \U1F1E8\U1F1FE\
CZ \U1F1E8\U1F1FF\
DE \U1F1E9\U1F1EA\
DG \U1F1E9\U1F1EC\
DJ \U1F1E9\U1F1EF\
DK \U1F1E9\U1F1F0\
DM \U1F1E9\U1F1F2\
DO \U1F1E9\U1F1F4\
DZ \U1F1E9\U1F1FF\
EA \U1F1EA\U1F1E6\
EC \U1F1EA\U1F1E8\
EE \U1F1EA\U1F1EA\
EG \U1F1EA\U1F1EC\
EH \U1F1EA\U1F1ED\
ER \U1F1EA\U1F1F7\
ES \U1F1EA\U1F1F8\
ET \U1F1EA\U1F1F9\
EU \U1F1EA\U1F1FA\
FI \U1F1EB\U1F1EE\
FJ \U1F1EB\U1F1EF\
FK \U1F1EB\U1F1F0\
FM \U1F1EB\U1F1F2\
FO \U1F1EB\U1F1F4\
FR \U1F1EB\U1F1F7\
GA \U1F1EC\U1F1E6\
GB \U1F1EC\U1F1E7\
GD \U1F1EC\U1F1E9\
GE \U1F1EC\U1F1EA\
GF \U1F1EC\U1F1EB\
GG \U1F1EC\U1F1EC\
GH \U1F1EC\U1F1ED\
GI \U1F1EC\U1F1EE\
GL \U1F1EC\U1F1F1\
GM \U1F1EC\U1F1F2\
GN \U1F1EC\U1F1F3\
GP \U1F1EC\U1F1F5\
GQ \U1F1EC\U1F1F6\
GR \U1F1EC\U1F1F7\
GS \U1F1EC\U1F1F8\
GT \U1F1EC\U1F1F9\
GU \U1F1EC\U1F1FA\
GW \U1F1EC\U1F1FC\
GY \U1F1EC\U1F1FE\
HK \U1F1ED\U1F1F0\
HM \U1F1ED\U1F1F2\
HN \U1F1ED\U1F1F3\
HR \U1F1ED\U1F1F7\
HT \U1F1ED\U1F1F9\
HU \U1F1ED\U1F1FA\
IC \U1F1EE\U1F1E8\
ID \U1F1EE\U1F1E9\
IE \U1F1EE\U1F1EA\
IL \U1F1EE\U1F1F1\
IM \U1F1EE\U1F1F2\
IN \U1F1EE\U1F1F3\
IO \U1F1EE\U1F1F4\
IQ \U1F1EE\U1F1F6\
IR \U1F1EE\U1F1F7\
IS \U1F1EE\U1F1F8\
IT \U1F1EE\U1F1F9\
JE \U1F1EF\U1F1EA\
JM \U1F1EF\U1F1F2\
JO \U1F1EF\U1F1F4\
JP \U1F1EF\U1F1F5\
KE \U1F1F0\U1F1EA\
KG \U1F1F0\U1F1EC\
KH \U1F1F0\U1F1ED\
KI \U1F1F0\U1F1EE\
KM \U1F1F0\U1F1F2\
KN \U1F1F0\U1F1F3\
KP \U1F1F0\U1F1F5\
KR \U1F1F0\U1F1F7\
KW \U1F1F0\U1F1FC\
KY \U1F1F0\U1F1FE\
KZ \U1F1F0\U1F1FF\
LA \U1F1F1\U1F1E6\
LB \U1F1F1\U1F1E7\
LC \U1F1F1\U1F1E8\
LI \U1F1F1\U1F1EE\
LK \U1F1F1\U1F1F0\
LR \U1F1F1\U1F1F7\
LS \U1F1F1\U1F1F8\
LT \U1F1F1\U1F1F9\
LU \U1F1F1\U1F1FA\
LV \U1F1F1\U1F1FB\
LY \U1F1F1\U1F1FE\
MA \U1F1F2\U1F1E6\
MC \U1F1F2\U1F1E8\
MD \U1F1F2\U1F1E9\
ME \U1F1F2\U1F1EA\
MF \U1F1F2\U1F1EB\
MG \U1F1F2\U1F1EC\
MH \U1F1F2\U1F1ED\
MK \U1F1F2\U1F1F0\
ML \U1F1F2\U1F1F1\
MM \U1F1F2\U1F1F2\
MN \U1F1F2\U1F1F3\
MO \U1F1F2\U1F1F4\
MP \U1F1F2\U1F1F5\
MQ \U1F1F2\U1F1F6\
MR \U1F1F2\U1F1F7\
MS \U1F1F2\U1F1F8\
MT \U1F1F2\U1F1F9\
MU \U1F1F2\U1F1FA\
MV \U1F1F2\U1F1FB\
MW \U1F1F2\U1F1FC\
MX \U1F1F2\U1F1FD\
MY \U1F1F2\U1F1FE\
MZ \U1F1F2\U1F1FF\
NA \U1F1F3\U1F1E6\
NC \U1F1F3\U1F1E8\
NE \U1F1F3\U1F1EA\
NF \U1F1F3\U1F1EB\
NG \U1F1F3\U1F1EC\
NI \U1F1F3\U1F1EE\
NL \U1F1F3\U1F1F1\
NO \U1F1F3\U1F1F4\
NP \U1F1F3\U1F1F5\
NR \U1F1F3\U1F1F7\
NU \U1F1F3\U1F1F8\
NZ \U1F1F3\U1F1ff\
OM \U1F1F4\U1F1F2\
PA \U1F1F5\U1F1E6\
PE \U1F1F5\U1F1EA\
PF \U1F1F5\U1F1EB\
PG \U1F1F5\U1F1EC\
PH \U1F1F5\U1F1ED\
PK \U1F1F5\U1F1ED\
PL \U1F1F5\U1F1F1\
PM \U1F1F5\U1F1F3\
PN \U1F1F5\U1F1F3\
PR \U1F1F5\U1F1F7\
PS \U1F1F5\U1F1F8\
PT \U1F1F5\U1F1F9\
PW \U1F1F5\U1F1FC\
PY \U1F1F5\U1F1FE\
QA \U1F1F6\U1F1E6\
RE \U1F1F7\U1F1EA\
RO \U1F1F7\U1F1F4\
RS \U1F1F7\U1F1F7\
RU \U1F1F7\U1F1FA\
RW \U1F1F7\U1F1FC\
SA \U1F1F8\U1F1E6\
SB \U1F1F8\U1F1E7\
SC \U1F1F8\U1F1E8\
SD \U1F1F8\U1F1E9\
SE \U1F1F8\U1F1EA\
SG \U1F1F8\U1F1EC\
SH \U1F1F8\U1F1ED\
SI \U1F1F8\U1F1EE\
SJ \U1F1F8\U1F1EF\
SK \U1F1F8\U1F1F0\
SL \U1F1F8\U1F1F1\
SM \U1F1F8\U1F1F2\
SN \U1F1F8\U1F1F3\
SO \U1F1F8\U1F1F4\
SR \U1F1F8\U1F1F7\
SS \U1F1F8\U1F1F8\
ST \U1F1F8\U1F1F9\
SV \U1F1F8\U1F1F7\
SX \U1F1F8\U1F1F8\
SY \U1F1F8\U1F1FE\
SZ \U1F1F8\U1F1FF\
TA \U1F1F9\U1F1E6\
TC \U1F1F9\U1F1E8\
TD \U1F1F9\U1F1E9\
TF \U1F1F9\U1F1EB\
TG \U1F1F9\U1F1EC\
TH \U1F1F9\U1F1ED\
TJ \U1F1F9\U1F1EF\
TK \U1F1F9\U1F1F0\
TL \U1F1F9\U1F1F1\
TM \U1F1F9\U1F1F2\
TN \U1F1F9\U1F1F3\
TO \U1F1F9\U1F1F4\
TR \U1F1F9\U1F1F7\
TT \U1F1F9\U1F1F9\
TV \U1F1F9\U1F1FB\
TW \U1F1F9\U1F1FC\
TZ \U1F1F9\U1F1FF\
UA \U1F1FA\U1F1E6\
UG \U1F1FA\U1F1EC\
UM \U1F1FA\U1F1F2\
US \U1F1FA\U1F1F8\
ZW \U1F1FF\U1F1FC
UY \U1F1FA\U1F1FE\
UZ \U1F1FA\U1F1FF\
VA \U1F1FB\U1F1E6\
VC \U1F1FB\U1F1E8\
VE \U1F1FB\U1F1EA\
VG \U1F1FB\U1F1EC\
VI \U1F1FB\U1F1EE\
VN \U1F1FB\U1F1F3\
VU \U1F1FB\U1F1FA\
WF \U1F1FC\U1F1EB\
WS \U1F1FC\U1F1F8\
XK \U1F1FD\U1F1F3\
YE \U1F1FE\U1F1EA\
YT \U1F1FE\U1F1F9\
ZA \U1F1FF\U1F1E6\
ZM \U1F1FF\U1F1F2\
ZW \U1F1FF\U1F1FC\
]
variable rflags
dict for {k v} $flags {
@ -2825,6 +3165,15 @@ tcl::namespace::eval punk::char::lib {
}
}
# -----------------------------------------------------------------------------
# register namespace(s) to have PUNKARGS,PUNKARGS_aliases variables checked
# -----------------------------------------------------------------------------
# variable PUNKARGS
# variable PUNKARGS_aliases
namespace eval ::punk::args::register {
#use fully qualified so 8.6 doesn't find existing var in global namespace
lappend ::punk::args::register::NAMESPACES ::punk::char
}
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++

909
src/vfs/_vfscommon.vfs/modules/punk/du-0.1.0.tm

File diff suppressed because it is too large Load Diff

7
src/vfs/_vfscommon.vfs/modules/punk/mix/commandset/module-0.1.0.tm

@ -158,8 +158,11 @@ namespace eval punk::mix::commandset::module {
If a version is specified in the module argument as well as in -version
the higher version number will be used.
"
-license -default <unspecified>
-author -default <unspecified> -multiple 1
-license -default <unspecified> -help\
"License to be mentioned in the module file. e.g BSD, MIT"
-author -default <unspecified> -multiple 1 -help\
{Author(s) of the module. Multiple authors can be specified by repeating the -author option.
e.g -author {"John Smith" "jsmith@example.com"} -author {"Jane Doe" "jane@example.org"}}
-template -default punk.module
-type -default "[lindex $moduletypes 0]" -choices {$moduletypes}
-force -default 0 -type boolean -help\

399
src/vfs/_vfscommon.vfs/modules/punk/nav/fs-0.1.0.tm

@ -233,6 +233,11 @@ tcl::namespace::eval punk::nav::fs {
set args [lrange $args 1 end]
}
if {$v eq "/"} {
#directory only listing - we can optimize this by not getting file sizes and times - as these are only used for file listings
#we can't completely skip iterating over files as we want to know if there are any files in the directory - as this affects the display of links/shortcuts that point to directories - but we can skip getting sizes and times for files.
#todo?
}
if {![llength $args]} {
#ls is too slow even over a fairly low-latency network
@ -243,12 +248,12 @@ tcl::namespace::eval punk::nav::fs {
commandstack::basecall cd $VIRTUAL_CWD
}
}
set matchinfo [dirfiles_dict -searchbase $VIRTUAL_CWD -with_times {f d l} -with_sizes {f d l}]
set matchinfo [dirfiles_dict -searchbase $VIRTUAL_CWD -with_times {f d l b c p s} -with_sizes {f d l b c p s}]
} else {
if {[pwd] ne $VIRTUAL_CWD} {
commandstack::basecall cd $VIRTUAL_CWD
}
set matchinfo [dirfiles_dict -searchbase [pwd] -with_times {f d l} -with_sizes {f d l}]
set matchinfo [dirfiles_dict -searchbase [pwd] -with_times {f d l b c p s} -with_sizes {f d l b c p s}]
}
set dircount [llength [dict get $matchinfo dirs]]
set filecount [llength [dict get $matchinfo files]]
@ -621,30 +626,41 @@ tcl::namespace::eval punk::nav::fs {
punk::args::define {
@id -id ::punk::nav::fs::d/new
-nonportable -type none -help\
"Allow creation of directories which may not be portable across platforms.
Use with caution and only when you know what you are doing.
This allows creation of directories with names that may be invalid on some
platforms, or that may have special meanings on some platforms
(e.g reserved device names on windows).
If -nonportable is not supplied, then an error will be raised if any supplied
path is non-portable as defined by punk::winpath::illegalname_test.
Regardless of whether -nonportable is supplied or not, some characters are not
suitable for windows or most other platforms and will be rejected with an error.
An example of this is the null character (\0)."
@cmd -name punk::nav::fs::d/new\
-summary\
"Create directory or directories at the specified path(s)."\
-help\
"This command creates directories at the specified path(s).
If any part of the specified path does not exist, then it will be created as well.
If a specified path already exists, then it will be left as-is and no error will be raised.
A summary line is returned for each created directory, with the full path of the created
directory and a status line indicating the number of dirs and files in the directory if
it already existed (or showing 0 for both if it was just created)."
-nonportable -type none\
-help\
"Allows creation of directories which may not be portable across platforms.
Use with caution and only when you know what you are doing.
This allows creation of directories with names that may be invalid on some
platforms, or that may have special meanings on some platforms
(e.g reserved device names on windows).
If -nonportable is not supplied, then an error will be raised if any supplied
path is non-portable as defined by punk::winpath::illegalname_test.
Regardless of whether -nonportable is supplied or not, some characters are not
suitable for windows or most other platforms and will be rejected with an error.
An example of this is the null character (\0)."
@values -min 1 -max -1 -type string
path -type string -multiple 1 -help\
"Path(s) to create. Can be absolute or relative.
If any path is rejected due to -nonportable or other invalid characters,
or because a parent directory is not writable, then no directories will be created.
If any path is rejected due to -nonportable or other invalid characters,
or because a parent directory is not writable, then no directories will be created.
If a path already exists, then it will be left as-is and no error will be raised.
If a path already exists, then it will be left as-is and no error will be raised.
If despite passing the name tests or writability tests, a directory cannot be
created for some reason (e.g other filesystem error) then an error will be raised
and processing of any remaining paths will be aborted."
If despite passing the name tests or writability tests, a directory cannot be
created for some reason (e.g other filesystem error) then an error will be raised
and processing of any remaining paths will be aborted."
}
#todo - synchronize overall behaviour of d/new with that of n/new (for namespaces)
proc d/new {args} {
@ -718,7 +734,7 @@ tcl::namespace::eval punk::nav::fs {
}
#display summaries of created directories (which may have already existed) by reusing d/ to get info on them.
set query_paths [lmap v $paths $v/*]
set query_paths [lmap v $paths {string cat $v "/*"}]
d/ / {*}$query_paths
}
@ -734,6 +750,10 @@ tcl::namespace::eval punk::nav::fs {
#run a file
#review. On unix the shebang should be able to choose the interpreter.
#on windows the ::env(PATHEXT) and file associations should be able to choose the interpreter. (see punk::auto_execok_better)
#when running using ./script_or_exe.name - the windows file association should be used.
#when running using x/ script_or_exe.name - we want to be able to run scripts even if no file association exists (or it is inappropriate for execution e.g ps1 defaults to open in editor)
proc x/ {args} {
if {![llength $args]} {
set result [d/]
@ -748,11 +768,16 @@ tcl::namespace::eval punk::nav::fs {
lua [list exe lua extensions [list ".lua"]]\
perl [list exe perl extensions [list ".pl"]]\
php [list exe php extensions [list ".php"]]\
ps1 [list exe pwsh extensions [list ".ps1"]]\
]
#todo - allow cofnig to specify arguments for the executable. eg pwsh -noprofile -executionpolicy remotesigned for running powershell scripts.
#this is a bit of a hack - fix
set tcl_extensions [list ".tcl" ".tm" ".kit" ".tk"] ;#todo - load from config
set py_extensions [list ".py"]
set lua_extensions [list ".lua"]
set perl_extensions [list ".pl"]
#set lua_extensions [list ".lua"]
#set perl_extensions [list ".pl"]
#set pwsh_extensions [list ".ps1"]
set script_extensions [list]
set extension_lookup [dict create]
@ -792,14 +817,12 @@ tcl::namespace::eval punk::nav::fs {
set ::argc [llength $newargs]
set ::argv $newargs
tailcall source $path
} elseif {$extlower in $py_extensions} {
set pycmd [auto_execok python]
tailcall {*}$pycmd {*}$args
} elseif {$extlower in $script_extensions} {
set exename [dict get $scriptconfig $scriptlang exe]
set cmd [auto_execok $exename]
tailcall {*}$cmd $args
} else {
#review
set fd [open $path r]
set chunk [read $fd 4000]; close $fd
#consider any commented line near top of file containing 'tcl' as likely to be a tcl script of some sort and attempt to source it.
@ -824,7 +847,11 @@ tcl::namespace::eval punk::nav::fs {
}
}
} else {
puts stderr "No script executable known for this"
if {$scriptfile eq ""} {
puts stderr "script not found"
} else {
puts stderr "No script executable known for files with extension [file extension $scriptfile]. Ensure file has a known extension ($script_extensions) or add a commented hint in the file such as #!/usr/bin/env tclsh"
}
}
}
@ -934,13 +961,96 @@ tcl::namespace::eval punk::nav::fs {
# -searchbase is always passed through - and is only used to construct a location path if a relative searchspec was supplied
punk::args::define {
@id -id ::punk::nav::fs::dirfiles_dict
@cmd -name punk::nav::fs::dirfiles_dict
@cmd -name punk::nav::fs::dirfiles_dict\
-summary\
""\
-help\
"This command performs a directory listing of the specified location and returns the results as a dictionary
containing lists of keys such as files and directories and their properties.
The results are returned as a dictionary with the following structure:
{
location <searched location>
searchbase <searchbase supplied>
dirs {<list of directories matching search>}
vfsmounts {<list of entries that are mount points for virtual filesystems>}
links {<list of links matching search>}
linkinfo {<list of link targets for links matching search, in same order as links list, or 'na'>}
files {<list of files matching search>}
filesizes {<list of file sizes for files matching search, in same order as files list, or 'na'>}
sizes {<dictionary keyed on entry name with sub-dictionary of size information keyed with bytes>}
times {<dictionary keyed on entry name with sub-dictionary of time properties keyed with c a m>}
flaggedhidden {<dictionary keyed on entry name with value of 1 if entry is hidden or 0, for entries matching search, or 'na'>}
flaggedsystem {<dictionary keyed on entry name with value of 1 if entry is a system file or 0, for entries matching search, or 'na'>}
flaggedreadonly {<dictionary keyed on entry name with value of 1 if entry is read-only or 0, for entries matching search, or 'na'>}
altnames {<dictionary keyed on entry name with value of list of alternate names for that entry, or 'na'>}
opts {
-glob <glob pattern used for search>
-filedebug <filedebug supplied>
-patterndebug <patterndebug supplied>
-types <types supplied>
-with_sizes <with_sizes supplied>
-with_times <with_times supplied>
}
debuginfo {
<any debug info that may be useful to caller>
}
errors {
<any errors encountered during search, e.g inaccessible folders etc>
}
nonportable {<dictionary keyed on entry name with value of 1 if entry is non-portable or 0>}
underlayfiles {<list of files that are underlay files for virtual filesystem mounts matching search>}
underlayfilesizes {<list of sizes for underlay files for virtual filesystem mounts matching search, in same order as underlayfiles list, or 'na'>}
timinginfo {
<any timing info that may be useful to caller>
}
}
"
@opts -any 0
-searchbase -default ""
-tailglob -default "\uFFFF"
-searchbase -default ""
-tailglob -default "\uFFFF"
-filedebug -default 0 -type boolean
-patterndebug -default 0 -type boolean
#with_sizes & with_times must accept 0|1|f|d|l where f = files d = dirs l = links (punk::du)
-with_sizes -default "\uFFFF" -type string
-with_times -default "\uFFFF" -type string
-with_sizes -default "\uFFFF" -type list
-with_times -default "\uFFFF" -type list
-link_info -default 1 -type boolean -help "When links are included in results, also include resparseinfo if present."
-portabilitycheck -default 1 -type boolean -help\
"Perform portability checks on entry names and flag non-portable entries in results.
Non-portable entries are those that may not be portable across platforms, for example due
to containing characters that are illegal on some platforms, or reserved device names on windows.
When this option is enabled, entries that are determined to be non-portable will be listed under
the 'nonportable' key."
-types -default {} -type list\
-help "Restrict results to specified types.
This uses the same basic types and mechanism as the Tcl 'glob' command.
entry:
{f}iles {d}irs {l}inks {s}ockets {p}ipes {b}lock devices {c}haracter special devices
attributes:
hidden readonly
permissions:
r w x
Note that on windows, Tcl's glob command uses very basic heuristics to determine the permissions - it doesn't
actually check the ACLs.
For example - the test for executable permission is just whether the file has a known executable extension
and the test for readonly is just whether the file has the read-only attribute set.
On unix, the permissions are likely to be determined by checking the actual permissions of the file against
the current user's uid and groups.
When entry types are given, results matching any of those types will be returned.
When attributes or permissions are given, only results matching all of the specified attributes or permissions
will be returned.
The default (empty list) is to return all types and ignore attributes and permissions.
If just attributes or permissions are given without entry types, then the types will be filtered according to
the specified attributes or permissions but not according to the directory entry type
- so for example if just 'hidden' attribute is given, then both files and folders with the hidden attribute
will be returned.
(todo - macintosh specific type handling)
"
@values -min 0 -max -1 -type string -unnamed true
}
proc dirfiles_dict {args} {
@ -955,23 +1065,29 @@ tcl::namespace::eval punk::nav::fs {
#review - spaced paths ?
error "dirfiles_dict: multiple listing not *yet* supported"
}
set searchspec [lindex $searchspecs 0]
# -- --- --- --- --- --- ---
set opt_searchbase [dict get $opts -searchbase]
set opt_tailglob [dict get $opts -tailglob]
set opt_with_sizes [dict get $opts -with_sizes]
set opt_with_times [dict get $opts -with_times]
set opt_searchbase [dict get $opts -searchbase]
set opt_tailglob [dict get $opts -tailglob]
set opt_with_sizes [dict get $opts -with_sizes]
set opt_with_times [dict get $opts -with_times]
set opt_link_info [dict get $opts -link_info]
set opt_types [dict get $opts -types]
set opt_filedebug [dict get $opts -filedebug]
set opt_patterndebug [dict get $opts -patterndebug]
set opt_portabilitycheck [dict get $opts -portabilitycheck]
# -- --- --- --- --- --- ---
set searchspec [lindex $searchspecs 0]
#we don't want to normalize..
#for example if the user supplies ../ we want to see ../result
set is_relativesearchspec [expr {[file pathtype $searchspec] eq "relative"}]
if {$opt_searchbase eq ""} {
set searchbase .
} else {
#if {$opt_searchbase eq ""} {
# set searchbase .
#} else {
set searchbase $opt_searchbase
}
#}
switch -- $opt_tailglob {
@ -979,7 +1095,7 @@ tcl::namespace::eval punk::nav::fs {
if {$searchspec eq ""} {
set location
} else {
if {$is_relativesarchspec} {
if {$is_relativesearchspec} {
#set location [file dirname [file join $opt_searchbase $searchspec]]
set location [punk::path::normjoin $searchbase $searchspec ..]
} else {
@ -1063,7 +1179,7 @@ tcl::namespace::eval punk::nav::fs {
set ts1 [clock clicks -milliseconds]
if {$is_in_vfs} {
set listing [punk::du::lib::du_dirlisting_tclvfs $location -glob $match_contents {*}$next_opt_with_sizes {*}$next_opt_with_times]
set listing [punk::du::lib::du_dirlisting_tclvfs $location -glob $match_contents -filedebug $opt_filedebug -patterndebug $opt_patterndebug {*}$next_opt_with_sizes {*}$next_opt_with_times -types $opt_types]
} else {
set invfs ""
switch -glob -- $location {
@ -1094,20 +1210,25 @@ tcl::namespace::eval punk::nav::fs {
switch -- $invfs {
zipfs {
#relative vs absolute? review - cwd valid for //zipfs:/ ??
set listing [punk::du::lib::du_dirlisting_zipfs $location -glob $match_contents {*}$next_opt_with_sizes {*}$next_opt_with_times]
set listing [punk::du::lib::du_dirlisting_zipfs $location -glob $match_contents -filedebug $opt_filedebug -patterndebug $opt_patterndebug {*}$next_opt_with_sizes {*}$next_opt_with_times -types $opt_types]
}
cookit {
#seems to be a vfs - except it mounts on a pseudo-volume path //cookit:/
#don't use twapi
#could possibly use du_dirlisting_tclvfs REVIEW
#files and folders are all returned with the -types hidden option for glob on windows
set listing [punk::du::lib::du_dirlisting_generic $location -glob $match_contents {*}$next_opt_with_sizes {*}$next_opt_with_times]
set listing [punk::du::lib::du_dirlisting_generic $location -glob $match_contents -filedebug $opt_filedebug -patterndebug $opt_patterndebug {*}$next_opt_with_sizes {*}$next_opt_with_times -types $opt_types]
}
pseudovol {
set listing [punk::du::lib::du_dirlisting_generic $location -glob $match_contents {*}$next_opt_with_sizes {*}$next_opt_with_times]
set listing [punk::du::lib::du_dirlisting_generic $location -glob $match_contents -filedebug $opt_filedebug -patterndube $opt_patterndebug {*}$next_opt_with_sizes {*}$next_opt_with_times -types $opt_types]
}
default {
set listing [punk::du::dirlisting $location -glob $match_contents {*}$next_opt_with_sizes {*}$next_opt_with_times]
#The default call to punk::du::dirlisting will use the most appropriate mechanism for the platform and path
#- e.g twapi for windows local paths (if twapi is available), tcl glob for unix etc
#- and will be able to handle vfs paths that are visible to the filesystem as well (e.g cookit)
#- but may not be able to handle some vfs paths that aren't visible as normal files/folders to the filesystem
#(e.g if the vfs doesn't report itself as a vfs in vfs::filesystem info)
set listing [punk::du::dirlisting $location -glob $match_contents -filedebug $opt_filedebug -patterndebug $opt_patterndebug {*}$next_opt_with_sizes {*}$next_opt_with_times -link_info $opt_link_info -types $opt_types]
}
}
}
@ -1157,6 +1278,10 @@ tcl::namespace::eval punk::nav::fs {
#we add dotfiles to flaggedhidden list in case there is some other mechanism that has flagged items as hidden
if {$::tcl_platform(platform) ne "windows"} {
#lappend flaggedhidden {*}[lsearch -all -inline [list {*}$dirs {*}$files] ".*"]
#todo - fix! we want to match any file or folder with a leading dot
#- but we also want to preserve the full path in the flaggedhidden list - so we need to check the tail of each entry for leading dot, rather than just doing a glob match on the whole path.
lappend flaggedhidden {*}[tcl::prefix::all [list {*}$dirs {*}$files] .]
#e.g we can have dupes in the case where there are vfs mounted files that appear as dirs
#as we will need to do a (nat)sort as a last step - it will be faster to not sort items prematurely
@ -1205,9 +1330,11 @@ tcl::namespace::eval punk::nav::fs {
#jmn
set ts1 [clock milliseconds]
foreach nm [list {*}$dirs {*}$files] {
if {[punk::winpath::illegalname_test $nm]} {
lappend nonportable $nm
if {$opt_portabilitycheck} {
foreach nm [list {*}$dirs {*}$files] {
if {[punk::winpath::illegalname_test $nm]} {
lappend nonportable $nm
}
}
}
set ts2 [clock milliseconds]
@ -1221,6 +1348,165 @@ tcl::namespace::eval punk::nav::fs {
return [dict merge $listing $updated]
}
#replacement for tcl's glob.
#we can document with punk::args - but we don't want the overhead of full option parsing for every glob call - so we'll just do the option parsing manually within the proc.
#todo - add alias for punk::args id ::punk::nav::fs::fglob to ::glob so documentation matches.
proc fglob_parse_test1 {args} {
#tcl's glob requires a -- when arguments in positions that could be an option begin with a dash.
#This means accounting for prefixes of valid options and determining if they accept a parameter (which is allowed to begin with a dash) or not.
#Any argument that is in a possible option position and begins with a dash but isn't a valid option (or the -- itself)
#should raise an error rather than being treated as a literal glob pattern.
set valid_options [list -directory -join -nocomplain -path -tails -types]
set solo_options [list -nocomplain -join -tails]
set eopts_reached 0
set options [dict create] ;#for solos we will set the value to 1, for options that take parameters we will set the value to the parameter
set patterns [list]
for {set i 0} {$i < [llength $args]} {incr i} {
set a [lindex $args $i]
if {[string match -* $a]} {
if {$a eq "--"} {
set eopts_reached 1
set patterns [lrange $args [expr {$i + 1}] end]
break
}
#before -- we need to check if arg is a valid option or not
set full_option_name [tcl::prefix::match -error "" $valid_options $a]
if {$full_option_name ne ""} {
if {$full_option_name ni $solo_options} {
#option takes a parameter - so next arg is parameter even if it looks like an option
incr i
if {$i < [llength $args]} {
set param [lindex $args $i]
dict set options $full_option_name $param
} else {
error "fglob: option \"$a\" requires a parameter but none found"
}
} else {
dict set options $full_option_name 1
}
} else {
error "fglob: bad option \"$a\": must be [join $valid_options ", "] or --"
}
} else {
set patterns [lrange $args $i end]
break
}
}
return [dict create options $options patterns $patterns]
}
proc fglob_parse_test {args} {
#tcl's glob requires a -- when arguments in positions that could be an option begin with a dash.
#This means accounting for prefixes of valid options and determining if they accept a parameter (which is allowed to begin with a dash) or not.
#Any argument that is in a possible option position and begins with a dash but isn't a valid option (or the -- itself)
#should raise an error rather than being treated as a literal glob pattern.
#set valid_options [list -directory -join -nocomplain -path -tails -types]
#set solo_options [list -nocomplain -join -tails]
set options [dict create] ;#for solos we will set the value to 1, for options that take parameters we will set the value to the parameter
set patterns [list]
set arglen [llength $args]
for {set i 0; set j 1; set a [lindex $args 0]} {$i < $arglen} {incr j; set a [lindex $args [incr i]]} {
if {![string match -* $a]} {
#encountered non-option-like argument. All subsequent arguments should be treated as patterns even if they look like options.
return [dict create options $options patterns [lrange $args $i end]]
}
if {$a eq "--"} {
return [dict create options $options patterns [lrange $args $j end]]
}
#flag-like argument before -- we need to check if arg is a valid option or not
set full_option_name [tcl::prefix::match {-directory -join -nocomplain -path -tails -types} $a]
switch -- $full_option_name {
-directory - -path - -types {
#option takes a parameter - so next arg is parameter even if it looks like an option
if {$j < $arglen} {
dict set options $full_option_name [lindex $args $j]
incr i
incr j
} else {
error "fglob: option \"$a\" requires a parameter but none found"
}
}
default {
dict set options $full_option_name 1
}
}
}
return [dict create options $options patterns $patterns]
}
proc fglob {args} {
#tcl's glob requires a -- when arguments in positions that could be an option begin with a dash.
#This means accounting for prefixes of valid options and determining if they accept a parameter (which is allowed to begin with a dash) or not.
#Any argument that is in a possible option position and begins with a dash but isn't a valid option (or the -- itself)
#should raise an error rather than being treated as a literal glob pattern.
#set valid_options [list -directory -join -nocomplain -path -tails -types]
#set solo_options [list -nocomplain -join -tails]
set options [dict create] ;#for solos we will set the value to 1, for options that take parameters we will set the value to the parameter
set patterns [list]
set arglen [llength $args]
for {set i 0; set j 1; set a [lindex $args 0]} {$i < $arglen} {incr j; set a [lindex $args [incr i]]} {
if {![string match -* $a]} {
#encountered non-option-like argument. All subsequent arguments should be treated as patterns even if they look like options.
#return [dict create options $options patterns [lrange $args $i end]]
set patterns [lrange $args $i end]
break
}
if {$a eq "--"} {
#return [dict create options $options patterns [lrange $args $j end]]
set patterns [lrange $args $j end]
break
}
#flag-like argument before -- we need to check if arg is a valid option or not
set full_option_name [tcl::prefix::match {-directory -join -nocomplain -path -tails -types} $a]
switch -- $full_option_name {
-directory - -path - -types {
#option takes a parameter - so next arg is parameter even if it looks like an option
if {$j < $arglen} {
dict set options $full_option_name [lindex $args $j]
incr i
incr j
} else {
error "fglob: option \"$a\" requires a parameter but none found"
}
}
default {
dict set options $full_option_name 1
}
}
}
#return [dict create options $options patterns $patterns]
#todo -join
# process each glob as a directory listing.
if {[dict exists $options -directory]} {
set basedir [dict get $options -directory]
} else {
set basedir ""
}
#ignore -nocomplain - like tcl9 glob - if no results - return empty list rather than error
if {[dict exists $options -types]} {
set types [dict get $options -types]
} else {
set types {}
}
#todo - fix for multiple absolute paths supplied.
#e.g c:/cmdfiles/*.exe c:/repo/jn/shellspy/bin/*.exe
#- we would want to treat as two separate globs with different basedirs
#for common basedir - we might be better off creating a single glob pattern using the brace syntax for alternatives.
#TCL's glob returns a single list when multiple patterns supplied.
#It does not seem to sort or de-dup, or group results by type.
set results [list]
foreach pattern $patterns {
set resultd [dirfiles_dict -portabilitycheck 0 -searchbase $basedir -with_times 0 -with_sizes 0 -link_info 0 -types $types -tailglob $pattern]
#puts [showdict $resultd]
#todo - other types?
lappend results {*}[dict get $resultd dirs] {*}[dict get $resultd files] {*}[dict get $resultd links]
}
return $results
}
punk::args::define {
@id -id ::punk::nav::fs::dirfiles_dict_as_lines
-stripbase -default 0 -type boolean
@ -1257,6 +1543,8 @@ tcl::namespace::eval punk::nav::fs {
# - we may be able to provide a reasonable default for windows vs other - but a proc option is likely needed to allow caller to override the default behaviour on a call by call basis,
# and a config option may be desirable for the user to override the platform default.
# The chosen defaults based on platform may be generally ok - but then not give the desired behaviour when accessing a particular filesystem/mount
# - note also that case-insensitivity can be configured per folder on windows via the fsutil.exe utility
# unfortunately we have no simple & fast way to query the case-sensitivity of a particular folder or filesystem.
if {$::tcl_platform(platform) eq "windows"} {
#case-preserving but case-insensitive matching is the default
foreach d $list_of_dicts {
@ -1704,9 +1992,13 @@ tcl::namespace::eval punk::nav::fs::lib {
punk::args::define {
@id -id ::punk::nav::fs::lib::is_fileglob
@cmd -name punk::nav::fs::lib::is_fileglob
@cmd -name punk::nav::fs::lib::is_fileglob\
-help\
"Determine if a string is a glob pattern as recognised by the tcl 'glob' command.
This is used for example to determine whether to treat a path component as a literal
or a glob pattern when processing paths with glob patterns in them (e.g for the ./ command)."
@values -min 1 -max 1
path -type string -required true -help\
path -type string -optional 0 -help\
{String to test for being a glob pattern as recognised by the tcl 'glob' command.
If the string represents a path with multiple segments, only the final component
of the path will be tested for glob characters.
@ -1799,6 +2091,7 @@ interp alias {} vwd {} punk::nav::fs::vwd ;#return punk::nav::fs::VI
interp alias {} dirlist {} punk::nav::fs::dirlist
interp alias {} dirfiles {} punk::nav::fs::dirfiles
interp alias {} dirfiles_dict {} punk::nav::fs::dirfiles_dict
interp alias {} fglob {} punk::nav::fs::fglob
interp alias {} ./new {} punk::nav::fs::d/new
interp alias {} d/new {} punk::nav::fs::d/new

5
src/vfs/_vfscommon.vfs/modules/punk/path-0.1.0.tm

@ -116,6 +116,8 @@ namespace eval punk::path {
#(sometimes //server.com used as a short form for urls - which doesn't seem too incompatible with this anyway)
# -- ---
#This is intended to be purely a string analysis - without reference to filesystem volumes or vfs or zipfs mountpoints etc
#(but this also means we won't be able to resolve windows shortnames or dos device paths - so we will preserve those as they are) - review
#(It also means we can't resolve per drive working directories on windows - so we will preserve c: as is rather than converting to absolute - review)
#
#TODO - option for caller to provide a -base below which we can't backtrack.
#This is preferable to setting policy here for example regarding forcing no trackback below //servername/share
@ -179,6 +181,9 @@ namespace eval punk::path {
#relpaths all end up with leading . - while not always the simplest form, this is ok. (helps stop inadvertent conversions to absolutes)
# Tests - TODO
# normjoin /d:/..//vfs:/test -> /vfs:/test (good - not converted to //vfs:/test)
#normjoin c: should theoretically return current per drive working directory on c:
# - would need to use win32 GetFullPathName to resolve this.
proc normjoin {args} {
set args [lmap a $args {string map "\\\\ /" $a}]
set path [plainjoin {*}$args]

290
src/vfs/_vfscommon.vfs/modules/punk/winlnk-0.1.1.tm

@ -21,11 +21,11 @@
#[manpage_begin punkshell_module_punk::winlnk 0 0.1.1]
#[copyright "2024"]
#[titledesc {windows shortcut .lnk library}] [comment {-- Name section and table of contents description --}]
#[moddesc {punk::winlnk}] [comment {-- Description at end of page heading --}]
#[moddesc {punk::winlnk}] [comment {-- Description at end of page heading --}]
#[require punk::winlnk]
#[keywords module shortcut lnk parse windows crossplatform]
#[description]
#[para] Tools for reading windows shortcuts (.lnk files) on any platform
#[para] Tools for reading windows shortcuts (.lnk files) on any platform
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
@ -164,6 +164,17 @@ tcl::namespace::eval punk::winlnk {
error "punk::winlnk::Header_Has_LinkFlag error - flagname $flagname not known"
}
}
proc Header_Get_LinkFlags_as_list {contents} {
variable LinkFlags
set allflags [Header_Get_LinkFlags $contents]
set setflags {}
dict for {flagname binflag} $LinkFlags {
if {$allflags & $binflag} {
lappend setflags $flagname
}
}
return $setflags
}
#MS-SHLLINK.pdf documents the .lnk file format in detail, but here is a brief overview of the structure of a .lnk file:
#protocol revision 10.0 (November 2025) https://winprotocoldocs-bhdugrdyduf5h2e4.b02.azurefd.net/MS-SHLLINK/%5bMS-SHLLINK%5d.pdf
@ -487,6 +498,89 @@ tcl::namespace::eval punk::winlnk {
}
}
variable known_guids [dict create\
"00021400-0000-0000-C000-000000000046" {name Desktop guidtype CLSID}\
"00021401-0000-0000-C000-000000000046" {name Shortcut guidtype CLSID}\
"20D04FE0-3AEA-1069-A2D8-08002B30309D" {name "This PC" guidtype CLSID}\
"F02C1A0D-BE21-4350-88B0-7367FC96EF3C" {name "Computers and Devices" guidtype CLSID}\
"645FF040-5081-101B-9F08-00AA002F954E" {name "Recycle Bin" guidtype CLSID}\
"26EE0668-A00A-44D7-9371-BEB064C98683" {name "Control Panel" guidtype CLSID}\
]
proc guid_lookup {guid} {
# This function looks up known GUIDs and returns their associated names or descriptions.
# In a real implementation, this could query a database of known GUIDs or use a predefined mapping of common GUIDs to their meanings.
# For example, it could recognize the CLSID for the "My Computer" folder, the "Network" folder, etc., and return human-readable names for those.
#
variable known_guids
if {[dict exists $known_guids $guid]} {
return [dict get $known_guids $guid]
} else {
if {"windows" eq $::tcl_platform(platform)} {
# On Windows, we can use the registry to look up GUIDs.
#for now we will just look up CLSIDs in HKEY_CLASSES_ROOT CLSID {guid}
package require registry
set reg_path [join [list HKEY_CLASSES_ROOT CLSID "{$guid}"] "\\"]
if {![catch {registry get $reg_path ""} name]} {
return [dict create name $name guidtype "CLSID"]
}
return ""
} else {
# On non-Windows platforms, we likely won't have a way to look up the GUID.
return ""
}
}
return ""
}
#some more hints: https://github.com/libyal/libfwsi/blob/main/documentation/Windows%20Shell%20Item%20format.asciidoc
proc Parse_LinkTargetID_typehex_1F {rawcontent} {
#The structure of this ItemID type is as follows:
#Offset 0: 2 bytes - size of the ItemID (including these 2 bytes)
#Offset 2: 1 byte - type byte (0x1F for file system objects)
#Offset 3: variable length - data specific to the item (e.g. file name, attributes, etc.)
set size_field [string range $rawcontent 0 1]
binary scan $size_field su size
set type_byte [string index $rawcontent 2]
if {[format %02X [scan $type_byte %c]] ne "1F"} {
error "punk::winlnk::Parse_LinkTargetID_typehex_1F error - expected type byte 0x1F but got [format %02X [scan $type_byte %c]]"
}
set parsed [dict create]
if {$size == 20} {
#when size is 20 and type is 0x1F - the data is assumed to be a GUID.
dict set parsed ident "GUID"
dict set parsed indicator [string range $rawcontent 3 3] ;#unknown - specific to shell implementation?
#structure is 4 bytes le, 2 bytes le, 2 bytes le, 8 bytes be
#the final hex format is 8-4-4-4-12 (e.g. 00021401-0000-0000-C000-000000000046) but the endianness is different for the first three parts vs the last two parts, which is a common pattern in Windows GUID/CLSID structures
set d1 [string range $rawcontent 4 7]
set d2 [string range $rawcontent 8 9]
set d3 [string range $rawcontent 10 11]
set d4 [string range $rawcontent 12 19]
#set d1_dec [scan $d1 i val1]
binary scan $d1 i d1_dec
binary scan $d2 s d2_dec
binary scan $d3 s d3_dec
#d4 is 8 bytes treated as individual bytes, so we can scan it as 8 individual bytes
set scan [string repeat %c 8]
set fmt [string repeat %02X 8]
set val4 [scan $d4 $scan]
set guid [format "%08X-%04X-%04X-%02X%02X-%02X%02X%02X%02X%02X%02X" $d1_dec $d2_dec $d3_dec {*}$val4]
dict set parsed guid $guid
set guid_info [guid_lookup $guid]
if {[dict size $guid_info]} {
dict set parsed name [dict get $guid_info name]
dict set parsed guidtype [dict get $guid_info guidtype]
}
return $parsed
} else {
#unknown
return $parsed
}
set data [string range $rawcontent 3 [expr {$size - 1}]]
#TODO - parse the data according to the structure of the ItemID type 0x1F
return [dict create size $size type "1F" rawdata $data]
}
#some clues on the structure of the IDList content and how to parse it can be found in the analysis of CVE-2020-0729,
#which is a remote code execution vulnerability in Windows that can be exploited through specially crafted .lnk files that contain malicious IDList content.
#The analysis of this vulnerability provides insights into how the IDList content is structured and how it can be parsed to extract information about the link target and potentially execute code.
@ -498,8 +592,8 @@ tcl::namespace::eval punk::winlnk {
set offset 0
while {$offset < [string length $idlist_content]} {
if {[string length $idlist_content] - $offset < 2} break
set size_bytes [string range $idlist_content $offset [expr {$offset + 1}]] ;#size including these 2 bytes
binary scan $size_bytes su size
set size_field [string range $idlist_content $offset $offset+1] ;#size including these 2 bytes
binary scan $size_field su size
if {$size == 0} break
if {$size < 2} {
# Invalid size, abort
@ -511,15 +605,24 @@ tcl::namespace::eval punk::winlnk {
break
}
set itemid [string range $idlist_content $offset [expr {$offset + $size - 1}]]
set itemid_bytes [string range $itemid 0 1]
binary scan $itemid_bytes su itemid_size
set itemid_size_field [string range $itemid 0 1]
binary scan $itemid_size_field su itemid_size
#in *general* byte 3 of the ItemID structure can be used to determine the type of the item
#(e.g. file, folder, network location, etc.) but this is not always reliable and can vary
#based on the specific structure of the ItemID and the context in which it is used
set itemid_type_byte [string index $itemid 2]
#puts stderr "ItemID size: $itemid_size, type byte: [format %02X [scan $itemid_type_byte %c]]"
set maybe_type [format %02X [scan $itemid_type_byte %c]]
lappend result [dict create size $itemid_size type $maybe_type rawcontent $itemid]
set typedec [scan $itemid_type_byte %c]
set typehex [format %02X $typedec]
set item_dict [dict create size $itemid_size typehex $typehex typedec $typedec rawbytes $itemid viewbytes [ansistring VIEW -lf 1 $itemid]]
switch -- $typehex {
"1F" {
set parsed [Parse_LinkTargetID_typehex_1F $itemid]
dict set item_dict parsed $parsed
}
}
lappend result $item_dict
incr offset $size
}
@ -615,9 +718,91 @@ tcl::namespace::eval punk::winlnk {
return [dict create localbasepath $localbasepath commonpathsuffix $commonpathsuffix note <incomplete>]
}
proc Contents_Get_DataStrings {contents start} {
set data_strings [string range $contents $start end]
return $data_strings
proc Contents_Get_StringData_String {contents start} {
#All StringData structures have a CountCharacters field of 2 bytes that is an unsigned integer specifying the number of characters
#in the string, followed by the string characters themselves.
#The string of variable length, must not be NULL-terminated.
#(which is 2 bytes of 0x00 if the IsUnicode flag is set in the LinkFlags field, or 1 byte of 0x00 if the IsUnicode flag is not set).
set lenfield [string range $contents $start $start+1]
set r [binary scan $lenfield su count_chars] ;# su is for unsigned short in little endian order
set string_value ""
if {[Header_Has_LinkFlag $contents "IsUnicode"]} {
#string is UTF-16LE encoded
set numbytes [expr {2 * $count_chars}]
set string_bytes [string range $contents $start+2 [expr {$start + 2 + $numbytes - 1}]]
#consider using tcl encoding convertfrom utf-16le instead of manually parsing the UTF-16LE bytes - this would be more robust and handle edge cases better (e.g. surrogate pairs, non-BMP characters, etc.)
set string_value [encoding convertfrom utf-16le $string_bytes]
#for {set i 0} {$i < [string length $string_bytes]} {
# set char_bytes [string range $string_bytes $i [expr {$i + 1}]]
# set r [binary scan $char_bytes su char] ;# s for unsigned short
# append string_value [format %c $char]
# incr i 1 ;# skip the next byte since it's part of the UTF-16LE encoding
#}
} else {
set numbytes $count_chars
set string_bytes [string range $contents $start+2 [expr {$start + 2 + $numbytes - 1}]]
#string is ANSI encoded
set string_value $string_bytes
}
return [dict create string $string_value next_start [expr {$start + 2 + $numbytes}]]
}
#MS-SHLLINK StringData
#https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-shllink/17b69472-0f34-4bcf-b290-eccdb8de224b
#stored in following order - each element is optional based on the presence of corresponding flags in the LinkFlags field of the header:
# NAME_STRING (description) - flag HasName
# RELATIVE_PATH - flag HasRelativePath
# WORKING_DIR - flag HasWorkingDir
# COMMAND_LINE_ARGUMENTS - flag HasArguments
# ICON_LOCATION - flag HasIconLocation
proc Contents_Get_StringData {contents start} {
set flags_enabled [Header_Get_LinkFlags_as_list $contents]
#puts stderr "flags_enabled: $flags_enabled"
#set data_strings [string range $contents $start end]
#return $data_strings
set current_offset $start
set result {}
if {"HasName" in $flags_enabled} {
set stringinfo [Contents_Get_StringData_String $contents $current_offset]
#set current_offset [expr {$current_offset + [string length $name] + 1}] ;#+1 for null terminator
#incr current_offset [string length $name]
set current_offset [dict get $stringinfo next_start]
set name [dict get $stringinfo string]
dict set result name_string $name
}
if {"HasRelativePath" in $flags_enabled} {
set stringinfo [Contents_Get_StringData_String $contents $current_offset]
set current_offset [dict get $stringinfo next_start]
set relative_path [dict get $stringinfo string]
#set current_offset [expr {$current_offset + [string length $relative_path] + 1}] ;#+1 for null terminator
dict set result relative_path $relative_path
}
if {"HasWorkingDir" in $flags_enabled} {
set stringinfo [Contents_Get_StringData_String $contents $current_offset]
set current_offset [dict get $stringinfo next_start]
set working_dir [dict get $stringinfo string]
#set current_offset [expr {$current_offset + [string length $working_dir] + 1}] ;#+1 for null terminator
dict set result working_dir $working_dir
}
if {"HasArguments" in $flags_enabled} {
set stringinfo [Contents_Get_StringData_String $contents $current_offset]
set current_offset [dict get $stringinfo next_start]
set arguments [dict get $stringinfo string]
#set current_offset [expr {$current_offset + [string length $arguments] + 1}] ;#+1 for null terminator
dict set result command_line_arguments $arguments
}
if {"HasIconLocation" in $flags_enabled} {
set stringinfo [Contents_Get_StringData_String $contents $current_offset]
set current_offset [dict get $stringinfo next_start]
set icon_location [dict get $stringinfo string]
#set current_offset [expr {$current_offset + [string length $icon_location] + 1}] ;#+1 for null terminator
dict set result icon_location $icon_location
}
return $result
}
proc Contents_Get_Info {contents} {
@ -639,12 +824,13 @@ tcl::namespace::eval punk::winlnk {
#Working Dir: C:\repo\jn\tclmodules\tomlish\src\modules\test\#modpod-tomlish-0.1.1\suites\roundtrip\roundtrip_files
variable LinkFlags
set flags_enabled [list]
dict for {k v} $LinkFlags {
if {[Header_Has_LinkFlag $contents $k] > 0} {
lappend flags_enabled $k
}
}
set flags_enabled [Header_Get_LinkFlags_as_list $contents]
#set flags_enabled [list]
#dict for {k v} $LinkFlags {
# if {[Header_Has_LinkFlag $contents $k] > 0} {
# lappend flags_enabled $k
# }
#}
set showcommand_val [Header_Get_ShowCommand $contents]
switch -- $showcommand_val {
@ -726,19 +912,13 @@ tcl::namespace::eval punk::winlnk {
}
# ----------------------------------------------------------------------
#todo - get Data strings by parsing contents starting at $next_start
#stored in following order:
# description
# relative path
# working directory
# command line arguments
# icon location
#get StringData by parsing contents starting at $next_start
#Data strings format:
# 2 bytes: number of characters in the string
# following: The string. ASCII or UTF-16 little-endian string
set datastring_dict [Contents_Get_DataStrings $contents $next_start]
#puts stderr "next_start: $next_start"
set datastring_dict [Contents_Get_StringData $contents $next_start]
# ----------------------------------------------------------------------
@ -770,18 +950,32 @@ tcl::namespace::eval punk::winlnk {
target_type_mech $target_type_mech\
idlist $linktargetidlist\
linkinfo $linkfields\
stringdata $datastring_dict\
]
#relative_path "?"
}
namespace eval argdoc {
variable PUNKARGS
lappend PUNKARGS [list {
@id -id ::punk::winlnk::file_check_header
@cmd -name punk::winlnk::file_check_header\
-summary\
"Test if .lnk file has a valid header for a windows shortcut."\
-help\
"Check the header of the file specified in path to see if it matches the expected
structure of a windows .lnk file header.
Returns a boolean.
If an invalid path is provided or the file cannot be read, an error will be raised."
@values -min 1 -max 1
path -type string -help "Path to the .lnk file to resolve"
}]
}
proc file_check_header {path} {
#*** !doctools
#[call [fun file_check_header] [arg path] ]
#[para]Return 0|1
#[para]Determines if the .lnk file specified in path has a valid header for a windows shortcut
set c [Get_contents $path 20]
return [Contents_check_header $c]
}
namespace eval argdoc {
variable PUNKARGS
lappend PUNKARGS [list {
@ -798,10 +992,6 @@ tcl::namespace::eval punk::winlnk {
}]
}
proc resolve {path} {
#*** !doctools
#[call [fun resolve] [arg path] ]
#[para] Return a dict of info obtained by parsing the binary data in a windows .lnk file
#[para] If the .lnk header check fails, then the .lnk file probably isn't really a shortcut file and the dictionary will contain an 'error' key
set c [Get_contents $path]
if {[Contents_check_header $c]} {
return [Contents_Get_Info $c]
@ -840,6 +1030,7 @@ tcl::namespace::eval punk::winlnk {
target_type target_type\
idlist idlist/@*/@*.@*\
linkinfo linkinfo/@*.@*\
stringdata stringdata/@*.@*\
]
set info [resolve $path]
if {[dict exists $info error]} {
@ -869,11 +1060,18 @@ tcl::namespace::eval punk::winlnk {
"Return the target path of the .lnk file specified in path.
This is a convenience function that extracts the target path from the .lnk file and returns it directly,
without all the additional information that resolve provides. If the .lnk header check fails, then
the .lnk file probably isn't really a shortcut file and an error message will be returned."
the .lnk file probably isn't really a shortcut file and an error message will be returned.
Incomplete! - needs to process arguments when HasArguments link flag is set and append the arguments to the target path.
e.g for a shortcut to 'START /D ...' the target will currently just return a path to cmd.exe - which is insufficient."
@values -min 1 -max 1
path -type string -help "Path to the .lnk file to resolve"
}]
}
#consider a shortcut to the following:
# START /D ^"C:\tcl\bin^" wish.exe c:\cmdfiles\ftp.tcl"
# the target currently only returns c:/Windows/System32/cmd.exe.
proc target {path} {
#*** !doctools
#[call [fun target] [arg path] ]
@ -941,19 +1139,6 @@ tcl::namespace::eval punk::winlnk {
}
#proc sample1 {p1 n args} {
# #*** !doctools
# #[call [fun sample1] [arg p1] [arg n] [opt {option value...}]]
# #[para]Description of sample1
# #[para] Arguments:
# # [list_begin arguments]
# # [arg_def tring p1] A description of string argument p1.
# # [arg_def integer n] A description of integer argument n.
# # [list_end]
# return "ok"
#}
#*** !doctools
@ -973,13 +1158,6 @@ tcl::namespace::eval punk::winlnk::lib {
#[para] Secondary functions that are part of the API
#[list_begin definitions]
#proc utility1 {p1 args} {
# #*** !doctools
# #[call lib::[fun utility1] [arg p1] [opt {?option value...?}]]
# #[para]Description of utility1
# return 1
#}
#*** !doctools
@ -1010,7 +1188,7 @@ namespace eval ::punk::args::register {
package provide punk::winlnk [tcl::namespace::eval punk::winlnk {
variable pkg punk::winlnk
variable version
set version 0.1.1
set version 0.1.1
}]
return

41
src/vfs/_vfscommon.vfs/modules/punk/winpath-0.1.0.tm

@ -136,18 +136,30 @@ namespace eval punk::winpath {
proc illegal_char_map_to_ntfs {ch} {
#windows ntfs maps illegal chars to the PUA unicode range 0xF000-0xF0FF for characters that are illegal in windows API but can exist on NTFS or samba shares etc.
#see also: https://cygwin.com/cygwin-ug-net/using-specialnames.html#pathnames-specialchars
#see also: https://stackoverflow.com/questions/76738031/unicode-private-use-characters-in-ntfs-filenames#:~:text=map_dict%20=%20%7B%200xf009:'%5C,c%20return%20(output_name%2C%20mapped)
#see also: https://stackoverflow.com/questions/76738031/unicode-private-use-characters-in-ntfs-filenames
#formula is 0xF000 + ascii value of char.
#(decimal values are 61440 + ascii value of char)
#see also punk::char::ascii2NTFSPUA
# punk::char::codetable ascii
set map [dict create \
"\"" "\uF022" \
"*" "\uF02A" \
":" "\uF03A" \
"<" "\uF03C" \
">" "\uF03E" \
":" "\uF03A" \
"\"" "\uF022" \
"/" "unknown" \
"?" "\uF03F" \
"\\" "\uF05c" \
"|" "\uF07C" \
"?" "\uF03F" \
"*" "\uF02A"]
]
dict set map "/" "\uF02F"
#ESC (\x1b) is also mapped.
dict set map \x1b "\uF01B"
#note that : is used for NTFS alternate data streams - but the character is still illegal in the main filename - so we will map it to a glyph in the PUA range for display purposes - but it will still need dos device syntax to be accessed via windows API.
if {$ch in [dict keys $map]} {
return [dict get $map $ch]
@ -412,14 +424,15 @@ namespace eval punk::winpath::system {
}
# -----------------------------------------------------------------------------
# register namespace(s) to have PUNKARGS,PUNKARGS_aliases variables checked
# -----------------------------------------------------------------------------
# variable PUNKARGS
# variable PUNKARGS_aliases
namespace eval ::punk::args::register {
#use fully qualified so 8.6 doesn't find existing var in global namespace
lappend ::punk::args::register::NAMESPACES ::punk::winpath
}
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
## Ready

Loading…
Cancel
Save