Tcl Part I · Module 12 · Files, Directories, and Paths
Paths: dirname, tail, rootname, extension, join
A path is a string, so it is tempting to take one apart with the string commands from Module 5 — find the last slash, find the dot, slice between them. That works on the paths you tested with and breaks on a directory whose name contains a dot, which is to say on any versioned output directory anyone has ever created. Tcl has five path subcommands that get it right, and this chapter covers them along with three behaviours that surprise people: a dotfile that is entirely extension, an archive whose extension is only its last part, and a join that can silently throw away everything you gave it.
Foundation11 min readTclPart IFilesPathsfile join
Part I · Module 12 · Chapter 12.10 · Paths: dirname, tail, rootname, extension, join
1. The five commands
set p "reports/run1/summary.csv"
puts "dirname : [file dirname $p]"
puts "tail : [file tail $p]"
puts "rootname : [file rootname $p]"
puts "extension : [file extension $p]"dirname : reports/run1
tail : summary.csv
rootname : reports/run1/summary
extension : .csvdirname— everything before the last separatortail— everything after itextension— the final dot and what follows, including the dotrootname— the path with the extension removed, keeping the directories
That last one catches people: rootname does not give you the bare name. Combine it with tail when that is what you want:
puts "bare name : [file rootname [file tail $p]]"bare name : summaryAnd join puts parts back together with the right separator:
puts [file join reports run1 summary.csv]reports/run1/summary.csv2. Why not string surgery
Here is the case the registry names. A versioned directory:
set q "reports/v1.2/summary.csv"
puts "file rootname : [file rootname $q]"
puts "string first : [string range $q 0 [expr {[string first "." $q] - 1}]]"file rootname : reports/v1.2/summary
string first : reports/v1The string version found the first dot, which is in the directory name, and truncated the path there. The result is not merely wrong — it is a plausible-looking path that does not exist.
Using string last instead fixes this particular case and fails on a different one:
set r "reports/v1.2/README"
puts "file extension : '[file extension $r]'"
puts "string last : '[string range $r [string last "." $r] end]'"file extension : ''
string last : '.2/README'A file with no extension has no dot after the last separator, so string last reaches back into the directory name again. file extension knows where the separator is and correctly reports nothing.
3. A dotfile is all extension
foreach name {README .bashrc a.tar.gz archive.tar notes.txt} {
puts [format " %-12s root='%s' ext='%s'" \
$name [file rootname $name] [file extension $name]]
} README root='README' ext=''
.bashrc root='' ext='.bashrc'
a.tar.gz root='a.tar' ext='.gz'
archive.tar root='archive' ext='.tar'
notes.txt root='notes' ext='.txt'Two rows deserve attention.
.bashrc is entirely extension. Its rootname is the empty string. Tcl treats the leading dot as an extension separator, so any code that builds an output name from [file rootname $name] produces an empty name for a dotfile.
a.tar.gz has extension .gz, not .tar.gz. The extension is the last dot onwards. Stripping it twice gets you a:
puts [file rootname [file rootname a.tar.gz]]a4. file join and the absolute component
file join is not string concatenation. If any component after the first is absolute, everything before it is discarded:
puts [file join reports summary.csv]
puts [file join reports /etc/passwd]
puts [file join a b /c d]reports/summary.csv
/etc/passwd
/c/dThe second line is the one to remember. file join reports /etc/passwd is /etc/passwd — the reports prefix is gone.
An empty component contributes nothing:
puts "'[file join reports ""]'"'reports'5. Building output names
The common job, done properly:
proc output_for {input outdir suffix} {
set base [file rootname [file tail $input]]
if {$base eq ""} { error "cannot derive a name from: $input" }
return [file join $outdir "$base$suffix"]
}
foreach in {data/run1/raw.csv reports/v1.2/summary.csv notes.txt} {
puts " $in"
puts " -> [output_for $in out .processed.csv]"
} data/run1/raw.csv
-> out/raw.processed.csv
reports/v1.2/summary.csv
-> out/summary.processed.csv
notes.txt
-> out/notes.processed.csvfile tail first, then rootname — in that order, so the directory's dots cannot interfere. The v1.2 directory is handled correctly without any special case.
6. Separators and portability
file join uses the platform's separator, and file split is its inverse:
set parts [file split "reports/run1/summary.csv"]
puts "split : $parts ([llength $parts] parts)"
puts "join : [file join {*}$parts]"split : reports run1 summary.csv (3 parts)
join : reports/run1/summary.csvThe {*} is Chapter 6.13's expansion operator, turning the list back into separate arguments.
Because join and split know the platform's convention, a script that builds paths this way works unchanged on Windows, where the separator differs. A script that writes "$dir/$name" does not.
7. A worked example
proc describe_path {p} {
return [format "%-28s dir=%-16s tail=%-14s root=%-12s ext=%s" \
$p [file dirname $p] [file tail $p] \
[file rootname [file tail $p]] "'[file extension $p]'"]
}
foreach p {
reports/run1/summary.csv
reports/v1.2/summary.csv
data/archive.tar.gz
config/.bashrc
README
} {
puts [describe_path $p]
}reports/run1/summary.csv dir=reports/run1 tail=summary.csv root=summary ext='.csv'
reports/v1.2/summary.csv dir=reports/v1.2 tail=summary.csv root=summary ext='.csv'
data/archive.tar.gz dir=data tail=archive.tar.gz root=archive.tar ext='.gz'
config/.bashrc dir=config tail=.bashrc root= ext='.bashrc'
README dir=. tail=README root=README ext=''Five paths, five correct decompositions, including the versioned directory, the double extension, the dotfile with its empty rootname, and the bare name whose dirname is ..
8. Exercises
Exercise 1. For reports/run1/summary.csv, give dirname, tail, rootname and extension.
Exercise 2. How do you get just summary from that path?
Exercise 3. Why does string range $p 0 [expr {[string first "." $p] - 1}] fail on reports/v1.2/summary.csv?
Exercise 4. What are file rootname and file extension of .bashrc, and why is that a problem when building output names?
Exercise 5. What does file join out /tmp/x.csv return, and when is that dangerous?
Exercise 6. Write output_for taking an input path and an output directory and returning the output path with a .log extension.
Answers
1. reports/run1, summary.csv, reports/run1/summary and .csv. Note rootname keeps the directories.
2. file rootname [file tail $p] — take the tail first, then strip the extension.
3. Because string first finds the first dot in the whole string, which is inside the directory name v1.2. The result is reports/v1, a path that does not exist.
4. The rootname is the empty string and the extension is .bashrc — Tcl treats the leading dot as an extension separator. Building a name as "[file rootname $n].out" then yields .out, a hidden file rather than the intended one, so check for an empty rootname first.
5. /tmp/x.csv — the out prefix is discarded because the second component is absolute. It is dangerous whenever that component came from outside the script, since a user-supplied name beginning with a separator escapes your output directory. Check file pathtype first.
6.
proc output_for {input outdir} {
set base [file rootname [file tail $input]]
if {$base eq ""} { error "cannot derive a name from: $input" }
return [file join $outdir "$base.log"]
}
puts [output_for data/v2.1/run.csv logs]logs/run.log9. Remember
dirnameis everything before the last separator;tailis everything after.extensionincludes the dot;rootnamestrips it but keeps the directories.file rootname [file tail $p]is how you get a bare base name.- Never take a path apart with string commands — every recipe fails on a dot in a directory name, a file with no extension, or a dotfile.
- A dotfile is entirely extension:
.bashrchas rootname"". Guard against an empty base name. a.tar.gzhas extension.gz— only the last part.file joindiscards everything before an absolute component. Checkfile pathtypewhen a component is not yours.file joinandfile splitare inverses and use the platform's separator, so prefer them to"$dir/$name".
Where this fits
Part of the Tcl curriculum.
