Skip to main content

big_code_analysis/
tools.rs

1// Per-language metric and AST modules deliberately consume the macro-
2// generated tree-sitter token enums via `use crate::*` and `use Foo::*`
3// inside match expressions — explicit imports would list dozens of
4// variants per arm and obscure the per-language token sets that are the
5// point of these files. Allowed at the module level rather than per
6// function so the per-language impl blocks stay readable.
7#![allow(clippy::wildcard_imports, clippy::enum_glob_use)]
8// Metric counts (token, function, branch, argument, etc.) are stored as
9// `usize` and crossed with `f64` averages, ratios, and Halstead scores
10// across the cyclomatic / MI / Halstead computations. The `usize as f64`
11// and `f64 as usize` casts are intentional and snapshot-anchored — every
12// site is bounded by the count it came from. Allowing the lints at the
13// module level keeps the metric arithmetic legible.
14#![allow(
15    clippy::cast_precision_loss,
16    clippy::cast_possible_truncation,
17    clippy::cast_sign_loss
18)]
19
20use std::borrow::Cow;
21use std::cmp::Ordering;
22use std::collections::HashMap;
23use std::fs::{self, File};
24use std::io::{Read, Write};
25use std::path::{Component, Path, PathBuf};
26use std::sync::OnceLock;
27
28use regex::bytes::Regex;
29use termcolor::{Color, ColorSpec, WriteColor};
30
31use crate::langs::*;
32
33/// Reads a file, normalising all CR-only and CRLF line endings to LF.
34///
35/// **Note for downstream consumers**: the returned buffer never contains `\r`
36/// bytes. Callers that previously observed raw `\r\n` sequences will see plain
37/// `\n` after this call. This is intentional — the metric engine requires LF-
38/// only input — but it is a behavioural difference from a plain `fs::read`.
39///
40/// # Errors
41///
42/// Returns any [`std::io::Error`] surfaced by [`File::open`] (the
43/// path is missing, lacks read permission, is a directory, …) or by
44/// [`File::read_to_end`] while reading the file contents.
45///
46/// # Examples
47///
48/// ```
49/// use std::path::Path;
50///
51/// use big_code_analysis::read_file;
52///
53/// let path = Path::new("Cargo.toml");
54/// read_file(&path).unwrap();
55/// ```
56pub fn read_file(path: &Path) -> std::io::Result<Vec<u8>> {
57    let mut file = File::open(path)?;
58    let mut data = Vec::new();
59    file.read_to_end(&mut data)?;
60
61    normalize_line_endings(&mut data);
62
63    Ok(data)
64}
65
66/// Bytes from the start of the file probed to decide whether the contents
67/// look like UTF-8 before the whole file is read. A small fixed window keeps
68/// the rejection of obviously-binary files cheap; the last character of the
69/// window may be a multibyte sequence split by this boundary, which the
70/// classifier tolerates only when more file follows (see `read_file_with_eol`).
71const UTF8_PROBE_BYTES: usize = 64;
72
73/// Decides whether a file's probe prefix is decodable UTF-8 and where its
74/// real content starts. Returns the post-BOM content slice when the probe
75/// is acceptable, or `None` when the file should be skipped.
76///
77/// A UTF-16 BE/LE BOM marks a file whose body is interleaved-NUL UTF-16,
78/// which the metric engine cannot parse: stripping the BOM and continuing
79/// would let the ASCII-dominant body pass the UTF-8 probe (each NUL is a
80/// valid single-byte UTF-8 scalar) and reach the parser as garbage (issue
81/// #803). Skip such files, mirroring `is_generated`'s documented stance
82/// that UTF-16 source is unsupported. A UTF-8 BOM, by contrast, prefixes
83/// genuine UTF-8 and is stripped so the body parses normally.
84/// `starts_with` is bounds-safe for a probe shorter than the BOM.
85///
86/// Validation is at the byte level rather than via a lossy string
87/// round-trip. The probe is only the first `UTF8_PROBE_BYTES`, so a file
88/// longer than the probe may legitimately have its last multibyte
89/// character split across the window boundary. `String::from_utf8_lossy`
90/// could not distinguish that benign truncation (issue #746) from a real
91/// encoding error, and its replacement character `U+FFFD` collided with
92/// the same scalar appearing legitimately in the source (issue #758).
93/// `probe_truncated` is true only when the file continues past the probe;
94/// when the probe is the whole file there is no more data to complete a
95/// trailing partial sequence, so such a sequence is genuine corruption.
96fn probe_decodable_prefix(start: &[u8], file_size: usize, probe_len: usize) -> Option<&[u8]> {
97    let start = if start.starts_with(b"\xFE\xFF") || start.starts_with(b"\xFF\xFE") {
98        return None;
99    } else if let Some(rest) = start.strip_prefix(b"\xEF\xBB\xBF") {
100        rest
101    } else {
102        start
103    };
104
105    let probe_truncated = file_size > probe_len;
106    match std::str::from_utf8(start) {
107        Ok(_) => {}
108        // Only a trailing incomplete multibyte sequence: the bytes before
109        // `valid_up_to()` are valid UTF-8 and the truncated tail is
110        // completed by data later in the file.
111        Err(e) if e.error_len().is_none() && probe_truncated => {}
112        Err(_) => return None,
113    }
114    Some(start)
115}
116
117/// Reads a file, normalising all CR-only and CRLF line endings to LF, and ensures
118/// the buffer ends with exactly one `\n`. Returns `None` for readable files
119/// ≤ 3 bytes or files that appear to be non-UTF-8.
120///
121/// # Errors
122///
123/// Returns any [`std::io::Error`] surfaced by [`File::open`] (the
124/// path is missing, lacks read permission, is a directory, …) or by
125/// the subsequent reads from the open file handle. A clean short read
126/// during the probe (`UnexpectedEof`) yields `Ok(None)`; any other
127/// `read_exact` error kind propagates as `Err`. A non-UTF-8 head, a
128/// too-small file, or a UTF-16 BE/LE BOM is reported via `Ok(None)`,
129/// not an error — but "too small" is decided only for a file that can
130/// be opened, so an unreadable one errors instead (#1060).
131///
132/// # Examples
133///
134/// ```
135/// use std::path::Path;
136///
137/// use big_code_analysis::read_file_with_eol;
138///
139/// let path = Path::new("Cargo.toml");
140/// read_file_with_eol(&path).unwrap();
141/// ```
142pub fn read_file_with_eol(path: &Path) -> std::io::Result<Option<Vec<u8>>> {
143    let meta = fs::metadata(path);
144    let file_size = meta.as_ref().map_or(1024 * 1024, |m| m.len() as usize);
145    if file_size <= 3 {
146        // Nothing worth parsing this small — but `stat` alone must not
147        // decide it: `stat` succeeds on a file the process cannot open,
148        // so an unreadable tiny file read as empty and `bca check`
149        // exited 0 on a tree it never read (#1060). `meta` is
150        // necessarily `Ok` here; the open is a discarded readability
151        // probe, skipped for non-regular files because a FIFO blocks.
152        if meta.is_ok_and(|m| m.is_file()) {
153            File::open(path)?;
154        }
155        return Ok(None);
156    }
157
158    let mut file = File::open(path)?;
159
160    let probe_len = UTF8_PROBE_BYTES.min(file_size);
161    let mut start = vec![0; probe_len];
162    // A clean short read (the file shrank below the probe between the
163    // `metadata` call and here) is reported as `Ok(None)`, matching the
164    // too-small-file case. Any other `read_exact` failure — a real I/O
165    // fault such as a permission or hardware error — must propagate as
166    // `Err` per the documented contract (issue #804); collapsing every
167    // error to `Ok(None)` would silently swallow genuine read failures.
168    match file.read_exact(&mut start) {
169        Ok(()) => {}
170        Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None),
171        Err(e) => return Err(e),
172    }
173
174    // Sniff the probe: reject UTF-16 / corrupt files, strip a UTF-8 BOM,
175    // and anchor the buffer at the post-BOM content. `None` means skip the
176    // file (see `probe_decodable_prefix`).
177    let Some(start) = probe_decodable_prefix(&start, file_size, probe_len) else {
178        return Ok(None);
179    };
180
181    let mut data = Vec::with_capacity(file_size + 2);
182    data.extend_from_slice(start);
183
184    file.read_to_end(&mut data)?;
185
186    normalize_line_endings(&mut data);
187
188    Ok(Some(data))
189}
190
191/// Normalises an in-memory source buffer to match the [`read_file_with_eol`]
192/// on-disk path: all CR-only and CRLF line endings become LF, and the buffer is
193/// guaranteed to end with exactly one `\n`.
194///
195/// In-memory entry points (the web server's JSON/octet-stream payloads, the
196/// Python `analyze_source` bindings) feed caller-supplied bytes straight to the
197/// parser, whereas the CLI reads files through [`read_file_with_eol`]. Without
198/// this step, identical content yields different metrics across surfaces — an
199/// editor buffer with no trailing newline reports `sloc: 0` over the wire but
200/// `sloc: 1` from the CLI on the same bytes (issue #640). Run the buffer through
201/// this helper before parsing so every surface computes the canonical numbers.
202///
203/// Returns a fresh owned buffer; the input is consumed so the common case
204/// (already-owned request body) reuses its allocation.
205///
206/// # Examples
207///
208/// ```
209/// use big_code_analysis::normalize_eol;
210///
211/// // CRLF endings collapse to LF and a missing final newline is added.
212/// assert_eq!(normalize_eol(b"a\r\nb".to_vec()), b"a\nb\n");
213/// ```
214#[must_use]
215pub fn normalize_eol(mut data: Vec<u8>) -> Vec<u8> {
216    normalize_line_endings(&mut data);
217    data
218}
219
220/// Writes data to a file.
221///
222/// # Errors
223///
224/// Returns any [`std::io::Error`] surfaced by [`File::create`]
225/// (parent directory missing, lacks write permission, target is a
226/// directory, …) or by [`File::write_all`] while writing the buffer.
227///
228/// # Examples
229///
230/// ```no_run
231/// use std::path::Path;
232///
233/// use big_code_analysis::write_file;
234///
235/// let path = Path::new("foo.txt");
236/// let data: [u8; 4] = [0; 4];
237/// write_file(&path, &data).unwrap();
238/// ```
239pub fn write_file(path: &Path, data: &[u8]) -> std::io::Result<()> {
240    let mut file = File::create(path)?;
241    file.write_all(data)?;
242
243    Ok(())
244}
245
246/// Detects the language of a code using
247/// the extension of a file.
248///
249/// # Examples
250///
251/// ```
252/// use std::path::Path;
253///
254/// use big_code_analysis::get_language_for_file;
255///
256/// let path = Path::new("build.rs");
257/// get_language_for_file(&path).unwrap();
258/// ```
259#[must_use]
260pub fn get_language_for_file(path: &Path) -> Option<LANG> {
261    let ext = path.extension()?.to_str()?;
262    get_from_ext(&lowercase_ext(ext))
263}
264
265/// Borrows `ext` when it is already the ASCII-lowercase spelling
266/// [`get_from_ext`]'s table is keyed on, allocating only for the
267/// mixed-case minority (`foo.C`, `foo.PY`).
268///
269/// The `is_ascii` guard is load-bearing: [`str::to_lowercase`] folds
270/// `U+212A KELVIN SIGN` onto ASCII `k`, so a non-ASCII extension must
271/// keep that Unicode-aware path to leave the lookup key as it was before
272/// #1111. Past the guard the input is ASCII, hence the cheaper fold.
273fn lowercase_ext(ext: &str) -> Cow<'_, str> {
274    if !ext.is_ascii() {
275        Cow::Owned(ext.to_lowercase())
276    } else if ext.bytes().any(|b| b.is_ascii_uppercase()) {
277        Cow::Owned(ext.to_ascii_lowercase())
278    } else {
279        Cow::Borrowed(ext)
280    }
281}
282
283fn mode_to_str(mode: &[u8]) -> Option<String> {
284    std::str::from_utf8(mode).ok().map(str::to_lowercase)
285}
286
287// comment containing coding info are useful
288static RE1_EMACS: OnceLock<Regex> = OnceLock::new();
289static RE2_EMACS: OnceLock<Regex> = OnceLock::new();
290static RE1_VIM: OnceLock<Regex> = OnceLock::new();
291static RE_GENERATED: OnceLock<Regex> = OnceLock::new();
292
293// Regular expressions
294const FIRST_EMACS_EXPRESSION: &str = r"(?i)-\*-.*[^-\w]mode\s*:\s*([^:;\s]+)";
295const SECOND_EMACS_EXPRESSION: &str = r"-\*-\s*([^:;\s]+)\s*-\*-";
296const VIM_EXPRESSION: &str = r"(?i)vim\s*:.*[^\w]ft\s*=\s*([^:\s]+)";
297
298// Generated-code marker patterns. Matched against the leading window of the
299// file (see `is_generated`) so a marker phrase deep in the body does not
300// trigger a skip. Each alternative covers a widely-used convention:
301//
302// - `@generated`      — Facebook / Meta convention, also used by buck2,
303//                       rustfmt, prettier, and many code generators.
304// - `DO NOT EDIT`     — Go's `Code generated ... DO NOT EDIT.` line is
305//                       canonical, but the bare phrase appears in Bazel,
306//                       protoc, OpenAPI clients, etc. — match either.
307// - `GENERATED CODE`  — Lizard's marker; preserved for compatibility with
308//                       projects that already tag generated files this way.
309const GENERATED_EXPRESSION: &str = r"(?i)@generated\b|DO NOT EDIT|GENERATED CODE";
310
311/// Bytes from the start of the file scanned for a generated-code marker.
312/// 5 KiB is enough to cover any reasonable file header (license + autogen
313/// preamble) without paying a meaningful read cost.
314const GENERATED_SCAN_BYTES: usize = 5 * 1024;
315/// Maximum lines scanned for a generated-code marker. Caps the work on a
316/// pathological "all-on-one-line" file.
317const GENERATED_SCAN_LINES: usize = 50;
318
319/// Returns `true` when `buf` looks like generated code: its leading window
320/// (first ~50 lines or first 5 KiB, whichever is smaller) contains a known
321/// marker phrase. Matching is case-insensitive for the marker and never
322/// allocates on the negative path.
323///
324/// Recognized markers:
325///
326/// - `@generated` — Facebook / Meta convention, also used by buck2,
327///   rustfmt, and prettier.
328/// - `DO NOT EDIT` — Go's `Code generated by ... DO NOT EDIT.` is the
329///   canonical form; the bare phrase is also widely copied.
330/// - `GENERATED CODE` — Lizard's marker, preserved for compatibility.
331///
332/// Detection runs against raw bytes before parsing, so callers can discard
333/// generated files without paying tree-sitter parse cost. Non-UTF-8 input
334/// will not panic — `regex::bytes::Regex` operates on the raw byte slice.
335///
336/// # Examples
337///
338/// ```
339/// use big_code_analysis::is_generated;
340///
341/// assert!(is_generated(b"// @generated\nfn x() {}\n"));
342/// assert!(is_generated(
343///     b"// Code generated by protoc. DO NOT EDIT.\npackage x\n",
344/// ));
345/// assert!(!is_generated(b"fn main() { /* not generated */ }\n"));
346/// ```
347///
348/// # Panics
349///
350/// Panics if the embedded marker regex set fails to build; the marker
351/// list is a static literal so this represents a compile-time bug, not
352/// a runtime input that can be handled.
353pub fn is_generated(buf: &[u8]) -> bool {
354    // Strip a leading UTF-8 BOM so a marker on the first line of a
355    // BOM-prefixed file still matches against the line start. UTF-16 BOMs
356    // are not handled: the byte-pattern regex cannot match the
357    // interleaved-zero encoding (`@\x00g\x00...`) that follows a UTF-16
358    // BOM, so a strip would not enable detection — it would only obscure
359    // the fact that UTF-16 source files are unsupported here.
360    let buf = buf.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(buf);
361
362    // Bound the search window: at most GENERATED_SCAN_BYTES bytes, and
363    // among those, stop after GENERATED_SCAN_LINES newlines. Scanning fewer
364    // lines avoids matching a marker phrase deep in the file body (the
365    // negative case in the issue's acceptance criteria).
366    let cap = buf.len().min(GENERATED_SCAN_BYTES);
367    let end = buf[..cap]
368        .iter()
369        .enumerate()
370        .filter_map(|(i, &b)| (b == b'\n').then_some(i + 1))
371        .nth(GENERATED_SCAN_LINES - 1)
372        .unwrap_or(cap);
373    let window = &buf[..end];
374
375    RE_GENERATED
376        .get_or_init(|| {
377            Regex::new(GENERATED_EXPRESSION).expect("GENERATED_EXPRESSION is a constant regex")
378        })
379        .is_match(window)
380}
381
382#[inline]
383fn get_regex<'a>(
384    once_lock: &OnceLock<Regex>,
385    line: &'a [u8],
386    regex: &'a str,
387) -> Option<regex::bytes::Captures<'a>> {
388    once_lock
389        .get_or_init(|| Regex::new(regex).expect("constant regex pattern must compile"))
390        .captures(line)
391}
392
393/// Resolves a language from a script's shebang line.
394///
395/// Returns `None` unless `buf` starts with `#!`. Reads up to the first `\n`,
396/// strips an optional trailing `\r`, splits on whitespace, and takes the
397/// basename of either the first token or — when that basename is `env` — the
398/// next non-flag token. Trailing version digits and dots (`python3`,
399/// `lua5.1`, `perl5.36`) are stripped before lookup. Non-UTF-8 bytes on the
400/// shebang line yield `None` (no panic).
401fn get_shebang_lang(buf: &[u8]) -> Option<LANG> {
402    // Early-out for the common case (any non-shebang buffer): no allocation,
403    // no UTF-8 decoding.
404    let rest = buf.strip_prefix(b"#!")?;
405    let line_end = rest.iter().position(|&b| b == b'\n').unwrap_or(rest.len());
406    let line = &rest[..line_end];
407    // Trim a trailing CR even though normalize_line_endings should have removed
408    // it — guess_language is on the public API and may be called with raw input.
409    let line = line.strip_suffix(b"\r").unwrap_or(line);
410    let line = std::str::from_utf8(line).ok()?;
411
412    let mut tokens = line.split_ascii_whitespace();
413    let first_base = basename(tokens.next()?);
414
415    let interpreter = if first_base == "env" {
416        skip_env_args(&mut tokens)?
417    } else {
418        first_base
419    };
420
421    get_from_interpreter(strip_version_suffix(interpreter))
422}
423
424// Walk past leading `env` arguments (`-FLAG`, `-u VAR`, `NAME=value`) and
425// return the basename of the actual interpreter token. Per `env(1)`, only
426// `-u` consumes a following argument; other short flags (`-i`, `-S`, …)
427// stand alone or carry their argument inline (e.g. `-S "node --foo"`).
428fn skip_env_args<'a>(tokens: &mut std::str::SplitAsciiWhitespace<'a>) -> Option<&'a str> {
429    loop {
430        let tok = tokens.next()?;
431        if let Some(flag) = tok.strip_prefix('-') {
432            if flag == "u" {
433                tokens.next()?;
434            }
435            continue;
436        }
437        if tok.contains('=') {
438            continue;
439        }
440        return Some(basename(tok));
441    }
442}
443
444fn basename(path: &str) -> &str {
445    path.rsplit_once('/').map_or(path, |(_, name)| name)
446}
447
448/// Strips a trailing run of digits and dots used to encode an interpreter
449/// version (`python3` → `python`, `lua5.1` → `lua`, `perl5.36` → `perl`).
450fn strip_version_suffix(name: &str) -> &str {
451    let trimmed = name.trim_end_matches(|c: char| c.is_ascii_digit() || c == '.');
452    if trimmed.is_empty() { name } else { trimmed }
453}
454
455fn get_from_interpreter(name: &str) -> Option<LANG> {
456    match name {
457        "sh" | "bash" | "dash" | "ksh" | "zsh" => Some(LANG::Bash),
458        "python" => Some(LANG::Python),
459        "perl" => Some(LANG::Perl),
460        "lua" | "luajit" => Some(LANG::Lua),
461        "php" | "php-cgi" => Some(LANG::Php),
462        "node" | "nodejs" => Some(LANG::Javascript),
463        "tclsh" | "wish" => Some(LANG::Tcl),
464        "ruby" => Some(LANG::Ruby),
465        "elixir" | "iex" => Some(LANG::Elixir),
466        _ => None,
467    }
468}
469
470// Editors place mode/file-local-variable lines near the very top or
471// very bottom of a file. Emacs honours the first non-shebang line and a
472// trailing "Local Variables:" block; Vim honours modelines in the first
473// or last few lines (`modelines` defaults to 5). Scanning this many real
474// lines at each end covers both conventions without trawling the body.
475const MODE_LINE_SCAN_WINDOW: usize = 5;
476
477// Entries into `get_emacs_mode` — the only thing able to observe #1111's
478// laziness: every assertion on what `guess_language` returns holds just as
479// well when the scan runs eagerly and throws the result away. Read by the
480// test that pins it, which carries why.
481crate::observation::counter!(modeline_scans);
482
483fn get_emacs_mode(buf: &[u8]) -> Option<String> {
484    modeline_scans::record();
485    // Forward scan: the first `MODE_LINE_SCAN_WINDOW` real lines may carry
486    // an emacs `-*- … -*-` header or a Vim modeline. `split` yields one
487    // element per line (no unbounded remainder), and `take` bounds the
488    // window precisely — the former `splitn(5)` + `i == 3` break inspected
489    // only 4 lines yet split off a 5th unbounded remainder (issue #709).
490    for line in buf.split(|c| *c == b'\n').take(MODE_LINE_SCAN_WINDOW) {
491        if let Some(cap) = get_regex(&RE1_EMACS, line, FIRST_EMACS_EXPRESSION) {
492            return mode_to_str(&cap[1]);
493        } else if let Some(cap) = get_regex(&RE2_EMACS, line, SECOND_EMACS_EXPRESSION) {
494            return mode_to_str(&cap[1]);
495        } else if let Some(cap) = get_regex(&RE1_VIM, line, VIM_EXPRESSION) {
496            return mode_to_str(&cap[1]);
497        }
498    }
499
500    // Backward scan for a trailing Vim modeline. Skip empty pieces so a
501    // trailing newline (the common case after `read_file_with_eol`) and
502    // any trailing blank lines do not consume the window before a real
503    // modeline is reached — the former `rsplitn(5)` spent its first slot
504    // on that empty piece, covering fewer than the intended real lines.
505    for line in buf
506        .rsplit(|c| *c == b'\n')
507        .filter(|line| !line.is_empty())
508        .take(MODE_LINE_SCAN_WINDOW)
509    {
510        if let Some(cap) = get_regex(&RE1_VIM, line, VIM_EXPRESSION) {
511            return mode_to_str(&cap[1]);
512        }
513    }
514
515    None
516}
517
518/// Guesses the language of a code.
519///
520/// Returns a tuple containing a [`LANG`] as first argument
521/// and the language name as a second one.
522///
523/// # Examples
524///
525/// ```
526/// use std::path::PathBuf;
527///
528/// use big_code_analysis::guess_language;
529///
530/// let source_code = "int a = 42;";
531///
532/// // The path to a dummy file used to contain the source code
533/// let path = PathBuf::from("foo.c");
534/// let source_slice = source_code.as_bytes();
535///
536/// // Guess the language of a code
537/// guess_language(&source_slice, &path);
538/// ```
539///
540/// [`LANG`]: enum.LANG.html
541pub fn guess_language<P: AsRef<Path>>(buf: &[u8], path: P) -> (Option<LANG>, &'static str) {
542    // Precedence: extension, then emacs/vim modeline, then shebang. The
543    // fallbacks are lazy, so a recognised extension never pays for the
544    // modeline scan; the previous form ran it for every file and
545    // discarded it, every arm of its extension branch having returned the
546    // extension's language — the "modeline agrees" arm included (#1111).
547    let lang = get_language_for_file(path.as_ref())
548        .or_else(|| get_emacs_mode(buf).and_then(|mode| get_from_emacs_mode(&mode)))
549        .or_else(|| get_shebang_lang(buf));
550
551    lang.map_or((None, ""), |lang| (Some(lang), lang.name()))
552}
553
554/// Normalises all CR-only and CRLF line endings to LF throughout the buffer,
555/// then ensures the buffer ends with exactly one `\n`.
556pub(crate) fn normalize_line_endings(data: &mut Vec<u8>) {
557    // In-place compaction: write pointer stays ≤ read pointer, so no extra allocation.
558    let mut w = 0;
559    let mut r = 0;
560    while r < data.len() {
561        if data[r] == b'\r' {
562            data[w] = b'\n';
563            w += 1;
564            r += if data.get(r + 1).copied() == Some(b'\n') {
565                2
566            } else {
567                1
568            };
569        } else {
570            data[w] = data[r];
571            w += 1;
572            r += 1;
573        }
574    }
575    data.truncate(w);
576    let trailing = data.iter().rev().take_while(|&&c| c == b'\n').count();
577    data.truncate(data.len() - trailing);
578    data.push(b'\n');
579}
580
581pub(crate) fn normalize_path<P: AsRef<Path>>(path: P) -> PathBuf {
582    // Copied from Cargo sources: https://github.com/rust-lang/cargo/blob/master/src/cargo/util/paths.rs#L65
583    let mut components = path.as_ref().components().peekable();
584    let mut ret = if let Some(c @ Component::Prefix(..)) = components.peek().copied() {
585        components.next();
586        PathBuf::from(c.as_os_str())
587    } else {
588        PathBuf::new()
589    };
590
591    for component in components {
592        match component {
593            // A `Prefix` (Windows drive / UNC) component only ever
594            // appears first; the leading peek+next above already
595            // consumed it, so it cannot recur in this loop.
596            Component::Prefix(..) => unreachable!(),
597            Component::RootDir => {
598                ret.push(component.as_os_str());
599            }
600            Component::CurDir => {}
601            Component::ParentDir => {
602                ret.pop();
603            }
604            Component::Normal(c) => {
605                ret.push(c);
606            }
607        }
608    }
609    ret
610}
611
612pub(crate) fn get_paths_dist(path1: &Path, path2: &Path) -> Option<usize> {
613    for ancestor in path1.ancestors() {
614        if path2.starts_with(ancestor) && !ancestor.as_os_str().is_empty() {
615            // `ancestor` is yielded by `path1.ancestors()`, so it is
616            // a prefix of `path1` by construction; `path2` was just
617            // verified by `starts_with` above. Both `strip_prefix`
618            // calls are therefore infallible.
619            let path1 = path1
620                .strip_prefix(ancestor)
621                .expect("ancestor is by construction a prefix of path1");
622            let path2 = path2
623                .strip_prefix(ancestor)
624                .expect("ancestor verified by starts_with above");
625            return Some(path1.components().count() + path2.components().count());
626        }
627    }
628    None
629}
630
631pub(crate) fn guess_file<S: ::std::hash::BuildHasher>(
632    current_path: &Path,
633    include_path: &str,
634    all_files: &HashMap<String, Vec<PathBuf>, S>,
635) -> Vec<PathBuf> {
636    let include_path = include_path
637        .strip_prefix("mozilla/")
638        .unwrap_or(include_path);
639
640    // Resolve the include relative to the including file's parent
641    // before normalizing. This preserves leading `..` traversal so
642    // `#include "../foo.h"` from `src/lib/file.c` targets
643    // `src/foo.h`, not the lexically-popped `foo.h` (issue #297).
644    // Lexical-only normalization is required because `current_path`
645    // and the entries in `all_files` are typically not canonicalized
646    // and the included header need not exist on disk yet.
647    let resolved_path = current_path
648        .parent()
649        .map(|parent| normalize_path(parent.join(include_path)));
650
651    let include_path = normalize_path(include_path);
652    let Some(file_name) = include_path.file_name().and_then(|n| n.to_str()) else {
653        return vec![];
654    };
655    let Some(possibilities) = all_files.get(file_name) else {
656        return vec![];
657    };
658    if possibilities.len() == 1 {
659        return possibilities.clone();
660    }
661
662    // Strategy chain: each step looks for a UNIQUE candidate that
663    // matches a progressively weaker signal (full resolved target →
664    // suffix on the normalized include → siblings of the including
665    // file). When no step yields a unique match, fall back to the
666    // closest by path distance, which may return zero or many.
667    resolve_against_resolved(possibilities, current_path, resolved_path.as_deref())
668        .or_else(|| unique_filter(possibilities, current_path, |p| p.ends_with(&include_path)))
669        .or_else(|| resolve_against_parent(possibilities, current_path))
670        .unwrap_or_else(|| min_distance_candidates(possibilities, current_path))
671}
672
673/// Filter `possibilities` to those satisfying `pred` and distinct
674/// from `current_path`, returning `Some(matched)` only when exactly
675/// one survives. The cascading caller treats `None` as "this strategy
676/// did not yield a unique resolution — try the next one."
677fn unique_filter<F>(possibilities: &[PathBuf], current_path: &Path, pred: F) -> Option<Vec<PathBuf>>
678where
679    F: Fn(&PathBuf) -> bool,
680{
681    let matched: Vec<PathBuf> = possibilities
682        .iter()
683        .filter(|p| current_path != p.as_path() && pred(p))
684        .cloned()
685        .collect();
686    (matched.len() == 1).then_some(matched)
687}
688
689/// Strongest signal: a candidate matches the fully resolved relative
690/// target. Prefer exact equality, then suffix match (so absolute
691/// `all_files` entries still match a relative resolved target like
692/// `src/foo.h`).
693fn resolve_against_resolved(
694    possibilities: &[PathBuf],
695    current_path: &Path,
696    resolved: Option<&Path>,
697) -> Option<Vec<PathBuf>> {
698    let resolved = resolved?;
699    unique_filter(possibilities, current_path, |p| p == resolved)
700        .or_else(|| unique_filter(possibilities, current_path, |p| p.ends_with(resolved)))
701}
702
703/// Candidate-in-same-directory heuristic: keep entries whose path
704/// starts with the including file's parent directory.
705fn resolve_against_parent(possibilities: &[PathBuf], current_path: &Path) -> Option<Vec<PathBuf>> {
706    let parent = current_path.parent()?;
707    unique_filter(possibilities, current_path, |p| p.starts_with(parent))
708}
709
710/// Last-chance fallback in the `guess_file` strategy chain: returns
711/// every candidate whose `get_paths_dist` from `current_path` ties
712/// the minimum, or an empty `Vec` when no candidate has a defined
713/// distance. Unlike the unique-match strategies, this may
714/// legitimately return zero or many entries — its result is the
715/// function's final answer, not a "try the next strategy" signal.
716fn min_distance_candidates(possibilities: &[PathBuf], current_path: &Path) -> Vec<PathBuf> {
717    // Hold survivors as borrows during the walk: `Less` arms clear the
718    // prior set without dropping owned `PathBuf`s, and the trailing
719    // `cloned()` runs exactly once per final survivor — never on
720    // entries that were tentatively kept and later evicted.
721    let mut dist_min = usize::MAX;
722    let mut path_min: Vec<&PathBuf> = Vec::new();
723    for p in possibilities {
724        if current_path == p {
725            continue;
726        }
727        let Some(dist) = get_paths_dist(current_path, p) else {
728            continue;
729        };
730        match dist.cmp(&dist_min) {
731            Ordering::Less => {
732                dist_min = dist;
733                path_min.clear();
734                path_min.push(p);
735            }
736            Ordering::Equal => path_min.push(p),
737            Ordering::Greater => {}
738        }
739    }
740    path_min.into_iter().cloned().collect()
741}
742
743// Accept `&mut dyn WriteColor` rather than `&mut StandardStreamLock` so
744// tests (e.g. `function::dump_spans`) can substitute `termcolor::NoColor`
745// over a `Vec<u8>` to capture the rendered bytes. Production callers
746// continue to pass `&mut StandardStreamLock`, which unsized-coerces to
747// the trait object at the call site.
748#[inline]
749pub(crate) fn color(stdout: &mut dyn WriteColor, color: Color) -> std::io::Result<()> {
750    stdout.set_color(ColorSpec::new().set_fg(Some(color)))
751}
752
753#[inline]
754pub(crate) fn intense_color(stdout: &mut dyn WriteColor, color: Color) -> std::io::Result<()> {
755    stdout.set_color(ColorSpec::new().set_fg(Some(color)).set_intense(true))
756}
757
758#[cfg(test)]
759#[path = "tools_tests.rs"]
760mod tests;