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 the [`SkipReason`] that rejects the file.
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(
97 start: &[u8],
98 file_size: usize,
99 probe_len: usize,
100) -> Result<&[u8], SkipReason> {
101 let start = if start.starts_with(b"\xFE\xFF") || start.starts_with(b"\xFF\xFE") {
102 return Err(SkipReason::Utf16Bom);
103 } else if let Some(rest) = start.strip_prefix(b"\xEF\xBB\xBF") {
104 rest
105 } else {
106 start
107 };
108
109 let probe_truncated = file_size > probe_len;
110 match std::str::from_utf8(start) {
111 Ok(_) => {}
112 // Only a trailing incomplete multibyte sequence: the bytes before
113 // `valid_up_to()` are valid UTF-8 and the truncated tail is
114 // completed by data later in the file.
115 Err(e) if e.error_len().is_none() && probe_truncated => {}
116 Err(_) => return Err(SkipReason::NotUtf8),
117 }
118 Ok(start)
119}
120
121/// Why [`read_file_with_eol_classified`] declined to hand a file's bytes to
122/// the metric engine.
123///
124/// [`read_file_with_eol`] collapses every one of these into `Ok(None)`, which
125/// left front-ends unable to name the real cause — `bca` reported a
126/// multi-kilobyte binary and a one-byte source alike as "empty" (#1287). This
127/// enum is the classified form of that same decision; the variants map 1:1 onto
128/// the skip branches of [`read_file_with_eol_classified`].
129///
130/// Marked `#[non_exhaustive]`: a future gate (a size ceiling, a further
131/// encoding) adds a variant additively, so match on it with a wildcard arm or
132/// render it through [`Display`](std::fmt::Display).
133#[non_exhaustive]
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
135pub enum SkipReason {
136 /// The file is zero bytes long.
137 Empty,
138 /// The file holds 1–3 bytes — too little to be worth parsing — or it
139 /// shrank below the UTF-8 probe window between the `stat` and the read.
140 TooSmall,
141 /// The file opens with a UTF-16 BE or LE byte-order mark. Its
142 /// interleaved-NUL body would pass a byte-level UTF-8 check and reach the
143 /// parser as garbage, so it is skipped rather than decoded (#803).
144 Utf16Bom,
145 /// The file's leading window is not valid UTF-8 — the shape a binary file
146 /// takes here.
147 NotUtf8,
148}
149
150impl SkipReason {
151 /// Which small-file reason applies to a file of `size` bytes: zero bytes
152 /// is [`Empty`](Self::Empty), anything else under the gate is
153 /// [`TooSmall`](Self::TooSmall).
154 fn for_size(size: usize) -> Self {
155 if size == 0 {
156 Self::Empty
157 } else {
158 Self::TooSmall
159 }
160 }
161}
162
163impl std::fmt::Display for SkipReason {
164 /// Renders the reason as a noun phrase naming the skipped file, so a
165 /// caller can splice it into a diagnostic: `skipping {reason}: {path}`.
166 ///
167 /// Per the project diagnostic convention the phrase carries no severity
168 /// word — the presenting layer owns that prefix.
169 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170 let phrase = match self {
171 Self::Empty => "empty file",
172 Self::TooSmall => "file too small to analyze (3 bytes or fewer)",
173 Self::Utf16Bom => "UTF-16 file (unsupported encoding)",
174 Self::NotUtf8 => "file with non-UTF-8 contents",
175 };
176 f.write_str(phrase)
177 }
178}
179
180/// Reads a file, normalising all CR-only and CRLF line endings to LF, and ensures
181/// the buffer ends with exactly one `\n`. Returns `None` for readable files
182/// ≤ 3 bytes or files that appear to be non-UTF-8.
183///
184/// `None` names no reason. Use [`read_file_with_eol_classified`] — of which
185/// this function is a thin projection — when the caller reports the skip to a
186/// user, so an empty file, a one-byte source, a binary blob and a UTF-16 file
187/// are not all announced as "empty" (#1287).
188///
189/// # Errors
190///
191/// Returns any [`std::io::Error`] surfaced by [`File::open`] (the
192/// path is missing, lacks read permission, is a directory, …) or by
193/// the subsequent reads from the open file handle. A clean short read
194/// during the probe (`UnexpectedEof`) yields `Ok(None)`; any other
195/// `read_exact` error kind propagates as `Err`. A non-UTF-8 head, a
196/// too-small file, or a UTF-16 BE/LE BOM is reported via `Ok(None)`,
197/// not an error — but "too small" is decided only for a file that can
198/// be opened, so an unreadable one errors instead (#1060).
199///
200/// # Examples
201///
202/// ```
203/// use std::path::Path;
204///
205/// use big_code_analysis::read_file_with_eol;
206///
207/// let path = Path::new("Cargo.toml");
208/// read_file_with_eol(&path).unwrap();
209/// ```
210pub fn read_file_with_eol(path: &Path) -> std::io::Result<Option<Vec<u8>>> {
211 read_file_with_eol_classified(path).map(Result::ok)
212}
213
214/// Reads a file exactly as [`read_file_with_eol`] does, but names the reason
215/// when the file is skipped instead of collapsing every skip into `None`.
216///
217/// The outer `Result` is the I/O outcome; the inner one is the analysis
218/// outcome — `Ok(bytes)` for a file the metric engine can parse, or
219/// `Err(`[`SkipReason`]`)` for one it declines. [`read_file_with_eol`] is
220/// `read_file_with_eol_classified(path).map(Result::ok)`, so the two agree
221/// branch for branch.
222///
223/// # Errors
224///
225/// Identical to [`read_file_with_eol`]: any [`std::io::Error`] from
226/// [`File::open`] or the subsequent reads propagates. Skips are *not* errors —
227/// they arrive as `Ok(Err(reason))`.
228///
229/// # Examples
230///
231/// ```
232/// use std::path::Path;
233///
234/// use big_code_analysis::{SkipReason, read_file_with_eol_classified};
235///
236/// let dir = tempfile::tempdir().unwrap();
237/// let path = dir.path().join("tiny.rs");
238/// std::fs::write(&path, b"x").unwrap();
239///
240/// assert_eq!(
241/// read_file_with_eol_classified(&path).unwrap(),
242/// Err(SkipReason::TooSmall)
243/// );
244/// ```
245pub fn read_file_with_eol_classified(path: &Path) -> std::io::Result<Result<Vec<u8>, SkipReason>> {
246 match read_gated(path) {
247 Ok(data) => Ok(Ok(data)),
248 Err(ReadStop::Skip(reason)) => Ok(Err(reason)),
249 Err(ReadStop::Io(err)) => Err(err),
250 }
251}
252
253/// The two ways [`read_gated`] stops short of returning bytes. Collapsing
254/// them into one `Err` is what lets that function spell every gate as `?`
255/// instead of hand-wrapping each early return in the public
256/// `Ok(Err(reason))` / `Err(err)` shape; the split back out happens once, in
257/// [`read_file_with_eol_classified`].
258enum ReadStop {
259 /// A genuine I/O failure, propagated to the caller as `Err`.
260 Io(std::io::Error),
261 /// The file was read far enough to decide the metric engine should not
262 /// see it. Not an error (issue #804).
263 Skip(SkipReason),
264}
265
266impl From<std::io::Error> for ReadStop {
267 fn from(err: std::io::Error) -> Self {
268 Self::Io(err)
269 }
270}
271
272impl From<SkipReason> for ReadStop {
273 fn from(reason: SkipReason) -> Self {
274 Self::Skip(reason)
275 }
276}
277
278/// Applies the pre-parse gates in order — size, readability, UTF-8 probe —
279/// and returns the EOL-normalised contents of a file that clears them all.
280fn read_gated(path: &Path) -> Result<Vec<u8>, ReadStop> {
281 let meta = fs::metadata(path);
282 let file_size = meta.as_ref().map_or(1024 * 1024, |m| m.len() as usize);
283 if file_size <= 3 {
284 // Nothing worth parsing this small — but `stat` alone must not
285 // decide it: `stat` succeeds on a file the process cannot open,
286 // so an unreadable tiny file read as empty and `bca check`
287 // exited 0 on a tree it never read (#1060). `meta` is
288 // necessarily `Ok` here; the open is a discarded readability
289 // probe, skipped for non-regular files because a FIFO blocks.
290 if meta.is_ok_and(|m| m.is_file()) {
291 File::open(path)?;
292 }
293 return Err(SkipReason::for_size(file_size).into());
294 }
295
296 let mut file = File::open(path)?;
297
298 let probe_len = UTF8_PROBE_BYTES.min(file_size);
299 let mut start = vec![0; probe_len];
300 // A clean short read (the file shrank below the probe between the
301 // `metadata` call and here) is reported as a skip, matching the
302 // too-small-file case. Any other `read_exact` failure — a real I/O
303 // fault such as a permission or hardware error — must propagate as
304 // `Err` per the documented contract (issue #804); collapsing every
305 // error to a skip would silently swallow genuine read failures.
306 match file.read_exact(&mut start) {
307 Ok(()) => {}
308 Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
309 return Err(SkipReason::TooSmall.into());
310 }
311 Err(e) => return Err(e.into()),
312 }
313
314 // Sniff the probe: reject UTF-16 / corrupt files, strip a UTF-8 BOM,
315 // and anchor the buffer at the post-BOM content. An `Err` names why
316 // the file is skipped (see `probe_decodable_prefix`).
317 let start = probe_decodable_prefix(&start, file_size, probe_len)?;
318
319 let mut data = Vec::with_capacity(file_size + 2);
320 data.extend_from_slice(start);
321
322 file.read_to_end(&mut data)?;
323
324 normalize_line_endings(&mut data);
325
326 Ok(data)
327}
328
329/// Normalises an in-memory source buffer to match the [`read_file_with_eol`]
330/// on-disk path: all CR-only and CRLF line endings become LF, and the buffer is
331/// guaranteed to end with exactly one `\n`.
332///
333/// In-memory entry points (the web server's JSON/octet-stream payloads, the
334/// Python `analyze_source` bindings) feed caller-supplied bytes straight to the
335/// parser, whereas the CLI reads files through [`read_file_with_eol`]. Without
336/// this step, identical content yields different metrics across surfaces — an
337/// editor buffer with no trailing newline reports `sloc: 0` over the wire but
338/// `sloc: 1` from the CLI on the same bytes (issue #640). Run the buffer through
339/// this helper before parsing so every surface computes the canonical numbers.
340///
341/// Returns a fresh owned buffer; the input is consumed so the common case
342/// (already-owned request body) reuses its allocation.
343///
344/// # Examples
345///
346/// ```
347/// use big_code_analysis::normalize_eol;
348///
349/// // CRLF endings collapse to LF and a missing final newline is added.
350/// assert_eq!(normalize_eol(b"a\r\nb".to_vec()), b"a\nb\n");
351/// ```
352#[must_use]
353pub fn normalize_eol(mut data: Vec<u8>) -> Vec<u8> {
354 normalize_line_endings(&mut data);
355 data
356}
357
358/// Writes data to a file.
359///
360/// # Errors
361///
362/// Returns any [`std::io::Error`] surfaced by [`File::create`]
363/// (parent directory missing, lacks write permission, target is a
364/// directory, …) or by [`File::write_all`] while writing the buffer.
365///
366/// # Examples
367///
368/// ```no_run
369/// use std::path::Path;
370///
371/// use big_code_analysis::write_file;
372///
373/// let path = Path::new("foo.txt");
374/// let data: [u8; 4] = [0; 4];
375/// write_file(&path, &data).unwrap();
376/// ```
377pub fn write_file(path: &Path, data: &[u8]) -> std::io::Result<()> {
378 let mut file = File::create(path)?;
379 file.write_all(data)?;
380
381 Ok(())
382}
383
384/// Detects the language of a code using
385/// the extension of a file.
386///
387/// # Examples
388///
389/// ```
390/// use std::path::Path;
391///
392/// use big_code_analysis::get_language_for_file;
393///
394/// let path = Path::new("build.rs");
395/// get_language_for_file(&path).unwrap();
396/// ```
397#[must_use]
398pub fn get_language_for_file(path: &Path) -> Option<LANG> {
399 let ext = path.extension()?.to_str()?;
400 get_from_ext(&lowercase_ext(ext))
401}
402
403/// Borrows `ext` when it is already the ASCII-lowercase spelling
404/// [`get_from_ext`]'s table is keyed on, allocating only for the
405/// mixed-case minority (`foo.C`, `foo.PY`).
406///
407/// The `is_ascii` guard is load-bearing: [`str::to_lowercase`] folds
408/// `U+212A KELVIN SIGN` onto ASCII `k`, so a non-ASCII extension must
409/// keep that Unicode-aware path to leave the lookup key as it was before
410/// #1111. Past the guard the input is ASCII, hence the cheaper fold.
411fn lowercase_ext(ext: &str) -> Cow<'_, str> {
412 if !ext.is_ascii() {
413 Cow::Owned(ext.to_lowercase())
414 } else if ext.bytes().any(|b| b.is_ascii_uppercase()) {
415 Cow::Owned(ext.to_ascii_lowercase())
416 } else {
417 Cow::Borrowed(ext)
418 }
419}
420
421fn mode_to_str(mode: &[u8]) -> Option<String> {
422 std::str::from_utf8(mode).ok().map(str::to_lowercase)
423}
424
425// comment containing coding info are useful
426static RE1_EMACS: OnceLock<Regex> = OnceLock::new();
427static RE2_EMACS: OnceLock<Regex> = OnceLock::new();
428static RE1_VIM: OnceLock<Regex> = OnceLock::new();
429static RE_GENERATED: OnceLock<Regex> = OnceLock::new();
430
431// Regular expressions
432const FIRST_EMACS_EXPRESSION: &str = r"(?i)-\*-.*[^-\w]mode\s*:\s*([^:;\s]+)";
433const SECOND_EMACS_EXPRESSION: &str = r"-\*-\s*([^:;\s]+)\s*-\*-";
434const VIM_EXPRESSION: &str = r"(?i)vim\s*:.*[^\w]ft\s*=\s*([^:\s]+)";
435
436// Generated-code marker patterns. Matched against the leading window of the
437// file (see `is_generated`) so a marker phrase deep in the body does not
438// trigger a skip. Each alternative covers a widely-used convention:
439//
440// - `@generated` — Facebook / Meta convention, also used by buck2,
441// rustfmt, prettier, and many code generators.
442// - `DO NOT EDIT` — Go's `Code generated ... DO NOT EDIT.` line is
443// canonical, but the bare phrase appears in Bazel,
444// protoc, OpenAPI clients, etc. — match either.
445// - `GENERATED CODE` — Lizard's marker; preserved for compatibility with
446// projects that already tag generated files this way.
447const GENERATED_EXPRESSION: &str = r"(?i)@generated\b|DO NOT EDIT|GENERATED CODE";
448
449/// Bytes from the start of the file scanned for a generated-code marker.
450/// 5 KiB is enough to cover any reasonable file header (license + autogen
451/// preamble) without paying a meaningful read cost.
452const GENERATED_SCAN_BYTES: usize = 5 * 1024;
453/// Maximum lines scanned for a generated-code marker. Caps the work on a
454/// pathological "all-on-one-line" file.
455const GENERATED_SCAN_LINES: usize = 50;
456
457/// Returns `true` when `buf` looks like generated code: its leading window
458/// (first ~50 lines or first 5 KiB, whichever is smaller) contains a known
459/// marker phrase. Matching is case-insensitive for the marker and never
460/// allocates on the negative path.
461///
462/// Recognized markers:
463///
464/// - `@generated` — Facebook / Meta convention, also used by buck2,
465/// rustfmt, and prettier.
466/// - `DO NOT EDIT` — Go's `Code generated by ... DO NOT EDIT.` is the
467/// canonical form; the bare phrase is also widely copied.
468/// - `GENERATED CODE` — Lizard's marker, preserved for compatibility.
469///
470/// Detection runs against raw bytes before parsing, so callers can discard
471/// generated files without paying tree-sitter parse cost. Non-UTF-8 input
472/// will not panic — `regex::bytes::Regex` operates on the raw byte slice.
473///
474/// # Examples
475///
476/// ```
477/// use big_code_analysis::is_generated;
478///
479/// assert!(is_generated(b"// @generated\nfn x() {}\n"));
480/// assert!(is_generated(
481/// b"// Code generated by protoc. DO NOT EDIT.\npackage x\n",
482/// ));
483/// assert!(!is_generated(b"fn main() { /* not generated */ }\n"));
484/// ```
485///
486/// # Panics
487///
488/// Panics if the embedded marker regex set fails to build; the marker
489/// list is a static literal so this represents a compile-time bug, not
490/// a runtime input that can be handled.
491pub fn is_generated(buf: &[u8]) -> bool {
492 // Strip a leading UTF-8 BOM so a marker on the first line of a
493 // BOM-prefixed file still matches against the line start. UTF-16 BOMs
494 // are not handled: the byte-pattern regex cannot match the
495 // interleaved-zero encoding (`@\x00g\x00...`) that follows a UTF-16
496 // BOM, so a strip would not enable detection — it would only obscure
497 // the fact that UTF-16 source files are unsupported here.
498 let buf = buf.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(buf);
499
500 // Bound the search window: at most GENERATED_SCAN_BYTES bytes, and
501 // among those, stop after GENERATED_SCAN_LINES newlines. Scanning fewer
502 // lines avoids matching a marker phrase deep in the file body (the
503 // negative case in the issue's acceptance criteria).
504 let cap = buf.len().min(GENERATED_SCAN_BYTES);
505 let end = buf[..cap]
506 .iter()
507 .enumerate()
508 .filter_map(|(i, &b)| (b == b'\n').then_some(i + 1))
509 .nth(GENERATED_SCAN_LINES - 1)
510 .unwrap_or(cap);
511 let window = &buf[..end];
512
513 RE_GENERATED
514 .get_or_init(|| {
515 Regex::new(GENERATED_EXPRESSION).expect("GENERATED_EXPRESSION is a constant regex")
516 })
517 .is_match(window)
518}
519
520#[inline]
521fn get_regex<'a>(
522 once_lock: &OnceLock<Regex>,
523 line: &'a [u8],
524 regex: &'a str,
525) -> Option<regex::bytes::Captures<'a>> {
526 once_lock
527 .get_or_init(|| Regex::new(regex).expect("constant regex pattern must compile"))
528 .captures(line)
529}
530
531/// Resolves a language from a script's shebang line.
532///
533/// Returns `None` unless `buf` starts with `#!`. Reads up to the first `\n`,
534/// strips an optional trailing `\r`, splits on whitespace, and takes the
535/// basename of either the first token or — when that basename is `env` — the
536/// next non-flag token. Trailing version digits and dots (`python3`,
537/// `lua5.1`, `perl5.36`) are stripped before lookup. Non-UTF-8 bytes on the
538/// shebang line yield `None` (no panic).
539fn get_shebang_lang(buf: &[u8]) -> Option<LANG> {
540 // Early-out for the common case (any non-shebang buffer): no allocation,
541 // no UTF-8 decoding.
542 let rest = buf.strip_prefix(b"#!")?;
543 let line_end = rest.iter().position(|&b| b == b'\n').unwrap_or(rest.len());
544 let line = &rest[..line_end];
545 // Trim a trailing CR even though normalize_line_endings should have removed
546 // it — guess_language is on the public API and may be called with raw input.
547 let line = line.strip_suffix(b"\r").unwrap_or(line);
548 let line = std::str::from_utf8(line).ok()?;
549
550 let mut tokens = line.split_ascii_whitespace();
551 let first_base = basename(tokens.next()?);
552
553 let interpreter = if first_base == "env" {
554 skip_env_args(&mut tokens)?
555 } else {
556 first_base
557 };
558
559 get_from_interpreter(strip_version_suffix(interpreter))
560}
561
562// Walk past leading `env` arguments (`-FLAG`, `-u VAR`, `NAME=value`) and
563// return the basename of the actual interpreter token. Per `env(1)`, only
564// `-u` consumes a following argument; other short flags (`-i`, `-S`, …)
565// stand alone or carry their argument inline (e.g. `-S "node --foo"`).
566fn skip_env_args<'a>(tokens: &mut std::str::SplitAsciiWhitespace<'a>) -> Option<&'a str> {
567 loop {
568 let tok = tokens.next()?;
569 if let Some(flag) = tok.strip_prefix('-') {
570 if flag == "u" {
571 tokens.next()?;
572 }
573 continue;
574 }
575 if tok.contains('=') {
576 continue;
577 }
578 return Some(basename(tok));
579 }
580}
581
582fn basename(path: &str) -> &str {
583 path.rsplit_once('/').map_or(path, |(_, name)| name)
584}
585
586/// Strips a trailing run of digits and dots used to encode an interpreter
587/// version (`python3` → `python`, `lua5.1` → `lua`, `perl5.36` → `perl`).
588fn strip_version_suffix(name: &str) -> &str {
589 let trimmed = name.trim_end_matches(|c: char| c.is_ascii_digit() || c == '.');
590 if trimmed.is_empty() { name } else { trimmed }
591}
592
593fn get_from_interpreter(name: &str) -> Option<LANG> {
594 match name {
595 "sh" | "bash" | "dash" | "ksh" | "zsh" => Some(LANG::Bash),
596 "python" => Some(LANG::Python),
597 "perl" => Some(LANG::Perl),
598 "lua" | "luajit" => Some(LANG::Lua),
599 "php" | "php-cgi" => Some(LANG::Php),
600 "node" | "nodejs" => Some(LANG::Javascript),
601 "tclsh" | "wish" => Some(LANG::Tcl),
602 "ruby" => Some(LANG::Ruby),
603 "elixir" | "iex" => Some(LANG::Elixir),
604 _ => None,
605 }
606}
607
608// Editors place mode/file-local-variable lines near the very top or
609// very bottom of a file. Emacs honours the first non-shebang line and a
610// trailing "Local Variables:" block; Vim honours modelines in the first
611// or last few lines (`modelines` defaults to 5). Scanning this many real
612// lines at each end covers both conventions without trawling the body.
613const MODE_LINE_SCAN_WINDOW: usize = 5;
614
615// Entries into `get_emacs_mode` — the only thing able to observe #1111's
616// laziness: every assertion on what `guess_language` returns holds just as
617// well when the scan runs eagerly and throws the result away. Read by the
618// test that pins it, which carries why.
619crate::observation::counter!(modeline_scans);
620
621fn get_emacs_mode(buf: &[u8]) -> Option<String> {
622 modeline_scans::record();
623 // Forward scan: the first `MODE_LINE_SCAN_WINDOW` real lines may carry
624 // an emacs `-*- … -*-` header or a Vim modeline. `split` yields one
625 // element per line (no unbounded remainder), and `take` bounds the
626 // window precisely — the former `splitn(5)` + `i == 3` break inspected
627 // only 4 lines yet split off a 5th unbounded remainder (issue #709).
628 for line in buf.split(|c| *c == b'\n').take(MODE_LINE_SCAN_WINDOW) {
629 if let Some(cap) = get_regex(&RE1_EMACS, line, FIRST_EMACS_EXPRESSION) {
630 return mode_to_str(&cap[1]);
631 } else if let Some(cap) = get_regex(&RE2_EMACS, line, SECOND_EMACS_EXPRESSION) {
632 return mode_to_str(&cap[1]);
633 } else if let Some(cap) = get_regex(&RE1_VIM, line, VIM_EXPRESSION) {
634 return mode_to_str(&cap[1]);
635 }
636 }
637
638 // Backward scan for a trailing Vim modeline. Skip empty pieces so a
639 // trailing newline (the common case after `read_file_with_eol`) and
640 // any trailing blank lines do not consume the window before a real
641 // modeline is reached — the former `rsplitn(5)` spent its first slot
642 // on that empty piece, covering fewer than the intended real lines.
643 for line in buf
644 .rsplit(|c| *c == b'\n')
645 .filter(|line| !line.is_empty())
646 .take(MODE_LINE_SCAN_WINDOW)
647 {
648 if let Some(cap) = get_regex(&RE1_VIM, line, VIM_EXPRESSION) {
649 return mode_to_str(&cap[1]);
650 }
651 }
652
653 None
654}
655
656/// Guesses the language of a code.
657///
658/// Returns a tuple containing a [`LANG`] as first argument
659/// and the language name as a second one.
660///
661/// # Examples
662///
663/// ```
664/// use std::path::PathBuf;
665///
666/// use big_code_analysis::guess_language;
667///
668/// let source_code = "int a = 42;";
669///
670/// // The path to a dummy file used to contain the source code
671/// let path = PathBuf::from("foo.c");
672/// let source_slice = source_code.as_bytes();
673///
674/// // Guess the language of a code
675/// guess_language(&source_slice, &path);
676/// ```
677///
678/// [`LANG`]: enum.LANG.html
679pub fn guess_language<P: AsRef<Path>>(buf: &[u8], path: P) -> (Option<LANG>, &'static str) {
680 // Precedence: extension, then emacs/vim modeline, then shebang. The
681 // fallbacks are lazy, so a recognised extension never pays for the
682 // modeline scan; the previous form ran it for every file and
683 // discarded it, every arm of its extension branch having returned the
684 // extension's language — the "modeline agrees" arm included (#1111).
685 let lang = get_language_for_file(path.as_ref())
686 .or_else(|| get_emacs_mode(buf).and_then(|mode| get_from_emacs_mode(&mode)))
687 .or_else(|| get_shebang_lang(buf));
688
689 lang.map_or((None, ""), |lang| (Some(lang), lang.name()))
690}
691
692/// Normalises all CR-only and CRLF line endings to LF throughout the buffer,
693/// then ensures the buffer ends with exactly one `\n`.
694pub(crate) fn normalize_line_endings(data: &mut Vec<u8>) {
695 // In-place compaction: write pointer stays ≤ read pointer, so no extra allocation.
696 let mut w = 0;
697 let mut r = 0;
698 while r < data.len() {
699 if data[r] == b'\r' {
700 data[w] = b'\n';
701 w += 1;
702 r += if data.get(r + 1).copied() == Some(b'\n') {
703 2
704 } else {
705 1
706 };
707 } else {
708 data[w] = data[r];
709 w += 1;
710 r += 1;
711 }
712 }
713 data.truncate(w);
714 let trailing = data.iter().rev().take_while(|&&c| c == b'\n').count();
715 data.truncate(data.len() - trailing);
716 data.push(b'\n');
717}
718
719pub(crate) fn normalize_path<P: AsRef<Path>>(path: P) -> PathBuf {
720 // Copied from Cargo sources: https://github.com/rust-lang/cargo/blob/master/src/cargo/util/paths.rs#L65
721 let mut components = path.as_ref().components().peekable();
722 let mut ret = if let Some(c @ Component::Prefix(..)) = components.peek().copied() {
723 components.next();
724 PathBuf::from(c.as_os_str())
725 } else {
726 PathBuf::new()
727 };
728
729 for component in components {
730 match component {
731 // A `Prefix` (Windows drive / UNC) component only ever
732 // appears first; the leading peek+next above already
733 // consumed it, so it cannot recur in this loop.
734 Component::Prefix(..) => unreachable!(),
735 Component::RootDir => {
736 ret.push(component.as_os_str());
737 }
738 Component::CurDir => {}
739 Component::ParentDir => {
740 ret.pop();
741 }
742 Component::Normal(c) => {
743 ret.push(c);
744 }
745 }
746 }
747 ret
748}
749
750pub(crate) fn get_paths_dist(path1: &Path, path2: &Path) -> Option<usize> {
751 for ancestor in path1.ancestors() {
752 if path2.starts_with(ancestor) && !ancestor.as_os_str().is_empty() {
753 // `ancestor` is yielded by `path1.ancestors()`, so it is
754 // a prefix of `path1` by construction; `path2` was just
755 // verified by `starts_with` above. Both `strip_prefix`
756 // calls are therefore infallible.
757 let path1 = path1
758 .strip_prefix(ancestor)
759 .expect("ancestor is by construction a prefix of path1");
760 let path2 = path2
761 .strip_prefix(ancestor)
762 .expect("ancestor verified by starts_with above");
763 return Some(path1.components().count() + path2.components().count());
764 }
765 }
766 None
767}
768
769pub(crate) fn guess_file<S: ::std::hash::BuildHasher>(
770 current_path: &Path,
771 include_path: &str,
772 all_files: &HashMap<String, Vec<PathBuf>, S>,
773) -> Vec<PathBuf> {
774 let include_path = include_path
775 .strip_prefix("mozilla/")
776 .unwrap_or(include_path);
777
778 // Resolve the include relative to the including file's parent
779 // before normalizing. This preserves leading `..` traversal so
780 // `#include "../foo.h"` from `src/lib/file.c` targets
781 // `src/foo.h`, not the lexically-popped `foo.h` (issue #297).
782 // Lexical-only normalization is required because `current_path`
783 // and the entries in `all_files` are typically not canonicalized
784 // and the included header need not exist on disk yet.
785 let resolved_path = current_path
786 .parent()
787 .map(|parent| normalize_path(parent.join(include_path)));
788
789 let include_path = normalize_path(include_path);
790 let Some(file_name) = include_path.file_name().and_then(|n| n.to_str()) else {
791 return vec![];
792 };
793 let Some(possibilities) = all_files.get(file_name) else {
794 return vec![];
795 };
796 if possibilities.len() == 1 {
797 return possibilities.clone();
798 }
799
800 // Strategy chain: each step looks for a UNIQUE candidate that
801 // matches a progressively weaker signal (full resolved target →
802 // suffix on the normalized include → siblings of the including
803 // file). When no step yields a unique match, fall back to the
804 // closest by path distance, which may return zero or many.
805 resolve_against_resolved(possibilities, current_path, resolved_path.as_deref())
806 .or_else(|| unique_filter(possibilities, current_path, |p| p.ends_with(&include_path)))
807 .or_else(|| resolve_against_parent(possibilities, current_path))
808 .unwrap_or_else(|| min_distance_candidates(possibilities, current_path))
809}
810
811/// Filter `possibilities` to those satisfying `pred` and distinct
812/// from `current_path`, returning `Some(matched)` only when exactly
813/// one survives. The cascading caller treats `None` as "this strategy
814/// did not yield a unique resolution — try the next one."
815fn unique_filter<F>(possibilities: &[PathBuf], current_path: &Path, pred: F) -> Option<Vec<PathBuf>>
816where
817 F: Fn(&PathBuf) -> bool,
818{
819 let matched: Vec<PathBuf> = possibilities
820 .iter()
821 .filter(|p| current_path != p.as_path() && pred(p))
822 .cloned()
823 .collect();
824 (matched.len() == 1).then_some(matched)
825}
826
827/// Strongest signal: a candidate matches the fully resolved relative
828/// target. Prefer exact equality, then suffix match (so absolute
829/// `all_files` entries still match a relative resolved target like
830/// `src/foo.h`).
831fn resolve_against_resolved(
832 possibilities: &[PathBuf],
833 current_path: &Path,
834 resolved: Option<&Path>,
835) -> Option<Vec<PathBuf>> {
836 let resolved = resolved?;
837 unique_filter(possibilities, current_path, |p| p == resolved)
838 .or_else(|| unique_filter(possibilities, current_path, |p| p.ends_with(resolved)))
839}
840
841/// Candidate-in-same-directory heuristic: keep entries whose path
842/// starts with the including file's parent directory.
843fn resolve_against_parent(possibilities: &[PathBuf], current_path: &Path) -> Option<Vec<PathBuf>> {
844 let parent = current_path.parent()?;
845 unique_filter(possibilities, current_path, |p| p.starts_with(parent))
846}
847
848/// Last-chance fallback in the `guess_file` strategy chain: returns
849/// every candidate whose `get_paths_dist` from `current_path` ties
850/// the minimum, or an empty `Vec` when no candidate has a defined
851/// distance. Unlike the unique-match strategies, this may
852/// legitimately return zero or many entries — its result is the
853/// function's final answer, not a "try the next strategy" signal.
854fn min_distance_candidates(possibilities: &[PathBuf], current_path: &Path) -> Vec<PathBuf> {
855 // Hold survivors as borrows during the walk: `Less` arms clear the
856 // prior set without dropping owned `PathBuf`s, and the trailing
857 // `cloned()` runs exactly once per final survivor — never on
858 // entries that were tentatively kept and later evicted.
859 let mut dist_min = usize::MAX;
860 let mut path_min: Vec<&PathBuf> = Vec::new();
861 for p in possibilities {
862 if current_path == p {
863 continue;
864 }
865 let Some(dist) = get_paths_dist(current_path, p) else {
866 continue;
867 };
868 match dist.cmp(&dist_min) {
869 Ordering::Less => {
870 dist_min = dist;
871 path_min.clear();
872 path_min.push(p);
873 }
874 Ordering::Equal => path_min.push(p),
875 Ordering::Greater => {}
876 }
877 }
878 path_min.into_iter().cloned().collect()
879}
880
881// Accept `&mut dyn WriteColor` rather than `&mut StandardStreamLock` so
882// tests (e.g. `function::dump_spans`) can substitute `termcolor::NoColor`
883// over a `Vec<u8>` to capture the rendered bytes. Production callers
884// continue to pass `&mut StandardStreamLock`, which unsized-coerces to
885// the trait object at the call site.
886#[inline]
887pub(crate) fn color(stdout: &mut dyn WriteColor, color: Color) -> std::io::Result<()> {
888 stdout.set_color(ColorSpec::new().set_fg(Some(color)))
889}
890
891#[inline]
892pub(crate) fn intense_color(stdout: &mut dyn WriteColor, color: Color) -> std::io::Result<()> {
893 stdout.set_color(ColorSpec::new().set_fg(Some(color)).set_intense(true))
894}
895
896#[cfg(test)]
897#[path = "tools_tests.rs"]
898mod tests;