module

Noir::PathScope

Path-boundary helpers shared by the analyzers and engines. Monorepo scans (multiple -b base paths) have to answer two questions repeatedly: does a file live under a given root, and which configured base does it belong to? Both must respect path boundaries — a plain String#starts_with? leaks siblings (/app would swallow /app2) — so the comparison runs on File.expand_path-normalized paths.

This logic was copy-pasted across half a dozen call sites (FileHelper, Analyzer, PythonEngine, the Dart helper, the Express router scanner, FastAPI). This module is the single source of truth; a future tweak (Windows separators, symlink handling, …) now lands in one place.

Hot loops should normalize the loop-invariant root once via normalize_root and compare with under_normalized_root? rather than paying for File.expand_path of the root per file.

Constants

BASE_SEPARATORS = {% if flag?(:windows) %} {'\\', '/'} {% else %} {'/'} {% end %}

Canonical form of a user-supplied base path, preserving whether it is relative. -b rem2, -b rem2/// and -b ./rem2 all name the same tree, but the raw string is echoed straight into every reported code_path, so the same codebase scanned three ways produced three different reports — which breaks baseline diffing and any SARIF consumer that keys on the URI.

Repeated separators collapse, trailing separators are dropped (the filesystem root itself stays /), and . segments are resolved away (./rem2 -> rem2, a bare . stays .). .. segments are left alone: resolving them textually is wrong across symlinks.

Deliberately NOT normalize_root/File.expand_path — expanding a relative base would bake the machine's checkout location into every reported path, and an absolute code_path was itself a bug fixed in an earlier sweep. A relative base stays relative. Separators a base path may legitimately use. Windows accepts both forms; on POSIX a backslash is an ordinary filename character and must not be treated as a separator. The result is always joined with /: noir handles paths as /-separated strings throughout, and Windows file APIs accept / as readily as \\. File::SEPARATOR is '/' on every platform in Crystal, Windows included, so the platform has to be read off the target flag.

Instance methods

base_relative(path : String, base_path : String) : String

The form every convention filter must match on: path's location relative to the scan base that owns it, /-separated and rooted with a leading /.

"Is this file under tests/?", "is this vendored?", "is this build output?" are all statements about a location inside the project. Ask them of the absolute path and the answer depends on where the checkout happens to sit: a clone into ~/work/tests/myapp, or a CI job that checks out under a test/ step directory, matches every file in the project and silently reports nothing. Rooting the result means one includes?("/tests/") also covers a tests/ directory at the top of the project, and keeps every match on a whole path segment (/test/ never matches latest/).

A path outside base_path keeps its own (absolute) form, exactly as get_relative_path returns it — the filters then behave as they did before, which is the conservative choice for a file the scan base does not own.

Source
base_relative(path : String, base_paths : Array(String)) : String

Multi-base overload: resolves the owning base first. Callers with a per-path base cache (Analyzer#configured_base_for) should use that and call the single-base form instead.

Source
expand(path : String) : String

File.expand_path(path), without the per-call cost of finding the working directory.

Crystal's File.expand_path(path) evaluates Dir.current on every call — even for an absolute path, which never uses it — and Dir.current stats both $PWD and . to confirm they agree. The analyzers expand paths inside per-file loops, so on a large scan those two syscalls per call were a measurable share of the run.

An absolute path is expanded against / (the base is ignored), and a relative one against the directory pinned by with_pinned_cwd, falling back to Dir.current outside a pinned scope. The result is identical to File.expand_path(path) as long as nothing changes the working directory inside the pinned scope, which noir never does.

Always /-separated, Windows included (see ext/windows_paths.cr).

Source
longest_base(path : String, bases : Enumerable(String)) : String | Nil

The most specific (longest normalized) base in bases that contains path, or nil if none does. Returns the ORIGINAL base string (not its normalized form) so callers can use it as a stable map key.

Source
marker_root(path : String, base : String, markers : Enumerable(String)) : String | Nil

path up to the first of markers (tried in order) that ends below base, or nil when none does. A marker wholly above the base is the checkout location (~/src/<project>), not the module's own source root, so it never counts. One that straddles the end of the base (-b module/src) does: below-base matches are tried first, then the straddling one, per marker.

Source
normalize_base(base : String) : String
Source
normalize_root(root : String) : String

Canonical comparison form of a root: expanded, with any trailing separator stripped (except the filesystem root itself).

Source
relative_under(path : String, base_path : String | Nil) : String

Remainder of path beneath base_path, or the bare basename when path is outside base_path (or no base given). Backs the Python/Dart test-path conventions, which key off the project-relative path so nested fixture trees don't trip the tests//test/ filters.

Source
under_normalized_root?(expanded_path : String, normalized_root : String) : Bool

Boundary check against an already-expanded path and an already-normalized root. Use this in per-file loops where the root is loop-invariant (normalize it once with normalize_root).

Source
under_root?(path : String, root : String) : Bool

True when path is root or sits beneath it on a path boundary. root is expanded/normalized internally; an empty root matches everything (mirrors the historical starts_with?("")).

Source
with_pinned_cwd

Resolves the working directory once and reuses it for every expand inside the block. Scoped rather than cached for the process: specs (and embedders) Dir.cd between scans, and a process-wide cache would resolve their paths against a stale directory. Re-entrant: a nested call keeps the outer pin.

Source