Skip to content

Commit 82604c1

Browse files
author
rwh
committed
reaching a milestone for unix_path.rb
1 parent e42b0ad commit 82604c1

File tree

2 files changed

+255
-36
lines changed

2 files changed

+255
-36
lines changed

lib/compsci/unix_path.rb

Lines changed: 92 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,98 @@
11
module CompSci
22

3+
# This is a simplification of common Unix paths, as used in Linux, OS X, etc.
4+
# It is intended to cover 99.9% of the "good use cases" and "best practices"
5+
# for path and filename conventions on Unix filesystems.
6+
7+
# Some primary distinctions:
8+
# 1. Absolute vs Relative paths
9+
# 2. File vs directory
10+
11+
# Absolute paths are indicated with a leading slash (/)
12+
# Relative paths are primarily indicated with a leading dot-slash (./)
13+
# or they may omit the leading ./ for brevity.
14+
15+
# In string form, directories are indicated with a trailing slash (/).
16+
# With no trailing slash, the last segment of a path is treated as a filename.
17+
# Internally, there is a @filename string, and if it is empty, this has
18+
# semantic meaning: the UnixPath is a directory.
19+
20+
# For nonempty @filename, it may be further broken down into basename
21+
# and extension, with an overly simple rule:
22+
#
23+
# Look for the last dot.
24+
#
25+
# Everything up to the last dot is basename. Possibly the entire filename.
26+
# Possibly empty.
27+
#
28+
# Everything after the last dot is extension, including the last dot.
29+
# Possibly the entire filename. Possibly empty.
30+
#
31+
# Concatenating basename and extension will always perfectly reconstruct
32+
# the filename.
33+
#
34+
# If @filename is empty, basename and extension are nil.
35+
36+
# Internal Representation
37+
# * abs: boolean indicating abspath vs relpath
38+
# * subdirs: array of strings, like %w[home user docs]
39+
# * filename: string, possibly empty
40+
41+
# Consume a string path with UnixPath.parse, creating a UnixPath instance.
42+
# Emit a string with UnixPath#to_s.
43+
# Relative paths are emitted with leading ./
44+
45+
# Combine path components with `slash`, aliased to /, yielding a UnixPath
46+
# * UnixPath.parse('/etc') / 'systemd' / 'system'
47+
# * UnixPath.parse('/etc').slash('passwd')
48+
# * UnixPath.parse('./') / 'a.out'
49+
#
50+
# Or just parse the full string:
51+
# * UnixPath.parse('/etc/systemd/system')
52+
#
53+
# Or construct by hand:
54+
# * UnixPath.new(abs: true, subdirs: %w[etc systemd system])
55+
56+
# Most of UnixPath is implemented via PathMixin and its FactoryMethods
57+
# These modules are mixed in to base classes ImmutablePath and MutablePath.
58+
# ImmutablePath is based on Ruby's Data.define, using Data.with for efficient
59+
# copies.
60+
# MutablePath may be more efficient and has a simple implementation.
61+
# UnixPath is itself a constant, currently referring to ImmutablePath.
62+
# This pattern of assigning a constant can allow you to create your own Path
63+
# in your own context. e.g. MyPath = CompSci::MutablePath
64+
65+
# Note that these are logical paths and have no connection to any filesystem.
66+
# This library never looks at the local filesystem that it runs on.
67+
368
# ImmutablePath and MutablePath both mix in this module via include
469
# N.B. @ivar references will not work with Data.define, so use self.ivar
570
module PathMixin
671
include Comparable
772

8-
class Error < RuntimeError; end
9-
class FilenameError < Error; end
10-
class SlashError < Error; end
73+
class FilenameError < RuntimeError; end
1174

1275
SEP = '/'
1376
DOT = '.'
1477
CWD = DOT + SEP
1578

