Skip to content

Commit ac39333

Browse files
authored
Fix join. (#7)
1 parent ab20796 commit ac39333

File tree

4 files changed

+271
-8
lines changed

4 files changed

+271
-8
lines changed

src/fs.toit

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -310,15 +310,57 @@ basename path/string -> string:
310310
return is-windows_ ? windows.basename path : posix.basename path
311311

312312
/**
313-
Creates a path relative to the given $base path.
313+
Joins any number of path elements into a single path, separating them
314+
with the OS specific $SEPARATOR.
315+
316+
Empty elements are ignored.
317+
Returns "" (the empty string) if there are no elements or all elements are empty.
318+
Otherwise, calls $clean before returning the result.
319+
320+
On Windows the result is only a UNC path (like `//host/share`) if the first
321+
element is a UNC path.
322+
323+
# Examples
324+
## Windows
325+
The examples use `/` as `\\` would need to be escaped in the strings.
326+
The results would always return `\\` instead of `/`.
327+
```
328+
join [] // ""
329+
join [""] // ""
330+
join ["foo"] // "foo"
331+
join ["foo", "bar"] // "foo/bar"
332+
join ["foo", "bar", "baz"] // "foo/bar/baz"
333+
join ["foo", "", "bar"] // "foo/bar"
334+
join ["c:", "foo"] // "c:foo"
335+
join ["c:", "/foo"] // "c:foo/bar"
336+
join ["c:/", "foo"] // "c:/foo"
337+
join ["//host/share", "foo"] // "//host/share/foo"
338+
join ["//host", "share", "foo"] // "//host/share/foo"
339+
join ["/", "/", "foo"] // "/foo"
340+
```
341+
342+
## Posix
343+
```
344+
join [] // ""
345+
join [""] // ""
346+
join ["foo"] // "foo"
347+
join ["foo", "bar"] // "foo/bar"
348+
join ["foo", "bar", "baz"] // "foo/bar/baz"
349+
join ["foo", "", "bar"] // "foo/bar"
350+
join ["/", "foo", "", "bar"] // "/foo/bar"
351+
join ["/foo", "", "bar"] // "/foo/bar"
352+
```
353+
*/
354+
join elements/List -> string:
355+
return is-windows_ ? windows.join elements : posix.join elements
356+
357+
/**
358+
Variant of $(join elements).
359+
360+
Joins the given $base and $path1, and optionally $path2, $path3 and $path4.
314361
*/
315-
join base/string path/string --path-platform/string=system.platform -> string:
316-
if is-rooted path: return path
317-
if path-platform == system.PLATFORM-WINDOWS:
318-
if base.size == 2 and base[1] == ':':
319-
// If the base is really just a drive letter, then we must not add a separator.
320-
return clean "$base$path"
321-
return clean "$base/$path"
362+
join base/string path1/string path2/string="" path3/string="" path4/string="" -> string:
363+
return join [base, path1, path2, path3, path4]
322364

323365
/**
324366
Cleans a path, removing redundant path separators and resolving "." and ".."

src/posix.toit

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,39 @@ basename path/string -> string:
162162
if path[i] == '/': break
163163
return path[i + 1 .. end]
164164

165+
/**
166+
Joins any number of path elements into a single path, separating them with `/`.
167+
168+
Empty elements are ignored.
169+
Returns "" (the empty string) if there are no elements or all elements are empty.
170+
Otherwise, calls $clean before returning the result.
171+
172+
# Examples
173+
```
174+
join [] // ""
175+
join [""] // ""
176+
join ["foo"] // "foo"
177+
join ["foo", "bar"] // "foo/bar"
178+
join ["foo", "bar", "baz"] // "foo/bar/baz"
179+
join ["foo", "", "bar"] // "foo/bar"
180+
join ["/", "foo", "", "bar"] // "/foo/bar"
181+
join ["/foo", "", "bar"] // "/foo/bar"
182+
```
183+
*/
184+
join elements/List -> string:
185+
non-empty := elements.filter: it != ""
186+
if non-empty.is-empty: return ""
187+
188+
return clean (non-empty.join SEPARATOR)
189+
190+
/**
191+
Variant of $(join elements).
192+
193+
Joins the given $base and $path1, and optionally $path2, $path3 and $path4.
194+
*/
195+
join base/string path1/string path2/string="" path3/string="" path4/string="" -> string:
196+
return join [base, path1, path2, path3, path4]
197+
165198
/**
166199
Cleans a path, removing redundant path separators and resolving "." and ".."
167200
segments.

src/windows.toit

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,94 @@ basename path/string -> string:
281281
if is-separator path[i]: break
282282
return path[i + 1 .. end]
283283

284+
/**
285+
Joins any number of path elements into a single path, separating them with `\\`.
286+
287+
Empty elements are ignored.
288+
Returns "" (the empty string) if there are no elements or all elements are empty.
289+
Otherwise, calls $clean before returning the result.
290+
291+
The result is only a UNC path (like `//host/share`) if the first
292+
element is a UNC path.
293+
294+
# Examples
295+
The examples use `/` as `\\` would need to be escaped in the strings.
296+
The results would always return `\\` instead of `/`.
297+
```
298+
join [] // ""
299+
join [""] // ""
300+
join ["foo"] // "foo"
301+
join ["foo", "bar"] // "foo/bar"
302+
join ["foo", "bar", "baz"] // "foo/bar/baz"
303+
join ["foo", "", "bar"] // "foo/bar"
304+
join ["c:", "foo"] // "c:foo"
305+
join ["c:", "/foo"] // "c:foo/bar"
306+
join ["c:/", "foo"] // "c:/foo"
307+
join ["//host/share", "foo"] // "//host/share/foo"
308+
join ["//host", "share", "foo"] // "//host/share/foo"
309+
join ["/", "/", "foo"] // "/foo"
310+
```
311+
*/
312+
join elements/List -> string:
313+
max-size :=
314+
(elements.reduce --initial=0: | a b | a + b.size)
315+
+ elements.size - 1
316+
+ 1 // We add an additional character to avoid root local device paths.
317+
result := ByteArray max-size
318+
319+
target-pos := 0
320+
elements.do: | segment/string |
321+
if segment == "": continue.do
322+
last-char := target-pos == 0 ? 0 : result[target-pos - 1]
323+
segment-start-pos := 0
324+
if target-pos == 0:
325+
// Write the first segment verbatim below. No need to deal with separators.
326+
else if is-separator last-char:
327+
// If the last character was a separator, we strip any leading separators.
328+
// We need to avoid creating a UNC path from individual segments.
329+
// For example `["/", "foo"]` should become `/foo` and not `//foo`.
330+
// In theory we don't need to strip leading separators if we have already
331+
// a volume, since 'clean' will get rid of multiple separators. However, it's
332+
// not that easy to figure out when a volume name is complete, and it's easy
333+
// to remove the leading separators.
334+
// `["//host", "//share"]` -> `//host/share` and not `//host//share`?
335+
// If the segment consists entirely of separators we drop the segment.
336+
while segment-start-pos < segment.size and is-separator segment[segment-start-pos]:
337+
segment-start-pos++
338+
if target-pos == 1 and is-separator result[0] and segment.starts-with "??":
339+
// If the path is '/' and the next segment is '??' add an extra './' to
340+
// create '/./??' rather than '/??/' which is a root local device path.
341+
result[target-pos++] = '.'
342+
result[target-pos++] = SEPARATOR-CHAR
343+
else if last-char == ':' and target-pos == 2:
344+
// Note: Go does this for any segment and not just if the colon is in the 2nd
345+
// position.
346+
// If the path ends in a colon, keep the path relative to the current
347+
// directory on a drive, and don't add a separator. If the segment starts
348+
// with a separator, it will make the path absolute.
349+
//
350+
// For example: `c:` + `foo` -> `c:foo` and not `c:/foo`.
351+
// but `c:` + `/foo` -> `c:/foo`.
352+
/* Do nothing. Don't add a separator. */
353+
else:
354+
// Otherwise, add a separator.
355+
result[target-pos++] = SEPARATOR-CHAR
356+
357+
result.replace target-pos segment segment-start-pos
358+
target-pos += segment.size - segment-start-pos
359+
360+
// Nothing was added. Should not happen, but doesn't cost to check.
361+
if target-pos == 0: return ""
362+
return clean (result[.. target-pos]).to-string
363+
364+
/**
365+
Variant of $(join elements).
366+
367+
Joins the given $base and $path1, and optionally $path2, $path3 and $path4.
368+
*/
369+
join base/string path1/string path2/string="" path3/string="" path4/string="" -> string:
370+
return join [base, path1, path2, path3, path4]
371+
284372
/**
285373
Cleans a path, removing redundant path separators and resolving "." and ".."
286374
segments.

tests/join-test.toit

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Copyright (C) 2023 Toitware ApS.
2+
// Use of this source code is governed by a Zero-Clause BSD license that can
3+
// be found in the tests/TESTS_LICENSE file.
4+
5+
import expect show *
6+
import fs
7+
import fs.posix
8+
import fs.windows
9+
import system
10+
11+
POSIX-TESTS ::= [
12+
[[], ""],
13+
[[""], ""],
14+
[["/"], "/"],
15+
[["a"], "a"],
16+
[["a", "b"], "a/b"],
17+
[["a", ""], "a"],
18+
[["", "b"], "b"],
19+
[["/", "a"], "/a"],
20+
[["/", "a/b"], "/a/b"],
21+
[["/", ""], "/"],
22+
[["/a", "b"], "/a/b"],
23+
[["a", "/b"], "a/b"],
24+
[["/a", "/b"], "/a/b"],
25+
[["a/", "b"], "a/b"],
26+
[["a/", ""], "a"],
27+
[["", ""], ""],
28+
[["/", "a", "b"], "/a/b"],
29+
[["//", "a"], "/a"],
30+
]
31+
32+
WINDOWS-TESTS ::= [
33+
[["/", "/", "foo"], "\\foo"],
34+
[[], ""],
35+
[[""], ""],
36+
[["/"], "\\"],
37+
[["a"], "a"],
38+
[["a", "b"], "a\\b"],
39+
[["a", ""], "a"],
40+
[["", "b"], "b"],
41+
[["/", "a"], "\\a"],
42+
[["/", "a/b"], "\\a\\b"],
43+
[["/", ""], "\\"],
44+
[["/a", "b"], "\\a\\b"],
45+
[["a", "/b"], "a\\b"],
46+
[["/a", "/b"], "\\a\\b"],
47+
[["a/", "b"], "a\\b"],
48+
[["a/", ""], "a"],
49+
[["", ""], ""],
50+
[["/", "a", "b"], "\\a\\b"],
51+
[["directory", "file"], "directory\\file"],
52+
[["C:\\Windows\\", "System32"], "C:\\Windows\\System32"],
53+
[["C:\\Windows\\", ""], "C:\\Windows"],
54+
[["C:\\", "Windows"], "C:\\Windows"],
55+
[["C:", "a"], "C:a"],
56+
[["C:", "a\\b"], "C:a\\b"],
57+
[["C:", "a", "b"], "C:a\\b"],
58+
[["C:", "", "b"], "C:b"],
59+
[["C:", "", "", "b"], "C:b"],
60+
[["C:", ""], "C:."],
61+
[["C:", "", ""], "C:."],
62+
[["C:", "\\a"], "C:\\a"],
63+
[["C:", "", "\\a"], "C:\\a"],
64+
[["C:.", "a"], "C:a"],
65+
[["C:a", "b"], "C:a\\b"],
66+
[["C:a", "b", "d"], "C:a\\b\\d"],
67+
[["\\\\host\\share", "foo"], "\\\\host\\share\\foo"],
68+
[["\\\\host\\share\\foo"], "\\\\host\\share\\foo"],
69+
[["//host/share", "foo/bar"], "\\\\host\\share\\foo\\bar"],
70+
[["\\"], "\\"],
71+
[["\\", ""], "\\"],
72+
[["\\", "a"], "\\a"],
73+
[["\\\\", "a"], "\\\\a"],
74+
[["\\", "a", "b"], "\\a\\b"],
75+
[["\\\\", "a", "b"], "\\\\a\\b"],
76+
[["\\", "\\\\a\\b", "c"], "\\a\\b\\c"],
77+
[["\\\\a", "b", "c"], "\\\\a\\b\\c"],
78+
[["\\\\a\\", "b", "c"], "\\\\a\\b\\c"],
79+
[["//", "a"], "\\\\a"],
80+
[["a:\\b\\c", "x\\..\\y:\\..\\..\\z"], "a:\\b\\z"],
81+
[["\\", "??\\a"], "\\.\\??\\a"],
82+
]
83+
84+
main:
85+
POSIX-TESTS.do: | test/List |
86+
elements := test[0]
87+
expected:= test[1]
88+
actual := posix.join elements
89+
expect-equals expected actual
90+
WINDOWS-TESTS.do: | test/List |
91+
elements := test[0]
92+
expected:= test[1]
93+
actual := windows.join elements
94+
expect-equals expected actual
95+
local-tests := system.platform == system.PLATFORM-WINDOWS ? WINDOWS-TESTS : POSIX-TESTS
96+
local-tests.do: | test/List |
97+
elements := test[0]
98+
expected:= test[1]
99+
actual := fs.join elements
100+
expect-equals expected actual

0 commit comments

Comments
 (0)