You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

1590 lines
69 KiB

# -*- tcl -*-
# Maintenance Instruction: leave the 999999.xxx.x as is and use 'pmix make' or src/make.tcl to update from <pkg>-buildversion.txt
#
# 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) 2023
#
# @@ Meta Begin
# Application punk::path 0.1.0
# Meta platform tcl
# Meta license <unspecified>
# @@ Meta End
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
# doctools header
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
#*** !doctools
#[manpage_begin punkshell_module_punk::path 0 0.1.0]
#[copyright "2023"]
#[titledesc {Filesystem path utilities}] [comment {-- Name section and table of contents description --}]
#[moddesc {punk path filesystem utils}] [comment {-- Description at end of page heading --}]
#[require punk::path]
#[description]
#[keywords module path filesystem]
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
#*** !doctools
#[section Overview]
#[para] overview of punk::path
#[para] Filesystem path utility functions
#[subsection Concepts]
#[para] -
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
## Requirements
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
#*** !doctools
#[subsection dependencies]
#[para] packages used by punk::path
#[list_begin itemized]
package require Tcl 8.6-
package require punk::args
#*** !doctools
#[item] [package {Tcl 8.6-}]
#[item] [package {punk::args}]
# #package require frobz
# #*** !doctools
# #[item] [package {frobz}]
#*** !doctools
#[list_end]
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
#*** !doctools
#[section API]
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
# oo::class namespace
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
#namespace eval punk::path::class {
#*** !doctools
#[subsection {Namespace punk::path::class}]
#[para] class definitions
#if {[info commands [namespace current]::interface_sample1] eq ""} {
#*** !doctools
#[list_begin enumerated]
# oo::class create interface_sample1 {
# #*** !doctools
# #[enum] CLASS [class interface_sample1]
# #[list_begin definitions]
# method test {arg1} {
# #*** !doctools
# #[call class::interface_sample1 [method test] [arg arg1]]
# #[para] test method
# puts "test: $arg1"
# }
# #*** !doctools
# #[list_end] [comment {-- end definitions interface_sample1}]
# }
#*** !doctools
#[list_end] [comment {--- end class enumeration ---}]
#}
#}
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
# Base namespace
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
namespace eval punk::path {
namespace export {[a-z]*}
#variable xyz
#*** !doctools
#[subsection {Namespace punk::path}]
#[para] Core API functions for punk::path
#[list_begin definitions]
# -- ---
#punk::path::normjoin
# - simplify . and .. segments as far as possible whilst respecting specific types of root.
# -- ---
#a form of file normalize that supports //xxx to be treated as server path names
#(ie regardless of unices ignoring (generally) leading double slashes, and regardless of windows volumerelative path syntax)
#(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
#Our default is to allow trackback to:
# <scheme>://<something>
# <driveletter>:/
# //./<volume> (dos device volume)
# //server (while normalizing //./UNC/server to same)
# / (ordinary unix root)
# ./../<repeated> - (track back indefinitely on relpath as we are not resolving to anything physical and can't fully simplify the leading backtracks)
#
#The caller should do the file/vfs operations to determine this - not us.
# -- ---
#simplify path with respect to /./ & /../ elements - independent of platform
#NOTE: "anomalies" in standard tcl processing on windows:
#e.g file normalize {//host} -> c:/host (or e.g d:/host if we happen to be on another volume)
#file normalize {//host/share} -> //host/share
#This is because //host is treated as volume-relative in cmd/powershell and Tcl quite reasonably follows suit.
#This prevents cwd and windows commandlines from pointing to the server (above the share)
#Explorer however does allow pointing to the //server level and seeing shares as if they are directory entries.
#we are more interested in supporting the explorer-like behaviour - as while volumerelative paths are also useful on windows - they are lesser known.
#REVIEW.
#To get back to some consistent cross platform behaviour - we will treat //something as a root/volume i.e we can't backtrack above it with ".."
#note too that file split on UNC paths doesn't give a clear indication of the root
# file split //./UNC/server/share/subpath -> //./UNC server share subpath
# file split //server/share/subpath -> //server/share subpath
#TODO - disallow all change of root or change from relative path to absolute result.
#e.g normjoin relpath/../d:/secret should not return d:/secret - but ./d:/secret
# ================
#known issues:
#1)
# normjoin d://a//b//c -> d://a/b/c
# This is because we don't detect specific schemes. ie it's treated the same as https://a/b/c -> https://a/b/c
# Not considered a problem - just potentially surprising.
# To avoid it we would have to enumerate possible schemes.
# As it stands a unix system could define a 'scheme' that happens to match windows style driveletters. Consider a 'feature' ? review.
# won't fix?
#2)
# normjoin https:///real.com/../fake.com -> https:///fake.com
# The extra slash means effectively our servername is empty - this is potentially confusing but probably the right thing to do here.
# It's a concern only if upstream treats the tripple slash in this case as valid and maps it to https:// - which would probably be bad anyway.
# won't fix (review)
#3)
#similarly
# normjoin //./UNC//server/share/subpath -> ///server/share/subpath (when 2 or more slashes directly after UNC)
# normjoin ///server/share -> ///server/share
#This is effectively an empty servername in the input with 'server' being pushed one level down - and the output is consistent
# possibly won't fix - review
#4) inconsistency
# we return normalized //server/share for //./UNC/server share
# but other dos device paths are maintained
# e.g //./c:/etc
# This is because such paths could contain alternate segment names (windows shortnames) which we aren't in a position to resolve.
# caller should
# #as with 'case' below - caller will need to run a post 'file normalize'
#5) we don't normalize case like file normalize does on windows platform.
# This is intentional. It could only be done with reference to underlying filesystem which we don't want here.
#
# ================
#
#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]
switch -exact -- $path {
"" {
return ""
}
/ - // {
#treated in unixlike manner - (but leading doubleslashes with subsequent data are server indication)
#// not considered a servername indicator - but /// (for consistency) is. (empty servername?)
return /
}
/// {
#if this is effectively //$emptyservername/
#then for consistency we should trail //<servername with a slash too?
#we can't transform to // or /
return ///
#assert - code below should return /// (empty server prefix) for any number of leading slashes >=3
#todo - shortcircuit that here?
}
}
# ///
set doubleslash1_posn [string first // $path]
# -- --- --- temp warning on windows only - no x-platform difference in result
#on windows //host is of type volumerelative
# whereas //host/share is of type absolute
if {"windows" eq $::tcl_platform(platform) && [file pathtype $path] eq "volumerelative"} {
#volumerelative probably only occurs on windows anyway
if {$doubleslash1_posn == 0} {
#e.g //something where no further slashes
#review - eventually get rid of this warning and require upstream to know the appropriate usecase
puts stderr "Warning - ambiguous path $path - treating as server path - not 'volumerelative'"
} else {
# /something/etc
# /mnt/c/stuff
#output will retain leading / as if on unix.
#on windows - the result would still be interpreted as volumerelative if the caller normalizes it
}
}
# -- --- ---
set is_relpath 0
#set path [string map [list \\ /] $path]
set finalparts [list]
set is_nonunc_dosdevice 0
if {[punk::winpath::is_dos_device_path $path]} {
#review
if {[string range $path 4 6] eq "UNC"} {
#convert to 'standard' //server/... path for processing
set path "/[string range $path 7 end]" ;# //server/...
} else {
#error "normjoin non-UNC dos device path '$path' not supported"
#first segment after //./ or //?/ represents the volume or drive.
#not applicable to unix - but unlikely to conflict with a genuine usecase there (review)
#we should pass through and stop navigation below //./vol
#!!!
#not anomaly in tcl (continues in tcl9)
#file exists //./c:/test -> 0
#file exists //?/c:/test -> 1
#file exists //./BootPartition/Windows -> 1
#file exists //?/BootPartition/Windows -> 0
set is_nonunc_dosdevice 1
}
}
if {$is_nonunc_dosdevice} {
#dosdevice prefix //./ or //?/ - preserve it (without trailing slash which will be put back in with join)
set prefix [string range $path 0 2]
set tail [string range $path 4 end]
set tailparts [split $tail /]
set parts [concat [list $prefix] $tailparts]
set rootindex 1 ;#disallow backtrack below //./<volume>
} else {
#note use of ordinary ::split vs file split is deliberate.
if {$doubleslash1_posn == 0} {
#this is handled differently on different platforms as far as 'file split' is concerned.
#e.g for file split //sharehost/share/path/etc
#e.g on windows: -> //sharehost/share path
#e.g on freebsd: -> / sharehost share path etc
#however..also on windows: file split //sharehost -> / sharehost
#normalize by dropping leading slash before split - and then treating first 2 segments as a root
#set parts [file split [string range $path 1 end]]
set parts [split $path /]
#assert parts here has {} {} as first 2 entries
set rootindex 2
#currently prefer can backtrack to the //zipfs:/ scheme (below the mountpoint - to browse other mounts)
#alternative handling for //zipfs:/path - don't go below mountpoint
#but we can't determine just from string if mountpoint is direct subpath or a lower one e.g //zipfs:/arbitraryname/actualmountpoint
#review - more generally //<mountmechanism>:/path ?
#todo - make an option for zipfs and others to determine the 'base'
#if {"zipfs:" eq [lindex $parts 2]} {
# set rootindex 3
#}
} else {
#path may or may not begin with a single slash here.
#treat same on unix and windows
set rootindex 0
#set parts [file split $path]
set parts [::split $path /]
#e.g /a/b/c -> {} a b c
#or relative path a/b/c -> a b c
#or c:/a/b/c -> c: a b c
if {[string match *: [lindex $parts 0]]} {
if {[lindex $parts 1] eq ""} {
#scheme://x splits to scheme: {} x
set parts [concat [list [lindex $parts 0]/] [lrange $parts 2 end]]
#e.g {scheme:/ x}
set rootindex 1 ;#disallow below first element of scheme
} else {
set rootindex 0
}
} elseif {[lindex $parts 0] ne ""} {
#relpath a/b/c
#set parts [linsert $parts 0 .]
ledit parts -1 -1 .
set rootindex 0
#allow backtracking arbitrarily for leading .. entries - simplify where possible
#also need to stop possible conversion to absolute path
set is_relpath 1
}
}
}
set baseparts [lrange $parts 0 $rootindex] ;#base below which we can't retreat via ".."
#puts stderr "-->baseparts:$baseparts"
#ensure that if our rootindex already spans a dotted segment (after the first one) we remove it
#must maintain initial . for relpaths to stop them converting to absolute via backtrack
#
set finalparts [list [lindex $baseparts 0]]
foreach b [lrange $baseparts 1 end] {
if {$b ni {. ..}} {
lappend finalparts $b
}
}
set baselen [expr {$rootindex + 1}]
if {$is_relpath} {
set i [expr {$rootindex+1}]
foreach p [lrange $parts $i end] {
switch -exact -- $p {
. - "" {}
.. {
switch -exact -- [lindex $finalparts end] {
. - .. {
lappend finalparts ..
}
default {
#lpop finalparts
ledit finalparts end end
}
}
}
default {
lappend finalparts $p
}
}
incr i
}
} else {
foreach p [lrange $parts $rootindex+1 end] {
if {[llength $finalparts] <= $baselen} {
if {$p ni {. .. ""}} {
lappend finalparts $p
}
} else {
switch -exact -- $p {
. - "" {}
.. {
#lpop finalparts ;#uses punk::lib::compat::lpop if on < 8.7
ledit finalparts end end ;#uses punk::lib::compat::ledit if on < 8.7
}
default {
lappend finalparts $p
}
}
}
}
}
#puts "==>finalparts: '$finalparts'"
# using join - {"" "" server share} -> //server/share and {a b} -> a/b
if {[llength $finalparts] == 1 && [lindex $finalparts 0] eq ""} {
#backtracking on unix-style path can end up with empty string as only member of finalparts
#e.g /x/..
return /
}
set result [::join $finalparts /]
#normalize volumes and mountschemes to have trailing slash if no subpath
#e.g c: -> c:/
#//zipfs: -> //zipfs:/
if {[set lastchar [string index $result end]] eq ":"} {
if {$result eq "//zipfs:"} {
set result "//zipfs:/"
} else {
if {[string first / $result] < 0} {
set result $result/
}
}
} elseif {[string match //* $result]} {
if {![punk::winpath::is_dos_device_path $result]} {
#server
set tail [string range $result 2 end]
set tailparts [split $tail /]
if {[llength $tailparts] <=1} {
#empty // or //servername
append result /
}
}
} elseif {[llength $finalparts] == 2} {
if {[string range [lindex $finalparts 0] end-1 end] eq ":/"} {
#e.g https://server/ -> finalparts {https:/ server}
#e.g https:/// -> finalparts {https:/ ""}
#scheme based path should always return trailing slash after server component - even if server component empty.
lappend finalparts "" ;#force trailing /
return [join $finalparts /]
}
}
if {[file extension [lindex $finalparts end]] eq ".lnk"} {
if {![catch {package require punk::winlnk}]} {
if {![catch {punk::winlnk::target $result} path]} {
return $path
}
}
}
return $result
}
proc trim_final_slash {str} {
if {[string index $str end] eq "/"} {
return [string range $str 0 end-1]
}
return $str
}
#x-platform - punk::path::pathtype - can be used in safe interps - different concept of pathtypes to 'file pathtype'
# - no volumerelative
# - no lookup of file volumes (volume is a windows concept - but with //zipfs:/ somewhat applicable to other platforms)
# - /* as absolute (covers also //zipfs:/ (volume), //server , //./etc , //./UNC)
# - xxx:// as absolute (scheme)
# - xxx:/ or x:/ as absolute
# - x: xxx: -> as absolute (volume-basic or volume-extended)
#note also on windows - legacy name for COM devices
# COM1 = COM1:
# //./COM1 ?? review
proc pathtype {str} {
set str [string map "\\\\ /" $str]
if {[string index $str 0] eq "/"} {
#todo - look for //xxx:/ prefix (generalisation of //zipfs:/) as a 'volume' specifically {volume mount} ?? - review
# look for //server prefix as {absolute server}
# look for //./UNC/server or //?/UNC/server as {absolute server UNC} ?
# look for //./<dosdevice> as {absolute dosdevice}
return absolute
}
#only firstsegment with single colon at last position (after some non empty string) counts as volume or scheme - review
#e.g a:b:/.. or a::/.. or :/.. is not treated as volume/scheme whereas ab:/ is.
set firstslash [string first / $str]
if {$firstslash == -1} {
set firstsegment $str
} else {
set firstsegment [string range $str 0 $firstslash-1]
}
if {[set firstc [string first : $firstsegment]] > 0} {
set lhs_firstsegment [string range $firstsegment 0 $firstc-1]
set rhs_firstsegment [string range $firstsegment $firstc+1 end] ;#exclude a:b/ etc
if {$rhs_firstsegment eq ""} {
set rhs_entire_path [string range $str $firstc+1 end]
#assert lhs_firstsegment not empty since firstc > 0
#count following / sequence
set i 0
set slashes_after_firstsegment "" ;#run of slashes *directly* following first segment
while {$i < [string length $rhs_entire_path]} {
if {[string index $rhs_entire_path $i] eq "/"} {
append slashes_after_firstsegment /
} else {
break
}
incr i
}
switch -exact -- $slashes_after_firstsegment {
"" - / {
if {[string length $lhs_firstsegment] == 1} {
return {absolute volume basic}
} else {
return {absolute volume extended}
}
}
default {
#2 or more /
#this will return 'scheme' even for c:// - even though that may look like a windows volume - review
return {absolute scheme}
}
}
}
}
#assert first element of any return has been absolute or relative
return relative
}
proc plain {str} {
set str [string map "\\\\ /" $str]
set pathinfo [punk::path::pathtype $str]
if {[lindex $pathinfo 0] eq "relative" && ![string match ./* $str]} {
set str ./$str
}
if {[string index $str end] eq "/"} {
if {[string map {/ ""} $str] eq ""} {
#all slash segment
return $str
} else {
if {[lindex $pathinfo 1] ni {volume scheme}} {
return [string range $str 0 end-1]
}
}
}
return $str
}
#purely string based - no reference to filesystem knowledge
#unix-style forward slash only
proc plainjoin {args} {
set args [lmap a $args {string map "\\\\ /" $a}]
#if {[llength $args] == 1} {
# return [lindex $args 0]
#}
set out ""
foreach a $args {
if {![string length $out]} {
append out [plain $a]
} else {
set a [plain $a]
if {[string map {/ ""} $out] eq ""} {
set out [string range $out 0 end-1]
}
if {[string map {/ ""} $a] eq ""} {
#all / segment
append out [string range $a 0 end-1]
} else {
if {[string length $a] > 2 && [string match "./*" $a]} {
set a [string range $a 2 end]
}
if {[string index $out end] eq "/"} {
append out $a
} else {
append out / $a
}
}
}
}
return $out
}
proc plainjoin1 {args} {
if {[llength $args] == 1} {
return [lindex $args 0]
}
set out [trim_final_slash [lindex $args 0]]
foreach a [lrange $args 1 end] {
set a [trim_final_slash $a]
append out / $a
}
return $out
}
#intention?
#proc filepath_dotted_dirname {path} {
#}
proc strip_prefixdepth {path prefix} {
if {$prefix eq ""} {
return [norm $path]
}
return [file join {*}{
} {*}[lrange {*}{
} [file split [norm $path]] {*}{
} [llength [file split [norm $prefix]]] {*}{
} end
]
]
}
## for comparison
#proc nsglob_as_re {glob} {
# #any segment that is not just * must match exactly one segment in the path
# set pats [list]
# foreach seg [nsparts_cached $glob] {
# switch -exact -- $seg {
# "" {
# lappend pats ""
# }
# * {
# #review - ::g*t will not find ::got:it (won't match single inner colon) - this should be fixed
# #lappend pats {[^:]*}
# #negative lookahead
# #any number of chars not followed by ::, followed by any number of non :
# lappend pats {(?:.(?!::))*[^:]*}
# }
# ** {
# lappend pats {.*}
# }
# default {
# set seg [string map {. [.]} $seg]
# if {[regexp {[*?]} $seg]} {
# #set pat [string map [list ** {.*} * {[^:]*} ? {[^:]}] $seg]
# set pat [string map [list ** {.*} * {(?:.(?!::))*[^:]*} ? {[^:]}] $seg]
# lappend pats "$pat"
# } else {
# lappend pats "$seg"
# }
# }
# }
# }
# return "^[join $pats ::]\$"
#}
proc pathglob_as_re {pathglob} {
#*** !doctools
#[call [fun pathglob_as_re] [arg pathglob]]
#[para] Returns a regular expression for matching a path to a glob pattern which can contain glob chars *|? in any segment of the path structure
#[para] Does not support square bracket globs or character classes.
#[para] ** matches any number of subdirectories.
#[para] e.g /etc/**/*.txt will match any .txt files at any depth below /etc (except directly within /etc itself)
#[para] e.g /etc/**.txt will match any .txt files at any depth below /etc
#[para] any segment that does not contain ** must match exactly one segment in the path
#[para] e.g the glob /etc/*/*.doc - will match any .doc files that are exactly one tree level below /etc
#[para] The pathglob doesn't have to contain glob characters, in which case the returned regex will match the pathglob exactly as specified.
#[para] Regular expression syntax is deliberateley not supported within the pathglob string so that supplied regex characters will be treated as literals
#todo - consider whether a way to escape the glob chars ? * is practical - to allow literals ? *
# - would require counting immediately-preceding backslashes
set pats [list]
foreach seg [file split $pathglob] {
if {[string range $seg end end] eq "/"} {
set seg [string range $seg 0 end-1] ;# e.g c:/ -> c: / -> "" so that join at end doesn't double up
}
switch -- $seg {
* {lappend pats {[^/]*}}
** {lappend pats {.*}}
default {
set seg [string map [list ^ {\^} $ {\$} \[ {\[} \] {\]} ( {\(} ) {\)} \{ \\\{ \\ {\\}] $seg] ;#treat regex characters (or tcl glob square bracket chars) in the input as literals
#set seg [string map [list . {[.]}] $seg]
set seg [string map {. [.]} $seg]
if {[regexp {[*?]} $seg]} {
set pat [string map [list ** {.*} * {[^/]*} ? {[^/]}] $seg]
lappend pats "$pat"
} else {
lappend pats "$seg"
}
}
}
}
return "^[join $pats /]\$"
}
punk::args::define {
@id -id ::punk::path::globmatchpath
@cmd -name punk::path::globmatchpath\
-summary\
"Match path to *|**|? glob patterns"\
-help\
"Return a boolean indicating whether the path matches the specialised glob pattern.
A pattern such as /usr/*/bin will match any path that has /usr as the first segment and bin as the third segment,
with any single segment in between.
A pattern such as /usr/**/bin will match any path that has /usr as the first segment and bin as the last segment,
with 1 or more segments in between (so it will not match /usr/bin).
A pattern such as /usr/** will match any path that has /usr as the first segment, with 1 or more segments
following (so it will not match /usr itself).
A pattern such as **/*.txt will match any path that ends with .txt, with 1 or more leading segments
(so it will not match test.txt or .txt).
A pattern such as ** will match any path.
The glob characters * and ? are the only special characters in the pathglob syntax.
- they are treated as glob characters regardless of where they appear in the pathglob string.
Note that this is different from other Tcl glob contexts where square brackets can be used.
The pathglob syntax treats other characters, including square brackets as literals.
For example, the pattern /usr/te?t will match /usr/test and /usr/text but not /usr/texxt, and the pattern /usr/te*t
will match /usr/test, /usr/teat, and /usr/teeeet but not /usr/te/t.
The pathglob syntax does not support escaping of glob characters - any glob characters in the pathglob are treated
as glob characters. For example, the pattern /usr/* will match any path that has /usr as the first segment and any
single segment as the second segment, but there is no way to specify a pattern that matches any path that has /usr
as the first segment and a literal * as the second segment.
Caller must ensure that file separator is forward slash. (e.g use file normalize on windows)
options:
-nocase 0|1 (default 0 - case sensitive)
If -nocase is not supplied - default to case sensitive *except for driveletter*
ie - the driveletter alone in paths such as c:/etc will still be case insensitive. (ie c:/ETC/* will match C:/ETC/blah but not C:/etc/blah)
Explicitly specifying -nocase 0 will require the entire case to match including the driveletter.
"
@leaders
pathglob -type string -help "glob pattern to match path against. See [fun pathglob_as_re] for syntax of glob patterns"
path -type string -help "path to match against glob pattern"
@opts
-nocase -type boolean -default 0 -help\
"case insensitive matching (default false - case sensitive)
- except for driveletter on windows which is always case insensitive
unless -nocase 0 is explicitly specified"
@values -min 0 -max 0
}
# -id
proc globmatchpath {pathglob path args} {
#*** !doctools
#[call [fun globmatchpath] [arg pathglob] [arg path] [opt {option value...}]]
#[para] Return true if the pathglob matches the path
#[para] see [fun pathglob_as_re] for pathglob description
#[para] Caller must ensure that file separator is forward slash. (e.g use file normalize on windows)
#[para]
#[para] Known options:
#[para] -nocase 0|1 (default 0 - case sensitive)
#[para] If -nocase is not supplied - default to case sensitive *except for driveletter*
#[para] ie - the driveletter alone in paths such as c:/etc will still be case insensitive. (ie c:/ETC/* will match C:/ETC/blah but not C:/etc/blah)
#[para] Explicitly specifying -nocase 0 will require the entire case to match including the driveletter.
set opts [dict create {*}{
-nocase \uFFFF
}]
foreach {k v} $args {
switch -- $k {
-nocase {
dict set opts $k $v
}
default {
error "Unrecognised option '$k'. Known-options: [dict keys $opts]"
}
}
}
# -- --- --- --- --- ---
set opt_nocase [dict get $opts -nocase]
set explicit_nocase 1 ;#default to disprove
if {$opt_nocase eq "\uFFFF"} {
set opt_nocase 0
set explicit_nocase 0
}
# -- --- --- --- --- ---
if {$opt_nocase} {
return [regexp -nocase [pathglob_as_re $pathglob] $path]
} else {
set re [pathglob_as_re $pathglob]
if {$explicit_nocase} {
set ismatch [regexp $re $path] ;#explicit -nocase 0 - require exact match of path literals including driveletter
} else {
#caller is using default for -nocase - which indicates case sensitivity - but we have an exception for the driveletter.
set re_segments [file split $re] ;#Note that file split c:/etc gives {c:/ etc} but file split ^c:/etc gives {^c: etc}
set first_seg [lindex $re_segments 0]
if {[regexp {^\^(.{1}):$} $first_seg _match driveletter]} {
#first part of re is like "^c:" i.e a drive letter
set chars [string tolower $driveletter][string toupper $driveletter]
set re [join [concat "^\[$chars\]:" [lrange $re_segments 1 end]] /] ;#rebuild re with case insensitive driveletter only - use join - not file join. file join will misinterpret leading re segment.
}
#puts stderr "-->re: $re"
set ismatch [regexp $re $path]
}
}
return $ismatch
}
punk::args::define {
@id -id ::punk::path::subfolders1
@cmd -name punk::path::subfolders1\
-summary\
"Listing of directories below supplied path."\
-help\
"List of folders below path.
The resulting list is unsorted."
@opts
-recursive -type none -help\
""
-exclude-paths -type list -default {} -help\
"list of path patterns to exclude from results.
May include * and ** path segments e.g /usr/**
A single /*/ will match any single segment in the path, and a single /**/ will match any number of segments in the path.
e.g to exclude any path with _aside as a segment in the middle: -exclude-paths **/_aside/**
i.e this would exclude /usr/_aside/etc and /usr/x/_aside/etc but not /usr/x/_aside or _aside/etc
To exclude all paths with _aside as a segment anywhere: -exclude-paths { **/_aside/** **/_aside _aside/**}
"
#todo -depth
@values -min 0 -max 1
path -type directory -optional 1 -help\
"Path of folder. If not supplied current directory is used.
This may be a relative or absolute path. Relative paths are treated as relative to current directory.
When using relative paths - the result will also be relative paths with the same relative prefix.
(e.g if path is ../test - the results will be ../test/subfolder1 ../test/subfolder2 etc)
Patterns in -exclude-paths are matched against the resulting paths
(so should be written to match the same relative prefix if path is relative)"
}
proc subfolders1 {args} {
#NOTE - this algorithm based on omit_only_patterns and prune_base_patterns was suggested by a 2026 AI model - it is apparent to this programmer that it is inadequate for the purpose.
#e.g consider subfolders1 -recursion -exclude {**/vfs/** **/src/**}
#This can still return something like c:/repo/etc/src/vfs - which should be excluded by the pattern **/src/**
#todo - review and fix properly.
set argd [punk::args::parse $args withid ::punk::path::subfolders1]
lassign [dict values $argd] leaders opts values received
set do_recursion [dict exists $received -recursive]
set exclude_paths [dict get $opts -exclude-paths]
if {"**" in $exclude_paths} {
#if ** is in exclude_paths - then we can skip all glob matching and just return empty list
#This is likely user error - so we'll be loud about it for now but will still return empty list rather than erroring.
#If user code is building exclude_paths dynamically - they can check for this case themselves and avoid the call to subfolders1 to suppress this message.
puts stderr "punk::path::subfolders1 Warning - exclude_paths contains '**' - all paths will be excluded"
return [list]
}
if {[dict exists $received path]} {
set path [dict get $values path]
} else {
set path [pwd]
}
set all_subfolders [glob -nocomplain -directory $path -types d *]
#example of expected exclude_paths pattern behaviour when recursion is enabled:
# **/dirname -> omit /x/y/dirname, but still visit /x/y/dirname/*
# **/dirname/* -> include /x/y/dirname and /x/y/dirname/a/b but omit directories that are a single level below /x/y/dirname such as /x/y/dirname/a
#c:/** - would exclude all subfolders below c: but not c: itself
# **/test/** - would exclude any path with test as a segment and all its subfolders
#- but not paths with test as a segment that is the final segment
set folders [list]
set recurse_subdirs [list]
foreach f $all_subfolders {
set include_in_results 1
set allow_recurse 1
foreach pat $exclude_paths {
set pat_parts [file split $pat] ;#note file split c:/test gives {c:/ test} but file split **/test gives {** test}
#also note that file split on windows treats forward slashes and backslashes the same.
#by using file split, we gain some flexibility in syntax of paths and patterns,
#but lose the ability to use backslashes as escapes to allow literal glob characters in path segments.
#This is almost always a non-issue on windows since * and ? are not valid in path segments there, and is rarely an issue on unix even though
# * and ? are technically valid in path segments, but it is inadvisable there anyway for compatibility with shells etc.
if {[llength $pat_parts] >= 2 && [lindex $pat_parts end] eq "**"} {
set base_pat [file join {*}[lrange $pat_parts 0 end-1]]
if {[globmatchpath $pat $f]} {
set include_in_results 0
set allow_recurse 0
} elseif {[globmatchpath $base_pat $f]} {
set allow_recurse 0
}
} elseif {[globmatchpath $pat $f]} {
set include_in_results 0
}
if {!$include_in_results && !$allow_recurse} {
break
}
}
if {$include_in_results} {
lappend folders $f
}
if {$allow_recurse} {
lappend recurse_subdirs $f
}
}
if {$do_recursion} {
foreach subdir $recurse_subdirs {
lappend folders {*}[subfolders1 -exclude-paths $exclude_paths -recursive $subdir]
}
}
return $folders
}
namespace eval subfolder_priv {
proc classify_exclude_pattern {pat} {
set parts [file split $pat]
if {[llength $parts] >= 2 && [lindex $parts end] eq "**"} {
set boundary_pat [file join {*}[lrange $parts 0 end-1]]
return [dict create {*}{
} pattern $pat {*}{
} kind subtree {*}{
} boundary_pat $boundary_pat {*}{
} descend_pat $pat {*}{
}
]
}
if {[llength $parts] >= 2 && [lindex $parts end] eq "*"} {
return [dict create {*}{
} pattern $pat {*}{
} kind child_only {*}{
} match_pat $pat {*}{
}
]
}
return [dict create {*}{
} pattern $pat {*}{
} kind exact {*}{
} match_pat $pat {*}{
}
]
}
proc compile_exclude_rules {exclude_paths} {
set rules [list]
foreach pat $exclude_paths {
lappend rules [classify_exclude_pattern $pat]
}
return $rules
}
proc match_rule_at_node {rule path} {
set kind [dict get $rule kind]
switch -- $kind {
exact - child_only {
if {[::punk::path::globmatchpath [dict get $rule match_pat] $path]} {
return [dict create include_current 0 recurse_below 1 child_rules [list $rule]]
}
return [dict create include_current 1 recurse_below 1 child_rules [list $rule]]
}
subtree {
set descend_pat [dict get $rule descend_pat]
set boundary_pat [dict get $rule boundary_pat]
if {[::punk::path::globmatchpath $descend_pat $path]} {
return [dict create include_current 0 recurse_below 0 child_rules [list]]
}
if {[::punk::path::globmatchpath $boundary_pat $path]} {
return [dict create include_current 1 recurse_below 0 child_rules [list]]
}
return [dict create include_current 1 recurse_below 1 child_rules [list $rule]]
}
default {
error "Unknown exclude rule kind '$kind'"
}
}
}
proc walk_subfolders {path rules do_recursion} {
set all_subfolders [glob -nocomplain -directory $path -types d *]
set folders [list]
foreach f $all_subfolders {
set include_current 1
set recurse_below $do_recursion
set child_rules [list]
foreach rule $rules {
set outcome [match_rule_at_node $rule $f]
if {![dict get $outcome include_current]} {
set include_current 0
}
if {![dict get $outcome recurse_below]} {
set recurse_below 0
}
if {$do_recursion} {
lappend child_rules {*}[dict get $outcome child_rules]
}
if {!$include_current && !$recurse_below} {
break
}
}
if {$include_current} {
lappend folders $f
}
if {$do_recursion && $recurse_below} {
lappend folders {*}[walk_subfolders $f $child_rules $do_recursion]
}
}
return $folders
}
}
punk::args::define {
@id -id ::punk::path::subfolders
@cmd -name punk::path::subfolders\
-summary\
"Listing of directories below supplied path."\
-help\
"List of folders below path.
The resulting list is unsorted.
"
@opts
-recursive -type none -help\
""
-exclude-paths -type list -default {} -help\
"list of path patterns to exclude from results.
May include * and ** path segments e.g /usr/**
A single /*/ will match any single segment in the path, and a single /**/ will match any number of segments in the path.
e.g to exclude any path with _aside as a segment in the middle: -exclude-paths **/_aside/**
i.e this would exclude /usr/_aside/etc and /usr/x/_aside/etc but not /usr/x/_aside or _aside/etc
To exclude all paths with _aside as a segment anywhere: -exclude-paths { **/_aside/** **/_aside ./_aside/**}
"
#todo -depth
@values -min 0 -max 1
path -type directory -optional 1 -help\
"Path of base folder. If not supplied current directory is used.
This may be a relative or absolute path. Relative paths are treated as relative to current directory.
When using relative paths - the result will also be relative paths with the same relative prefix.
(e.g if path is ../test - the results will be ../test/subfolder1 ../test/subfolder2 etc)
Patterns in -exclude-paths are matched against the resulting paths
(so should be written to match the same relative prefix if path is relative)"
}
proc subfolders {args} {
set argd [punk::args::parse $args withid ::punk::path::subfolders]
lassign [dict values $argd] leaders opts values received
set do_recursion [dict exists $received -recursive]
set exclude_paths [dict get $opts -exclude-paths]
if {"**" in $exclude_paths} {
puts stderr "punk::path::subfolders Warning - exclude_paths contains '**' - all paths will be excluded"
return [list]
}
if {[dict exists $received path]} {
set path [dict get $values path]
} else {
set path [pwd]
}
set compiled_rules [subfolder_priv::compile_exclude_rules $exclude_paths]
return [subfolder_priv::walk_subfolders $path $compiled_rules $do_recursion]
}
namespace eval treefile_priv {
proc _path_segment_matches {pattern_segment path_segment} {
if {[::punk::path::globmatchpath $pattern_segment $path_segment]} {
return 1
}
if {[string range $pattern_segment end end] eq "/" && [string range $path_segment end end] eq "/"} {
set root_probe __punk_path_root_probe__
return [::punk::path::globmatchpath [file join $pattern_segment $root_probe] [file join $path_segment $root_probe]]
}
return 0
}
proc _pattern_prefix_viable_parts {pattern_parts path_parts} {
if {![llength $path_parts]} {
return 1
}
if {![llength $pattern_parts]} {
return 0
}
set pattern_head [lindex $pattern_parts 0]
set path_head [lindex $path_parts 0]
if {$pattern_head eq "**"} {
if {[_pattern_prefix_viable_parts [lrange $pattern_parts 1 end] $path_parts]} {
return 1
}
return [_pattern_prefix_viable_parts $pattern_parts [lrange $path_parts 1 end]]
}
if {[_path_segment_matches $pattern_head $path_head]} {
return [_pattern_prefix_viable_parts [lrange $pattern_parts 1 end] [lrange $path_parts 1 end]]
}
return 0
}
proc pattern_prefix_viable {pattern path} {
return [_pattern_prefix_viable_parts [file split $pattern] [file split $path]]
}
proc pattern_boundary {pattern} {
set parts [file split $pattern]
if {[llength $parts] >= 2 && [lindex $parts end] eq "**"} {
return [file join {*}[lrange $parts 0 end-1]]
}
return ""
}
proc directory_state {glob_paths path inherited_allbelow} {
if {$inherited_allbelow} {
return [dict create include_files 1 recurse_below 1 next_allbelow 1]
}
set include_files 0
set recurse_below 0
set next_allbelow 0
foreach gp $glob_paths {
if {[::punk::path::globmatchpath $gp $path]} {
set include_files 1
set recurse_below 1
set next_allbelow 1
break
}
set boundary [pattern_boundary $gp]
if {$boundary ne "" && [::punk::path::globmatchpath $boundary $path]} {
set recurse_below 1
set next_allbelow 1
continue
}
if {[pattern_prefix_viable $gp $path]} {
set recurse_below 1
}
}
return [dict create {*}{
} include_files $include_files {*}{
} recurse_below $recurse_below {*}{
} next_allbelow $next_allbelow {*}{
}
]
}
proc child_path_state {glob_paths child_path inherited_allbelow} {
if {$inherited_allbelow} {
return 1
}
foreach gp $glob_paths {
if {[pattern_prefix_viable $gp $child_path]} {
return 1
}
}
return 0
}
proc _sort_paths {paths sortmode} {
switch -- $sortmode {
ascii {
return [lsort $paths]
}
dictionary {
return [lsort -dictionary $paths]
}
natural {
return [natsort::sort $paths]
}
default {
return $paths
}
}
}
proc _path_matches_any {patterns path} {
foreach pattern $patterns {
if {[::punk::path::globmatchpath $pattern $path]} {
return 1
}
}
return 0
}
proc _tailbase_relative {tailbase path} {
if {$tailbase eq ""} {
return $path
}
return [::punk::path::relative $tailbase $path]
}
proc _tailbase_match_path {tailbase path} {
set match_path [_tailbase_relative $tailbase $path]
if {$match_path eq "."} {
return ""
}
return $match_path
}
proc _tailbase_relative_list {tailbase paths} {
if {$tailbase eq ""} {
return $paths
}
set relative_paths [list]
foreach path $paths {
lappend relative_paths [_tailbase_relative $tailbase $path]
}
return $relative_paths
}
proc _retain_files {matches exclude_files sortmode} {
set retained [list]
foreach match $matches {
set skip 0
set file_tail [file tail $match]
foreach anti $exclude_files {
if {[string match $anti $file_tail]} {
set skip 1
break
}
}
if {!$skip} {
lappend retained $match
}
}
return [_sort_paths $retained $sortmode]
}
proc _state_from_argd {argd} {
set opts [dict get $argd opts]
set values [dict get $argd values]
set received [dict get $argd received]
if {[dict exists $received -directory]} {
set directory [dict get $opts -directory]
} else {
set directory [pwd]
}
set glob_paths [dict get $opts -include-paths]
if {"*" in $glob_paths} {
set glob_paths {*}
}
set sortmode [dict get $opts -sort]
if {$sortmode eq "natural"} {
package require natsort
}
return [dict create {*}{
depth 0
subvector {}
allbelow 0
} sort $sortmode {*}{
} directory $directory {*}{
} tailbase [dict get $opts -tailbase] {*}{
} exclude_paths [dict get $opts -exclude-paths] {*}{
} exclude_files [dict get $opts -exclude-files] {*}{
} glob_paths $glob_paths {*}{
} tailglobs [dict get $values tailglobs] {*}{
}
]
}
proc walk_treefilenames {state} {
set opt_dir [dict get $state directory]
set opt_tailbase [dict get $state tailbase]
set depth [dict get $state depth]
set subvector [dict get $state subvector]
set callallbelow [dict get $state allbelow]
set opt_sort [dict get $state sort]
set opt_exclude_paths [dict get $state exclude_paths]
set opt_exclude_files [dict get $state exclude_files]
set opt_glob_paths [dict get $state glob_paths]
set tailglobs [dict get $state tailglobs]
if {![file isdirectory $opt_dir]} {
return [list]
}
if {[string match //zipfs:/* $opt_dir]} {
return [walk_treefilenames_zipfs $state]
}
set opt_dir_match [_tailbase_match_path $opt_tailbase $opt_dir]
if {[_path_matches_any $opt_exclude_paths $opt_dir_match]} {
return [list]
}
set files [list]
set dir_state [directory_state $opt_glob_paths $opt_dir_match $callallbelow]
if {[dict get $dir_state include_files]} {
if {[catch {glob -nocomplain -dir $opt_dir -type f -- {*}$tailglobs} matches]} {
puts stderr "treefilenames error while listing files in dir $opt_dir\n $matches"
set dirfiles [list]
} else {
set dirfiles [_retain_files $matches $opt_exclude_files $opt_sort]
}
lappend files {*}[_tailbase_relative_list $opt_tailbase $dirfiles]
}
if {![dict get $dir_state recurse_below]} {
return $files
}
if {[catch {glob -nocomplain -dir $opt_dir -type d *} dirdirs]} {
puts stderr "treefilenames error while listing subdirs in dir $opt_dir\n $dirdirs"
set dirdirs [list]
}
set okdirs [list]
foreach dir $dirdirs {
if {![_path_matches_any $opt_exclude_paths [_tailbase_match_path $opt_tailbase $dir]]} {
lappend okdirs $dir
}
}
if {$opt_glob_paths eq "*"} {
set matchdirs $okdirs
} else {
set matchdirs [list]
foreach dir $okdirs {
if {$callallbelow || [child_path_state $opt_glob_paths [_tailbase_match_path $opt_tailbase $dir] $callallbelow]} {
lappend matchdirs $dir
}
}
}
set finaldirs [_sort_paths $matchdirs $opt_sort]
set childallbelow [expr {$callallbelow || [dict get $dir_state next_allbelow]}]
set nextsubvector [list {*}$subvector [file tail $opt_dir]]
foreach dir $finaldirs {
set child_state [dict merge $state [dict create {*}{
} directory $dir {*}{
} depth [expr {$depth + 1}] {*}{
} subvector $nextsubvector {*}{
} allbelow $childallbelow {*}{
}
]]
lappend files {*}[walk_treefilenames $child_state]
}
return $files
}
proc walk_treefilenames_zipfs {state} {
set opt_dir [dict get $state directory]
set opt_tailbase [dict get $state tailbase]
set opt_exclude_paths [dict get $state exclude_paths]
set opt_exclude_files [dict get $state exclude_files]
set opt_glob_paths [dict get $state glob_paths]
set opt_sort [dict get $state sort]
set tailglobs [dict get $state tailglobs]
if {![string match [zipfs root]* $opt_dir]} {
error "treefilenames_zipfs can only be used on paths beginning with [zipfs root] on this systems"
}
set dir [string trimright $opt_dir "/"]
if {[_path_matches_any $opt_exclude_paths [_tailbase_match_path $opt_tailbase $dir]]} {
return [list]
}
set dirlen [string length $dir]
set subpaths [zipfs list $dir/*]
set dirlist [list]
set skipdirs [list]
set filelist [list]
foreach sub $subpaths {
set tail [string range $sub $dirlen+1 end]
set tailparts [file split $tail]
set accum ""
set skipdir 0
foreach tailpart [lrange $tailparts 0 end-1] {
append accum "/$tailpart"
set superpath "${dir}${accum}"
if {$superpath in $skipdirs} {
set skipdir 1
break
}
if {$superpath ni $dirlist} {
if {[_path_matches_any $opt_exclude_paths [_tailbase_match_path $opt_tailbase $superpath]]} {
lappend skipdirs $superpath
set skipdir 1
break
} else {
lappend dirlist $superpath
}
}
}
if {!$skipdir} {
append accum "/[lindex $tailparts end]"
set finalpart "${dir}${accum}"
if {$finalpart ni $dirlist} {
if {[file type $finalpart] eq "file"} {
set file_tail [lindex $tailparts end]
set match 0
if {"*" ni $tailglobs} {
foreach tailglob $tailglobs {
if {[string match $tailglob $file_tail]} {
set match 1
break
}
}
} else {
set match 1
}
if {$match && $opt_glob_paths ne "*"} {
set file_dir_match [_tailbase_match_path $opt_tailbase [file dirname $finalpart]]
set file_dir_state [directory_state $opt_glob_paths $file_dir_match 0]
set match [dict get $file_dir_state include_files]
}
if {$match} {
set skipfile 0
foreach anti $opt_exclude_files {
if {[string match $anti $file_tail]} {
set skipfile 1
break
}
}
if {!$skipfile} {
lappend filelist [_tailbase_relative $opt_tailbase $finalpart]
}
}
} else {
if {$finalpart ni $dirlist} {
if {[_path_matches_any $opt_exclude_paths [_tailbase_match_path $opt_tailbase $finalpart]]} {
lappend skipdirs $finalpart
} else {
lappend dirlist $finalpart
}
}
}
}
}
}
return [_sort_paths $filelist $opt_sort]
}
}
#todo - treefolders with similar search caps as treefilenames
punk::args::define {
@id -id ::punk::path::treefilenames
@cmd -name punk::path::treefilenames\
-summary\
"List of filenames below supplied path."\
-help\
"List of filenames below path.
The resulting list is unsorted.
The path globbing syntax supports *, ** and ? as glob characters in any segment of the path, with the following semantics:
* matches any single segment in the path
** matches 1 or more segments in the path (so /usr/**/bin will match /usr/x/bin and user/x/y/bin but not /usr/bin )
? matches any single character in a single segment of the path (so /usr/te?t will match /usr/test and /usr/text but not /usr/texxt)
"
-directory -type directory -help\
"folder in which to begin recursive scan for files."
-tailbase -type string -default "" -help\
"if supplied, only the relative path compared to the tailbase will be returned for each file.
So if tailbase is /usr and a file is found at /usr/x/y/file.txt, the returned path for that file would be x/y/file.txt.
If tailbase is not supplied, the full path to each file will be returned.
If tailbase is supplied, it should be a prefix of the directory supplied (or the directory itself)
The patterns in -exclude-paths should be written to match the returned paths (i.e with the tailbase prefix removed) if -tailbase is supplied.
If the tailbase is not a prefix of the directory supplied, the resulting paths may have /../ components in them to account for the difference,
but the behaviour is not well defined in this case and it is recommended to ensure tailbase is a prefix of the directory supplied if using -tailbase.
see: punk::path::relative to compute relative paths
"
-sort -type any -default natural -choices {none ascii dictionary natural}
-exclude-paths -default {} -help\
"list of path patterns to exclude
may include * and ** path segments e.g
/usr/** (exclude subfolders based at /usr but not
files within /usr itself)
**/_aside (exclude files where _aside is last segment)
**/_aside/* (exclude folders one below an _aside folder)
**/_aside/** (exclude files in all folders with _aside as a segment)"
-exclude-files -default {}
-include-paths -default {**} -help\
"list of path patterns to include
may include * and ** path segments e.g
/usr/** (include files in subfolders based at /usr but not
files within /usr itself)
**/_aside (include files where _aside is last segment in the folder)
**/_aside/* (include files in folders one below an _aside folder)
**/_aside/** (include all files in folders with _aside as a segment)"
@values -min 0 -max -1 -optional 1 -type string
tailglobs -default * -multiple 1 -help\
"Patterns to match against filename portion (last segment) of each file path
within the directory tree being searched."
}
#todo - implement treefiles which acts like dirfiles but allows path globbing in the same way as punk::ns::ns/
#then review if treefiles can replace dirfiles or if both should exist (dirfiles can have literal glob chars in path segments - but that is a rare usecase)
proc treefilenames {args} {
set argd [punk::args::parse $args withid ::punk::path::treefilenames]
set state [treefile_priv::_state_from_argd $argd]
return [treefile_priv::walk_treefilenames $state]
}
punk::args::set_idalias ::punk::path::treefilenames_zipfs ::punk::path::treefilenames
proc treefilenames_zipfs {args} {
#seems to be 2 or 3 times faster than treefilenames for //zipfs:/ paths - REVIEW
# is sort order the same?
set argd [punk::args::parse $args withid ::punk::path::treefilenames]
set state [treefile_priv::_state_from_argd $argd]
if {![file isdirectory [dict get $state directory]]} {
return [list]
}
return [treefile_priv::walk_treefilenames_zipfs $state]
}
punk::args::define {
@id -id ::punk::path::relative
@cmd -name punk::path::relative\
-summary\
"Compute the relative path from a reference path to a location path."\
-help\
"Taking two directory paths, a reference and a location, computes the path
of the location relative to the reference.
Will return a single dot '.' if the paths are the same.
Both paths must be the same type - ie both absolute or both relative.
Matching is case sensitive. On windows, the drive-letter component (only) is
not case sensitive, so punk::path::relative c:/etc C:/etc returns '.'.
The part following the driveletter is case sensitive, so
punk::path::relative c:/etc C:/Etc returns ../Etc.
On windows, if the paths are absolute and specify different volumes,
only the location will be returned."
@leaders -min 2 -max 2
reference -type string -help\
"The path from which the relative path to location is determined."
location -type string -help\
"The location path which may be above or below the reference path."
}
#maint warning - also in punkcheck
proc relative {reference location} {
#see also kettle
# Modified copy of ::fileutil::relative (tcllib)
# Adapted to 8.5 ({*}).
#review - check volume info on windows.. UNC paths?
if {[file pathtype $reference] ne [file pathtype $location]} {
return -code error "Unable to compute relation for paths of different pathtypes: [file pathtype $reference] vs. [file pathtype $location], ($reference vs. $location)"
}
#avoid normalizing if possible (file normalize *very* expensive on windows)
set do_normalize 0
if {[file pathtype $reference] eq "relative"} {
#if reference is relative so is location
if {[regexp {[.]{2}} [list $reference $location]]} {
set do_normalize 1
}
if {[regexp {[.]/} [list $reference $location]]} {
set do_normalize 1
}
} else {
set do_normalize 1
}
if {$do_normalize} {
set reference [file normalize $reference]
set location [file normalize $location]
}
set save $location
set reference [file split $reference]
set location [file split $location]
while {[lindex $location 0] eq [lindex $reference 0]} {
set location [lrange $location 1 end]
set reference [lrange $reference 1 end]
if {![llength $location]} {break}
}
set location_len [llength $location]
set reference_len [llength $reference]
if {($location_len == 0) && ($reference_len == 0)} {
# Cases:
# (a) reference == location
set location .
} else {
# Cases:
# (b) ref is: ref/sub = sub
# loc is: ref = {}
# (c) ref is: ref = {}
# loc is: ref/sub = sub
while {$reference_len > 0} {
#set location [linsert $location 0 ..]
ledit location -1 -1 ..
incr reference_len -1
}
set location [file join {*}$location]
}
return $location
}
#*** !doctools
#[list_end] [comment {--- end definitions namespace punk::path ---}]
}
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
# Secondary API namespace
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
namespace eval punk::path::lib {
namespace export *
namespace path [namespace parent]
#*** !doctools
#[subsection {Namespace punk::path::lib}]
#[para] Secondary functions that are part of the API
#[list_begin definitions]
#*** !doctools
#[list_end] [comment {--- end definitions namespace punk::path::lib ---}]
}
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
#*** !doctools
#[section Internal]
namespace eval punk::path::system {
#*** !doctools
#[subsection {Namespace punk::path::system}]
#[para] Internal functions that are not part of the API
}
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
## Ready
package provide punk::path [namespace eval punk::path {
variable pkg punk::path
variable version
set version 0.1.0
}]
return
#*** !doctools
#[manpage_end]