16-
# must be a String != '.'
79+
# must be a String != DOT
1780
def self.valid_filename!(val)
1881
if !val.is_a?(String) or val == DOT
1982
raise(FilenameError, "illegal value: #{val.inspect}")
2083
end
2184
val
2285
end
2386

87+
#
88+
# module FactoryMethods: create new Paths
89+
#
90+
2491
# ImmutablePath and MutablePath both mix in this module via extend
2592
# This ensures self.new refers to the base class and not the mixin (module)
2693
# This extension happens automatically via an included hook on PathMixin
2794
# PathMixin#slash calls FactoryMethods#parse
95+
#
2896
module FactoryMethods
2997
# return a new Path from a string
3098
def parse(path_str)
@@ -54,6 +122,10 @@ def directory(path)
54122
end
55123
end
56124

125+
#
126+
# module PathMixin: provides most of the Path instance behavior
127+
#
128+
57129
# the base class automatically gets FactoryMethods at the "class layer"
58130
def self.included(base) = base.extend(FactoryMethods)
59131

@@ -91,7 +163,6 @@ def <=>(other)
91163
# Enable path building with slash method
92164
# !!! base class must implement Klass#slash_path !!!
93165
def slash(other)
94-
raise(SlashError, "can only slash on dirs") unless self.dir?
95166
case other
96167
when String
97168
other.empty? ? self : self.slash_path(self.class.parse(other))
@@ -121,6 +192,11 @@ def extension
121192
def reconstruct_filename = [self.basename, self.extension].join
122193
end
123194

195+
196+
#
197+
# ImmutablePath (Data)
198+
#
199+
124200
class ImmutablePath < Data.define(:abs, :subdirs, :filename)
125201
include PathMixin # also extends PathMixin::FactoryMethods
126202

@@ -131,8 +207,11 @@ def initialize(**kwargs)
131207

132208
# rely on Data#with for efficient copying
133209
def slash_path(other)
134-
raise(SlashError, "can't slash an absolute path") if other.abs?
135-
self.with(subdirs: subdirs + other.subdirs, filename: other.filename)
210+
# nonempty filename is now a subdir
211+
dirs = self.subdirs
212+
dirs << self.filename unless self.filename.empty?
213+
dirs += other.subdirs
214+
self.with(subdirs: dirs, filename: other.filename)
136215
end
137216
end
138217

@@ -154,13 +233,16 @@ def filename=(val)
154233

155234
# simple update
156235
def slash_path(other)
157-
raise(SlashError, "can't slash an absolute path") if other.abs?
236+
@subdirs << @filename unless @filename.empty?
158237
@subdirs += other.subdirs
159238
@filename = other.filename
160239
self
161240
end
162241
end
163242

164-
# UnixPath = ImmutablePath
165-
UnixPath = MutablePath
243+
# you can do this too, in your own context:
244+
# MyUnixPath = CompSci::MutablePath
245+
246+
UnixPath = ImmutablePath
247+
# UnixPath = MutablePath
166248
end

test/unix_path.rb

Lines changed: 163 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,89 @@
11
require 'minitest/autorun'
2-
require 'compsci/unix_path_immutable'
2+
require 'compsci/unix_path'
33

44
include CompSci
55

