Skip to content
VLSI Mentor

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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]"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
dirname   : reports/run1
tail      : summary.csv
rootname  : reports/run1/summary
extension : .csv
  • dirname — everything before the last separator
  • tail — everything after it
  • extension — the final dot and what follows, including the dot
  • rootname — 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
puts "bare name : [file rootname [file tail $p]]"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
bare name : summary

And join puts parts back together with the right separator:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
puts [file join reports run1 summary.csv]
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
reports/run1/summary.csv

2. Why not string surgery

Here is the case the registry names. A versioned directory:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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}]]"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
file rootname : reports/v1.2/summary
string first  : reports/v1

The 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
set r "reports/v1.2/README"

puts "file extension : '[file extension $r]'"
puts "string last    : '[string range $r [string last "." $r] end]'"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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]]
}
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
puts [file rootname [file rootname a.tar.gz]]
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
a

4. 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
puts [file join reports summary.csv]
puts [file join reports /etc/passwd]
puts [file join a b /c d]
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
reports/summary.csv
/etc/passwd
/c/d

The 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
puts "'[file join reports ""]'"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
'reports'

5. Building output names

The common job, done properly:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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]"
}
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  data/run1/raw.csv
    -> out/raw.processed.csv
  reports/v1.2/summary.csv
    -> out/summary.processed.csv
  notes.txt
    -> out/notes.processed.csv

file 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
set parts [file split "reports/run1/summary.csv"]
puts "split : $parts  ([llength $parts] parts)"
puts "join  : [file join {*}$parts]"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
split : reports run1 summary.csv  (3 parts)
join  : reports/run1/summary.csv

The {*} 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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]
}
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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]
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
logs/run.log

9. Remember

  • dirname is everything before the last separator; tail is everything after.
  • extension includes the dot; rootname strips 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: .bashrc has rootname "". Guard against an empty base name.
  • a.tar.gz has extension .gz — only the last part.
  • file join discards everything before an absolute component. Check file pathtype when a component is not yours.
  • file join and file split are inverses and use the platform's separator, so prefer them to "$dir/$name".

Where this fits

Part of the Tcl curriculum.