diff --git a/docs/paths_doc.md b/docs/paths_doc.md index e78c0d3..15fc57f 100755 --- a/docs/paths_doc.md +++ b/docs/paths_doc.md @@ -83,6 +83,33 @@ Returns `True` if `path` is an absolute path. `True` if `path` is an absolute path. + + +## paths.is_normalized + +
+paths.is_normalized(str, look_for_same_level_references) ++ +Returns true if the passed path doesn't contain uplevel references "..". + +Also checks for single-dot references "." if look_for_same_level_references +is `True.` + + +**PARAMETERS** + + +| Name | Description | Default Value | +| :------------- | :------------- | :------------- | +| str | The path string to check. | none | +| look_for_same_level_references | If True checks if path doesn't contain uplevel references ".." or single-dot references ".". | `True` | + +**RETURNS** + +True if the path is normalized, False otherwise. + + ## paths.join @@ -239,3 +266,24 @@ the leading dot). The returned tuple always satisfies the relationship `root + ext == p`. + + +## paths.starts_with + +
+paths.starts_with(path_a, path_b) ++ +Returns True if and only if path_b is an ancestor of path_a. + +Does not handle OS dependent case-insensitivity. + +**PARAMETERS** + + +| Name | Description | Default Value | +| :------------- | :------------- | :------------- | +| path_a |
-
| none | +| path_b |-
| none | + + diff --git a/lib/paths.bzl b/lib/paths.bzl index 8e3fcae..ef38555 100644 --- a/lib/paths.bzl +++ b/lib/paths.bzl @@ -153,6 +153,67 @@ def _normalize(path): return path or "." +_BASE = 0 +_SEPARATOR = 1 +_DOT = 2 +_DOTDOT = 3 + +def _is_normalized(str, look_for_same_level_references = True): + """Returns true if the passed path doesn't contain uplevel references "..". + + Also checks for single-dot references "." if look_for_same_level_references + is `True.` + + Args: + str: The path string to check. + look_for_same_level_references: If True checks if path doesn't contain + uplevel references ".." or single-dot references ".". + + Returns: + True if the path is normalized, False otherwise. + """ + state = _SEPARATOR + for c in str.elems(): + is_separator = False + if c == "/": + is_separator = True + + if state == _BASE: + if is_separator: + state = _SEPARATOR + else: + state = _BASE + elif state == _SEPARATOR: + if is_separator: + state = _SEPARATOR + elif c == ".": + state = _DOT + else: + state = _BASE + elif state == _DOT: + if is_separator: + if look_for_same_level_references: + # "." segment found. + return False + state = _SEPARATOR + elif c == ".": + state = _DOTDOT + else: + state = _BASE + elif state == _DOTDOT: + if is_separator: + return False + else: + state = _BASE + + if state == _DOT: + if look_for_same_level_references: + # "." segment found. + return False + elif state == _DOTDOT: + return False + return True + def _relativize(path, start): """Returns the portion of `path` that is relative to `start`. @@ -230,13 +291,30 @@ def _split_extension(p): dot_distance_from_end = len(b) - last_dot_in_basename return (p[:-dot_distance_from_end], p[-dot_distance_from_end:]) +def _starts_with(path_a, path_b): + """Returns True if and only if path_b is an ancestor of path_a. + + Does not handle OS dependent case-insensitivity.""" + if not path_b: + # all paths start with the empty string + return True + norm_a = _normalize(path_a) + norm_b = _normalize(path_b) + if len(norm_b) > len(norm_a): + return False + if not norm_a.startswith(norm_b): + return False + return len(norm_a) == len(norm_b) or norm_a[len(norm_b)] == "/" + paths = struct( basename = _basename, dirname = _dirname, is_absolute = _is_absolute, join = _join, normalize = _normalize, + is_normalized = _is_normalized, relativize = _relativize, replace_extension = _replace_extension, split_extension = _split_extension, + starts_with = _starts_with, ) diff --git a/tests/paths_tests.bzl b/tests/paths_tests.bzl index 42253d8..d0d3cc6 100644 --- a/tests/paths_tests.bzl +++ b/tests/paths_tests.bzl @@ -180,6 +180,55 @@ def _normalize_test(ctx): normalize_test = unittest.make(_normalize_test) +def _is_normalized_test(ctx): + """Unit tests for paths.is_normalized.""" + env = unittest.begin(ctx) + + # Try the most basic cases. + asserts.true(env, paths.is_normalized("")) + asserts.false(env, paths.is_normalized(".")) + asserts.true(env, paths.is_normalized("/")) + asserts.true(env, paths.is_normalized("/tmp")) + asserts.true(env, paths.is_normalized("tmp")) + asserts.true(env, paths.is_normalized("c:/")) + asserts.false(env, paths.is_normalized("../a")) + asserts.false(env, paths.is_normalized("a/..")) + + # Try some basic adjacent-slash removal. + asserts.true(env, paths.is_normalized("foo//bar")) + asserts.true(env, paths.is_normalized("foo////bar")) + + # Try some "." removal. + asserts.false(env, paths.is_normalized("foo/./bar")) + asserts.false(env, paths.is_normalized("./foo/bar")) + asserts.false(env, paths.is_normalized("foo/bar/.")) + asserts.false(env, paths.is_normalized("/.")) + + # Try some ".." removal. + asserts.false(env, paths.is_normalized("foo/../bar")) + asserts.false(env, paths.is_normalized("foo/bar/..")) + asserts.false(env, paths.is_normalized("foo/..")) + asserts.false(env, paths.is_normalized("foo/bar/../..")) + asserts.false(env, paths.is_normalized("foo/../..")) + asserts.false(env, paths.is_normalized("/foo/../..")) + asserts.false(env, paths.is_normalized("a/b/../../../../c/d/..")) + + # Make sure one or two initial slashes are preserved, but three or more are + # collapsed to a single slash. + asserts.true(env, paths.is_normalized("/foo")) + asserts.true(env, paths.is_normalized("//foo")) + asserts.true(env, paths.is_normalized("///foo")) + + # Trailing slashes should be removed unless the entire path is a trailing + # slash. + asserts.true(env, paths.is_normalized("/")) + asserts.true(env, paths.is_normalized("foo/")) + asserts.true(env, paths.is_normalized("foo/bar/")) + + return unittest.end(env) + +is_normalized_test = unittest.make(_is_normalized_test) + def _relativize_test(ctx): """Unit tests for paths.relativize.""" env = unittest.begin(ctx) @@ -276,6 +325,31 @@ def _split_extension_test(ctx): split_extension_test = unittest.make(_split_extension_test) +def _starts_with_test(ctx): + """Unit tests for paths.starts_with.""" + env = unittest.begin(ctx) + + # Make sure that relative-to-current-directory works in all forms. + asserts.true(env, paths.starts_with("foo", "")) + asserts.false(env, paths.starts_with("foo", ".")) + + # Try some regular cases. + asserts.true(env, paths.starts_with("foo/bar", "foo")) + asserts.false(env, paths.starts_with("foo/bar", "fo")) + asserts.true(env, paths.starts_with("foo/bar/baz", "foo/bar")) + asserts.true(env, paths.starts_with("foo/bar/baz", "foo")) + + # Try a case where a parent directory is normalized away. + asserts.true(env, paths.starts_with("foo/bar/../baz", "foo")) + + # Relative paths work, as long as they share a common start. + asserts.true(env, paths.starts_with("../foo/bar/baz/file", "../foo/bar/baz")) + asserts.true(env, paths.starts_with("../foo/bar/baz/file", "../foo/bar")) + + return unittest.end(env) + +starts_with_test = unittest.make(_starts_with_test) + def paths_test_suite(): """Creates the test targets and test suite for paths.bzl tests.""" unittest.suite( @@ -285,7 +359,9 @@ def paths_test_suite(): is_absolute_test, join_test, normalize_test, + is_normalized_test, relativize_test, replace_extension_test, split_extension_test, + starts_with_test, )