6-
CASES = {
7-
'/' => [true, [], ''],
8-
'/home/' => [true, %w[home], ''],
9-
'/home/user/documents' => [true, %w[home user], 'documents'],
10-
'/home/user/documents/' => [true, %w[home user documents], ''],
11-
'/home/user/file.txt' => [true, %w[home user], 'file.txt'],
12-
'relative_dir/' => [false, %w[relative_dir], ''],
13-
'relative_file' => [false, [], 'relative_file'],
14-
'relative_dir/file.txt' => [false, %w[relative_dir], 'file.txt'],
15-
'relative_dir/file.txt/' => [false, %w[relative_dir file.txt], ''],
16-
'file.txt' => [false, [], 'file.txt'],
17-
'.bashrc' => [false, [], '.bashrc'],
18-
'/home/user/.bashrc' => [true, %w[home user], '.bashrc'],
19-
'/home/user/.config/' => [true, %w[home user .config], ''],
20-
'./file.txt' => [false, [], 'file.txt'],
21-
'./.emacs' => [false, [], '.emacs'],
22-
'./././././file.txt' => [false, [], 'file.txt'],
23-
'/././etc/passwd' => [true, %w[etc], 'passwd'],
24-
}
6+
# Helper method to run parsing tests
7+
def assert_parsing(cases)
8+
cases.each { |str, (abs, subdirs, filename)|
9+
path = UnixPath.parse(str)
10+
expect(path.abs).must_equal abs
11+
expect(path.subdirs).must_equal subdirs
12+
expect(path.filename).must_equal filename
13+
}
14+
end
15+
16+
def assert_filenames(cases)
17+
cases.each do |filename, (base, ext)|
18+
path = UnixPath.parse(filename)
19+
expect(path.basename).must_equal base
20+
expect(path.extension).must_equal ext
21+
end
22+
end
2523

