Skip to content

Commit 0e485c8

Browse files
authored
Add is_normalized and starts_with to paths module (#514)
* Add is_normalized and starts_with to paths module. * Update docs
1 parent f351bed commit 0e485c8

File tree

3 files changed

+202
-0
lines changed

3 files changed

+202
-0
lines changed

docs/paths_doc.md

+48
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,33 @@ Returns `True` if `path` is an absolute path.
8383
`True` if `path` is an absolute path.
8484

8585

86+
<a id="paths.is_normalized"></a>
87+
88+
## paths.is_normalized
89+
90+
<pre>
91+
paths.is_normalized(<a href="#paths.is_normalized-str">str</a>, <a href="#paths.is_normalized-look_for_same_level_references">look_for_same_level_references</a>)
92+
</pre>
93+
94+
Returns true if the passed path doesn't contain uplevel references "..".
95+
96+
Also checks for single-dot references "." if look_for_same_level_references
97+
is `True.`
98+
99+
100+
**PARAMETERS**
101+
102+
103+
| Name | Description | Default Value |
104+
| :------------- | :------------- | :------------- |
105+
| <a id="paths.is_normalized-str"></a>str | The path string to check. | none |
106+
| <a id="paths.is_normalized-look_for_same_level_references"></a>look_for_same_level_references | If True checks if path doesn't contain uplevel references ".." or single-dot references ".". | `True` |
107+
108+
**RETURNS**
109+
110+
True if the path is normalized, False otherwise.
111+
112+
86113
<a id="paths.join"></a>
87114

88115
## paths.join
@@ -239,3 +266,24 @@ the leading dot). The returned tuple always satisfies the relationship
239266
`root + ext == p`.
240267

241268

269+
<a id="paths.starts_with"></a>
270+
271+
## paths.starts_with
272+
273+
<pre>
274+
paths.starts_with(<a href="#paths.starts_with-path_a">path_a</a>, <a href="#paths.starts_with-path_b">path_b</a>)
275+
</pre>
276+
277+
Returns True if and only if path_b is an ancestor of path_a.
278+
279+
Does not handle OS dependent case-insensitivity.
280+
281+
**PARAMETERS**
282+
283+
284+
| Name | Description | Default Value |
285+
| :------------- | :------------- | :------------- |
286+
| <a id="paths.starts_with-path_a"></a>path_a | <p align="center"> - </p> | none |
287+
| <a id="paths.starts_with-path_b"></a>path_b | <p align="center"> - </p> | none |
288+
289+

lib/paths.bzl

+78
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,67 @@ def _normalize(path):
153153

154154
return path or "."
155155