2624
describe UnixPath do
27-
it "parses the most common well-formed cases properly" do
28-
CASES.each { |str, tuple|
29-
path = UnixPath.parse(str)
30-
expect(path.abs).must_equal tuple[0]
31-
expect(path.subdirs).must_equal tuple[1]
32-
expect(path.filename).must_equal tuple[2]
25+
it "parses absolute paths with leading slash" do
26+
cases = {
27+
'/' => [true, [], ''],
28+
'/home/' => [true, %w[home], ''],
29+
'/home/user/documents' => [true, %w[home user], 'documents'],
30+
'/home/user/documents/' => [true, %w[home user documents], ''],
31+
'/home/user/file.txt' => [true, %w[home user], 'file.txt'],
32+
'/home/user/.bashrc' => [true, %w[home user], '.bashrc'],
33+
'/home/user/.config/' => [true, %w[home user .config], ''],
34+
'/././etc/passwd' => [true, %w[etc], 'passwd'],
35+
}
36+
assert_parsing(cases)
37+
end
38+
39+
it "parses relative paths with leading dot-slash" do
40+
cases = {
41+
'./file.txt' => [false, [], 'file.txt'],
42+
'./.emacs' => [false, [], '.emacs'],
43+
'./././././file.txt' => [false, [], 'file.txt'],
44+
}
45+
assert_parsing(cases)
46+
end
47+
48+
it "parses relative paths with no leading slash" do
49+
cases = {
50+
'relative_dir/' => [false, %w[relative_dir], ''],
51+
'relative_file' => [false, [], 'relative_file'],
52+
'relative_dir/file.txt' => [false, %w[relative_dir], 'file.txt'],
53+
'relative_dir/file.txt/' => [false, %w[relative_dir file.txt], ''],
54+
'file.txt' => [false, [], 'file.txt'],
55+
'.bashrc' => [false, [], '.bashrc'],
3356
}
57+
assert_parsing(cases)
58+
end
59+
60+
it "parses directories with trailing slash; filename is empty" do
61+
cases = {
62+
'/' => [true, [], ''],
63+
'/home/' => [true, %w[home], ''],
64+
'/home/user/documents/' => [true, %w[home user documents], ''],
65+
'relative_dir/' => [false, %w[relative_dir], ''],
66+
'relative_dir/file.txt/' => [false, %w[relative_dir file.txt], ''],
67+
'/home/user/.config/' => [true, %w[home user .config], ''],
68+
}
69+
assert_parsing(cases)
70+
end
71+
72+
it "parses filenames with no trailing slash; nonempty filename" do
73+
cases = {
74+
'/home/user/documents' => [true, %w[home user], 'documents'],
75+
'/home/user/file.txt' => [true, %w[home user], 'file.txt'],
76+
'relative_file' => [false, [], 'relative_file'],
77+
'relative_dir/file.txt' => [false, %w[relative_dir], 'file.txt'],
78+
'file.txt' => [false, [], 'file.txt'],
79+
'.bashrc' => [false, [], '.bashrc'],
80+
'/home/user/.bashrc' => [true, %w[home user], '.bashrc'],
81+
'./file.txt' => [false, [], 'file.txt'],
82+
'./.emacs' => [false, [], '.emacs'],
83+
'./././././file.txt' => [false, [], 'file.txt'],
84+
'/././etc/passwd' => [true, %w[etc], 'passwd'],
85+
}
86+
assert_parsing(cases)
3487
end
3588

3689
it "leads with a dot for all relpaths" do
@@ -42,4 +95,88 @@
4295
path = UnixPath.new(abs: true, subdirs: %w[path to], filename: 'file.txt')
4396
expect(path.to_s.start_with? '/').must_equal true
4497
end
98+
99+
it "concatenates paths with chained slash calls" do
100+
base = UnixPath.parse("./src/")
101+
expect(base.abs?).must_equal false
102+
expect(base.dir?).must_equal true
103+
104+
# check this out!
105+
full = base / 'components' / 'forms' / 'LoginForm.js'
106+
107+
expect(full.abs?).must_equal false
108+
expect(full.dir?).must_equal false
109+
110+
if base.is_a? MutablePath
111+
expect(base).must_equal full
112+
elsif base.is_a? ImmutablePath
113+
expect(base).wont_equal full
114+
end
115+
116+
expect(full.to_s).must_equal "./src/components/forms/LoginForm.js"
117+
end
118+
119+
describe 'empty filename' do
120+
it "indicates a directory when empty" do
121+
path = UnixPath.parse("/home/user/docs/")
122+
expect(path.dir?).must_equal true
123+
expect(path.filename).must_equal ""
124+
end
125+
126+
it "has neither basename nor extension" do
127+
path = UnixPath.parse("/home/user/docs/")
128+
expect(path.basename).must_be_nil
129+
expect(path.extension).must_be_nil
130+
end
131+
end
132+
133+
describe 'nonempty filename' do
134+
it "is illegal to be a single dot" do
135+
expect { UnixPath.parse('.') }.must_raise
136+
expect { UnixPath.parse('/etc/.') }.must_raise
137+
138+
# . is fine as dir
139+
expect(UnixPath.parse('/etc/./').to_s).must_equal '/etc/'
140+
end
141+
142+
it "extracts basename for regular files" do
143+
path = UnixPath.parse("file.txt")
144+
expect(path.basename).must_equal "file"
145+
end
146+
147+
it "extracts basename for dotfiles" do
148+
path = UnixPath.parse(".bashrc")
149+
expect(path.basename).must_equal ""
150+
end
151+
152+
it "extracts basename for multiple extensions" do
153+
path = UnixPath.parse("archive.tar.gz")
154+
expect(path.basename).must_equal "archive.tar"
155+
end
156+
157+
it "extracts basename for files without extensions" do
158+
path = UnixPath.parse("/etc/passwd")
159+
expect(path.basename).must_equal "passwd"
160+
end
161+
162+
it "extracts extension for regular files" do
163+
path = UnixPath.parse("file.txt")
164+
expect(path.extension).must_equal ".txt"
165+
end
166+
167+
it "extracts extension for dotfiles" do
168+
path = UnixPath.parse(".bashrc")
169+
expect(path.extension).must_equal ".bashrc"
170+
end
171+
172+
it "extracts extension for multiple extensions" do
173+
path = UnixPath.parse("archive.tar.gz")
174+
expect(path.extension).must_equal ".gz"
175+
end
176+
177+
it "extracts empty extension for files without extensions" do
178+
path = UnixPath.parse("/etc/passwd")
179+
expect(path.extension).must_equal ""
180+
end
181+
end
45182
end

0 commit comments

Comments
 (0)