156+
_BASE = 0
157+
_SEPARATOR = 1
158+
_DOT = 2
159+
_DOTDOT = 3
160+
161+
def _is_normalized(str, look_for_same_level_references = True):
162+
"""Returns true if the passed path doesn't contain uplevel references "..".
163+
164+
Also checks for single-dot references "." if look_for_same_level_references
165+
is `True.`
166+
167+
Args:
168+
str: The path string to check.
169+
look_for_same_level_references: If True checks if path doesn't contain
170+
uplevel references ".." or single-dot references ".".
171+
172+
Returns:
173+
True if the path is normalized, False otherwise.
174+
"""
175+
state = _SEPARATOR
176+
for c in str.elems():
177+
is_separator = False
178+
if c == "/":
179+
is_separator = True
180+
181+
if state == _BASE:
182+
if is_separator:
183+
state = _SEPARATOR
184+
else:
185+
state = _BASE
186+
elif state == _SEPARATOR:
187+
if is_separator:
188+
state = _SEPARATOR
189+
elif c == ".":
190+
state = _DOT
191+
else:
192+
state = _BASE
193+
elif state == _DOT:
194+
if is_separator:
195+
if look_for_same_level_references:
196+
# "." segment found.
197+
return False
198+
state = _SEPARATOR
199+
elif c == ".":
200+
state = _DOTDOT
201+
else:
202+
state = _BASE
203+
elif state == _DOTDOT:
204+
if is_separator:
205+
return False
206+
else:
207+
state = _BASE
208+
209+
if state == _DOT:
210+
if look_for_same_level_references:
211+
# "." segment found.
212+
return False
213+
elif state == _DOTDOT:
214+
return False
215+
return True
216+
156217
def _relativize(path, start):
157218
"""Returns the portion of `path` that is relative to `start`.
158219
@@ -230,13 +291,30 @@ def _split_extension(p):
230291
dot_distance_from_end = len(b) - last_dot_in_basename
231292
return (p[:-dot_distance_from_end], p[-dot_distance_from_end:])
232293

294+
def _starts_with(path_a, path_b):
295+
"""Returns True if and only if path_b is an ancestor of path_a.
296+
297+
Does not handle OS dependent case-insensitivity."""
298+
if not path_b:
299+
# all paths start with the empty string
300+
return True
301+
norm_a = _normalize(path_a)
302+
norm_b = _normalize(path_b)
303+
if len(norm_b) > len(norm_a):
304+
return False
305+
if not norm_a.startswith(norm_b):
306+
return False
307+
return len(norm_a) == len(norm_b) or norm_a[len(norm_b)] == "/"
308+
233309
paths = struct(
234310
basename = _basename,
235311
dirname = _dirname,
236312
is_absolute = _is_absolute,
237313
join = _join,
238314
normalize = _normalize,
315+
is_normalized = _is_normalized,
239316
relativize = _relativize,
240317
replace_extension = _replace_extension,
241318
split_extension = _split_extension,
319+
starts_with = _starts_with,
242320
)

tests/paths_tests.bzl

+76
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,55 @@ def _normalize_test(ctx):
180180

181181
normalize_test = unittest.make(_normalize_test)
182182

183+
def _is_normalized_test(ctx):
184+
"""Unit tests for paths.is_normalized."""
185+
env = unittest.begin(ctx)
186+
187+
# Try the most basic cases.
188+
asserts.true(env, paths.is_normalized(""))
189+
asserts.false(env, paths.is_normalized("."))
190+
asserts.true(env, paths.is_normalized("/"))
191+
asserts.true(env, paths.is_normalized("/tmp"))
192+
asserts.true(env, paths.is_normalized("tmp"))
193+
asserts.true(env, paths.is_normalized("c:/"))
194+
asserts.false(env, paths.is_normalized("../a"))
195+
asserts.false(env, paths.is_normalized("a/.."))
196+
197+
# Try some basic adjacent-slash removal.
198+
asserts.true(env, paths.is_normalized("foo//bar"))
199+
asserts.true(env, paths.is_normalized("foo////bar"))
200+
201+
# Try some "." removal.
202+
asserts.false(env, paths.is_normalized("foo/./bar"))
203+
asserts.false(env, paths.is_normalized("./foo/bar"))
204+
asserts.false(env, paths.is_normalized("foo/bar/."))
205+
asserts.false(env, paths.is_normalized("/."))
206+
207+
# Try some ".." removal.
208+
asserts.false(env, paths.is_normalized("foo/../bar"))
209+
asserts.false(env, paths.is_normalized("foo/bar/.."))
210+
asserts.false(env, paths.is_normalized("foo/.."))
211+
asserts.false(env, paths.is_normalized("foo/bar/../.."))
212+
asserts.false(env, paths.is_normalized("foo/../.."))
213+
asserts.false(env, paths.is_normalized("/foo/../.."))
214+
asserts.false(env, paths.is_normalized("a/b/../../../../c/d/.."))
215+
216+
# Make sure one or two initial slashes are preserved, but three or more are
217+
# collapsed to a single slash.
218+
asserts.true(env, paths.is_normalized("/foo"))
219+
asserts.true(env, paths.is_normalized("//foo"))
220+
asserts.true(env, paths.is_normalized("///foo"))
221+
222+
# Trailing slashes should be removed unless the entire path is a trailing
223+
# slash.
224+
asserts.true(env, paths.is_normalized("/"))
225+
asserts.true(env, paths.is_normalized("foo/"))
226+
asserts.true(env, paths.is_normalized("foo/bar/"))
227+
228+
return unittest.end(env)
229+
230+
is_normalized_test = unittest.make(_is_normalized_test)
231+
183232
def _relativize_test(ctx):
184233
"""Unit tests for paths.relativize."""
185234
env = unittest.begin(ctx)
@@ -276,6 +325,31 @@ def _split_extension_test(ctx):
276325

277326
split_extension_test = unittest.make(_split_extension_test)
278327

328+
def _starts_with_test(ctx):
329+
"""Unit tests for paths.starts_with."""
330+
env = unittest.begin(ctx)
331+
332+
# Make sure that relative-to-current-directory works in all forms.
333+
asserts.true(env, paths.starts_with("foo", ""))
334+
asserts.false(env, paths.starts_with("foo", "."))
335+
336+
# Try some regular cases.
337+
asserts.true(env, paths.starts_with("foo/bar", "foo"))
338+
asserts.false(env, paths.starts_with("foo/bar", "fo"))
339+
asserts.true(env, paths.starts_with("foo/bar/baz", "foo/bar"))
340+
asserts.true(env, paths.starts_with("foo/bar/baz", "foo"))
341+
342+
# Try a case where a parent directory is normalized away.
343+
asserts.true(env, paths.starts_with("foo/bar/../baz", "foo"))
344+
345+
# Relative paths work, as long as they share a common start.
346+
asserts.true(env, paths.starts_with("../foo/bar/baz/file", "../foo/bar/baz"))
347+
asserts.true(env, paths.starts_with("../foo/bar/baz/file", "../foo/bar"))
348+
349+
return unittest.end(env)
350+
351+
starts_with_test = unittest.make(_starts_with_test)
352+
279353
def paths_test_suite():
280354
"""Creates the test targets and test suite for paths.bzl tests."""
281355
unittest.suite(
@@ -285,7 +359,9 @@ def paths_test_suite():
285359
is_absolute_test,
286360
join_test,
287361
normalize_test,
362+
is_normalized_test,
288363
relativize_test,
289364
replace_extension_test,
290365
split_extension_test,
366+
starts_with_test,
291367
)

0 commit comments

Comments
 (0)