Skip to main content

brokk_bifrost_core/analyzer/
common.rs

1//! The language-blind half of the analyzer's shared helpers.
2//!
3//! Everything here is decided by a path extension or a byte range, so none of
4//! it needs a grammar, an analyzer handle, or a store. The language-aware rest
5//! of `analyzer::common` stays in `brokk-bifrost-analysis` and re-exports these
6//! at their original paths.
7
8use tree_sitter::Node;
9
10use crate::analyzer::structural::facts::Span;
11use crate::analyzer::{CodeUnit, Language, ProjectFile};
12use crate::cancellation::CancellationToken;
13use std::path::Path;
14
15/// Qualify `name` with a dot-separated namespace or package prefix.
16pub fn qualify_dotted_name(prefix: &str, name: &str) -> String {
17    if prefix.is_empty() {
18        name.to_string()
19    } else {
20        format!("{prefix}.{name}")
21    }
22}
23
24#[cfg(test)]
25pub(crate) fn test_range(start_byte: usize) -> crate::analyzer::Range {
26    crate::analyzer::Range {
27        start_byte,
28        end_byte: start_byte + 1,
29        start_line: 1,
30        end_line: 1,
31    }
32}
33
34/// The byte span a tree-sitter node covers, in the shared structural span
35/// shape. Adapters use this to record token anchors (e.g. an import's binder
36/// token) on parser-derived models.
37pub fn node_span(node: Node<'_>) -> Span {
38    Span {
39        start_byte: node.start_byte(),
40        end_byte: node.end_byte(),
41    }
42}
43
44pub fn span_from_nodes(first: Node<'_>, last: Node<'_>) -> Span {
45    Span {
46        start_byte: first.start_byte(),
47        end_byte: last.end_byte(),
48    }
49}
50
51pub fn packed_bits_is_empty(words: &[u64]) -> bool {
52    words.iter().all(|word| *word == 0)
53}
54
55pub fn packed_bits_len(words: &[u64]) -> usize {
56    words.iter().map(|word| word.count_ones() as usize).sum()
57}
58
59pub fn language_for_target(target: &CodeUnit) -> Language {
60    declaration_language_for_file(target.source())
61}
62
63pub fn language_for_file(file: &ProjectFile) -> Language {
64    file.language()
65}
66
67pub(crate) fn estimated_string_vec_bytes(values: &[String], capacity: usize) -> usize {
68    capacity
69        .saturating_mul(std::mem::size_of::<String>())
70        .saturating_add(
71            values
72                .iter()
73                .map(String::capacity)
74                .fold(0usize, usize::saturating_add),
75        )
76}
77
78/// Select the workspace delegate needed for source or static dependency input.
79/// Metadata activates its reader without becoming a programming-language file.
80/// Admission and refresh must use the same rule for files-only workspaces.
81pub fn workspace_language_for_file(file: &ProjectFile) -> Language {
82    match file.rel_path().file_name().and_then(|name| name.to_str()) {
83        Some("Cargo.toml" | "Cargo.lock") => Language::Rust,
84        Some(
85            "package.json"
86            | "package-lock.json"
87            | "npm-shrinkwrap.json"
88            | "pnpm-lock.yaml"
89            | "yarn.lock",
90        ) => Language::JavaScript,
91        _ => language_for_file(file),
92    }
93}
94
95/// The single language whose adapter adopts files by include inference
96/// (#1837).
97///
98/// The CLAIMS SEAM registry that drives inference is
99/// `analyzer::languages::claim_inferring_languages` over in
100/// `brokk-bifrost-analysis`, which core cannot reach; core needs the same fact
101/// to name a declaration found in a claimed file. `core_claiming_language_
102/// matches_the_claims_seam` in that module asserts the two agree. The
103/// registry's own single-claimant invariant -- also what
104/// `LanguageAdapter::storage_language_key_for_file` rests on -- is what lets
105/// this be one language rather than a set.
106pub const INCLUDE_CLAIMING_LANGUAGE: Language = Language::Cpp;
107
108/// The language whose spelling rules name a declaration that lives in `file`.
109///
110/// This differs from [`language_for_file`] for exactly the files include-driven
111/// inference adopts (#1837). Those have, by construction, an extension no
112/// language's registry claims, so the extension answers `Language::None` -- yet
113/// a declaration only exists in such a file because the claiming analyzer
114/// parsed it, and its qualified name must be spelled in that analyzer's
115/// language. Rendering a C++ namespace head with `Language::None`'s neutral `.`
116/// join turned `tbb::interface5` into `tbb.interface5` and tripped the FqName
117/// round-trip assertion at construction, panicking the workspace build on
118/// midonet's extensionless vendored TBB headers (issue #1878).
119///
120/// Use [`language_for_file`] to ask which language's extension list owns a
121/// path; use this to ask how a declaration found in that path is named.
122pub fn declaration_language_for_file(file: &ProjectFile) -> Language {
123    file.declaration_language()
124}
125
126/// The language whose spelling rules name a declaration found in a file at
127/// `rel_path`, decided from the path alone.
128///
129/// [`declaration_language_for_file`] is the same answer for a caller holding
130/// the file. This is for the callers that hold only a workspace-relative path
131/// -- a stored row keyed by path, a checker reading a tool payload -- and both
132/// reach it through the same `declaration_language_of` rule, so the owner is
133/// one function rather than one per entry point.
134pub fn declaration_language_for_rel_path(rel_path: &Path) -> Language {
135    let extension = rel_path.extension().and_then(|ext| ext.to_str());
136    let language = extension.map_or(Language::None, Language::from_extension);
137    declaration_language_of(language, unclaimed_extension(extension))
138}
139
140/// The declaration-naming rule over the two facts a path decides:
141/// [`ProjectFile::language`] and [`ProjectFile::has_unclaimed_extension`].
142///
143/// [`ProjectFile::language`]: crate::analyzer::ProjectFile::language
144/// [`ProjectFile::has_unclaimed_extension`]: crate::analyzer::ProjectFile::has_unclaimed_extension
145pub(crate) fn declaration_language_of(language: Language, unclaimed_extension: bool) -> Language {
146    match language {
147        Language::None if unclaimed_extension => INCLUDE_CLAIMING_LANGUAGE,
148        language => language,
149    }
150}
151
152/// The extension-list half of [`ProjectFile::has_unclaimed_extension`], for a
153/// caller that has the extension rather than a file.
154pub(crate) fn unclaimed_extension(extension: Option<&str>) -> bool {
155    extension.is_none_or(|extension| !Language::is_source_extension(extension))
156}
157
158/// Whether an analyzer covering `languages` could have indexed `file`, judged
159/// from the path alone: no store query, no filesystem access, no snapshot.
160///
161/// This is the lexical half of the ownership test each language analyzer
162/// applies to a live path (`TreeSitterAnalyzer::adapter_owns_file`): the file's
163/// extension names one of the analyzer's languages, or the analyzer includes
164/// the one language whose adapter adopts files by include inference and the
165/// extension is claimed by no language at all.
166///
167/// A caller uses it to size a candidate set before spending store work on it.
168/// It over-approximates in exactly one direction -- a path can be eligible and
169/// still be unanalyzed -- so it may only decide how much work to attempt, never
170/// whether a file is a result. [`CodeUnitIndex::retain_analyzed`] decides that.
171///
172/// [`CodeUnitIndex::retain_analyzed`]: crate::analyzer::code_unit_index::CodeUnitIndex::retain_analyzed
173pub fn languages_may_analyze(
174    languages: &std::collections::BTreeSet<Language>,
175    file: &ProjectFile,
176) -> bool {
177    languages.contains(&language_for_file(file))
178        || (languages.contains(&INCLUDE_CLAIMING_LANGUAGE) && file.has_unclaimed_extension())
179}
180
181/// Default longest single line a source file may contain before tree-sitter parsing is
182/// skipped. Minified/generated single-line bundles (committed webpack output, mermaid.min.js,
183/// etc.) have 16KB+ lines and otherwise both livelock the parser and explode downstream
184/// consumers (e.g. the semantic indexer extracting thousands of bogus chunks). Hand-written
185/// and normally-formatted generated source stays far below this, so the cap is effectively
186/// invisible to real code. 16000 is comfortably above any human-authored line while still
187/// catching moderately-sized minified bundles that a higher cap would let through.
188pub const DEFAULT_MAX_LINE_LENGTH: usize = 16_000;
189
190/// Longest single line a source file may contain before tree-sitter parsing is skipped.
191/// Defaults to [`DEFAULT_MAX_LINE_LENGTH`]; `BIFROST_MAX_LINE_LENGTH` overrides it, and an
192/// explicit `0` disables the limit entirely (parse everything, at your own risk).
193pub fn max_line_length_limit() -> Option<usize> {
194    match std::env::var("BIFROST_MAX_LINE_LENGTH") {
195        Ok(v) => match v.trim().parse::<usize>() {
196            Ok(0) => None,
197            Ok(n) => Some(n),
198            Err(_) => Some(DEFAULT_MAX_LINE_LENGTH),
199        },
200        Err(_) => Some(DEFAULT_MAX_LINE_LENGTH),
201    }
202}
203
204/// Whether `source` must NOT be handed to tree-sitter: it is binary (contains NUL
205/// bytes) or pathological for the parser (a line longer than the configured cap).
206/// Centralizes the "is this safe to parse?" decision for every parse site so no
207/// consumer livelocks on adversarial input.
208pub fn is_unparseable_source(source: &str) -> bool {
209    if source.as_bytes().contains(&0) {
210        return true;
211    }
212    match max_line_length_limit() {
213        Some(limit) => source.lines().any(|line| line.len() > limit),
214        None => false,
215    }
216}
217
218/// `text` with every whitespace run collapsed to a single space and the ends
219/// trimmed.
220///
221/// Used to render a multi-line source header (a class or callable signature)
222/// as one stable line.
223pub fn collapse_whitespace(text: &str) -> String {
224    let mut out = String::with_capacity(text.len());
225    for token in text.split_whitespace() {
226        if !out.is_empty() {
227            out.push(' ');
228        }
229        out.push_str(token);
230    }
231    out
232}
233
234/// Split a language-specific call reference into its receiver and member.
235///
236/// The first parenthesis begins the argument list. Separators are tried in
237/// order, and the last occurrence of the first matching separator is used.
238pub fn extract_call_reference_parts<'a>(
239    reference: &'a str,
240    separators: &[&str],
241) -> Option<(&'a str, &'a str)> {
242    debug_assert!(separators.iter().all(|separator| !separator.is_empty()));
243    let trimmed = reference.trim();
244    let before_args = trimmed
245        .split_once('(')
246        .map(|(head, _)| head)
247        .unwrap_or(trimmed);
248    separators
249        .iter()
250        .find_map(|separator| before_args.rsplit_once(*separator))
251}
252
253/// Verbatim source text spanned by `node`, or `""` when the byte range is not a
254/// valid `str` boundary (adversarial or partially-parsed input).
255///
256/// This is the single "slice a node's bytes" primitive. It replaces the
257/// per-language `source.get(node.byte_range()).unwrap_or("")` copies and the
258/// panicking `&source[node.byte_range()]` slicers (bad ranges now yield `""`
259/// instead of panicking). Use [`node_source_text_trimmed`] when surrounding
260/// whitespace must be dropped, and [`node_ident_text`] when a language sigil
261/// (`r#`, `@`) must be normalized off identifier tokens.
262pub fn node_source_text<'a>(node: Node<'_>, source: &'a str) -> &'a str {
263    source.get(node.byte_range()).unwrap_or("")
264}
265
266/// The UTF-8 source text spanned by `node`, or `None` when its range is invalid
267/// for `source`.
268pub fn try_node_source_text<'a>(node: Node<'_>, source: &'a str) -> Option<&'a str> {
269    node.utf8_text(source.as_bytes()).ok()
270}
271
272/// [`node_source_text`] with leading/trailing whitespace trimmed. Trimming is
273/// load-bearing on the usages side, where a "name" node can span a compound
274/// token whose canonical text is the trimmed inner identifier; declaration-side
275/// callers that must preserve exact spans use [`node_source_text`] instead.
276pub fn node_source_text_trimmed<'a>(node: Node<'_>, source: &'a str) -> &'a str {
277    node_source_text(node, source).trim()
278}
279
280pub fn nonempty_node_source_text(node: Node<'_>, source: &str) -> Option<String> {
281    let text = node_source_text_trimmed(node, source);
282    (!text.is_empty()).then(|| text.to_string())
283}
284
285/// Per-language identifier sigil: which tree-sitter node kinds are single
286/// identifier tokens, and the escape/sigil `prefix` (`r#` in Rust, `@` in C#)
287/// to strip from those tokens so identity text (short/fq names) and
288/// reference/member text agree on the canonical spelling.
289///
290/// Stripping is gated on `is_identifier_kind`: the sigil is only removed from
291/// genuine identifier leaf nodes, never from spans where the same character is
292/// meaningful (C# `@"..."` verbatim strings, attribute markers, larger token
293/// runs). See [`node_ident_text`].
294///
295/// The mechanism is language-blind and lives here; each sigil constant is
296/// language knowledge and lives with its language (Rust's in
297/// `brokk-bifrost-rust`, C#'s still in `brokk-bifrost-analysis`).
298pub struct IdentifierSigil {
299    pub is_identifier_kind: fn(&str) -> bool,
300    pub prefix: &'static str,
301}
302
303/// Node text with a language identifier sigil normalized off.
304///
305/// Slices `node`'s source (empty on a bad range), optionally trims, then strips
306/// `sigil.prefix` iff `node`'s kind satisfies `sigil.is_identifier_kind`. This
307/// is the one place the sigil-normalization invariant lives; the per-surface
308/// (declaration / graph / get-definition) copies delegate here so they cannot
309/// drift out of agreement.
310pub fn node_ident_text<'a>(
311    node: Node<'_>,
312    source: &'a str,
313    trim: bool,
314    sigil: &IdentifierSigil,
315) -> &'a str {
316    let raw = source.get(node.byte_range()).unwrap_or("");
317    let text = if trim { raw.trim() } else { raw };
318    if (sigil.is_identifier_kind)(node.kind()) {
319        text.strip_prefix(sigil.prefix).unwrap_or(text)
320    } else {
321        text
322    }
323}
324
325/// Parse only `[start, end)` of `source` as `language`, confining the parser to
326/// that region via tree-sitter included ranges. Every node keeps its original
327/// byte offset and line/column position, exactly like the historical
328/// "padded copy" technique (issues #941/#1015), but without materializing an
329/// O(file) whitespace prefix or making the lexer walk it -- on a large file with
330/// a region near the end that padding turned each recovery reparse into seconds
331/// of whitespace lexing (issue #1309's cold-start profile).
332///
333/// Returns `None` for an empty or invalid region (out of bounds, or not on
334/// char boundaries), mirroring the padded implementations' refusal to build a
335/// reparse for nothing.
336pub fn parse_source_region(
337    language: &tree_sitter::Language,
338    source: &str,
339    start: usize,
340    end: usize,
341) -> Option<tree_sitter::Tree> {
342    parse_source_region_with_cancellation(language, source, start, end, None)
343}
344
345/// Cancellation-aware form of [`parse_source_region`].
346pub fn parse_source_region_with_cancellation(
347    language: &tree_sitter::Language,
348    source: &str,
349    start: usize,
350    end: usize,
351    cancellation: Option<&CancellationToken>,
352) -> Option<tree_sitter::Tree> {
353    parse_source_ranges_with_cancellation(language, source, &[(start, end)], cancellation)
354}
355
356/// Parse only the byte spans in `ranges` of `source` as `language`, confining
357/// the parser to those spans via tree-sitter included ranges. Every node
358/// keeps its original byte offset and line/column position, exactly like
359/// [`parse_source_region`] for a single span -- this is the form a recovery
360/// needs when the clean text is scattered across more than one span of the
361/// original source rather than one contiguous region. For example, a C
362/// prototype hidden behind a K&R-compatibility macro invocation such as
363/// `RET name _(( args ));` is recovered by parsing `RET name`, then the
364/// parenthesized `args` list (keeping one of the two nested parenthesis
365/// pairs so it reads as an ordinary parameter list), then the terminating
366/// `;`, each at its own original offset, which is exactly what a
367/// preprocessor's `#define _(args) args` expansion would leave behind
368/// (issue #2932).
369///
370/// `ranges` must be non-empty and given in increasing, non-overlapping
371/// order (each span's end at or before the next span's start); this
372/// mirrors tree-sitter's own requirement on `Parser::set_included_ranges`.
373/// Returns `None` if `ranges` is empty or if any span is empty or invalid
374/// (out of bounds, not on char boundaries, or out of order).
375pub fn parse_source_ranges_with_cancellation(
376    language: &tree_sitter::Language,
377    source: &str,
378    ranges: &[(usize, usize)],
379    cancellation: Option<&CancellationToken>,
380) -> Option<tree_sitter::Tree> {
381    if ranges.is_empty() {
382        return None;
383    }
384    let bytes = source.as_bytes();
385    let mut point = tree_sitter::Point { row: 0, column: 0 };
386    let mut previous_end = 0usize;
387    let mut ts_ranges = Vec::with_capacity(ranges.len());
388    for &(start, end) in ranges {
389        if start >= end
390            || end > source.len()
391            || !source.is_char_boundary(start)
392            || !source.is_char_boundary(end)
393            || start < previous_end
394        {
395            return None;
396        }
397        let start_point = advance_ts_point(bytes, point, previous_end, start);
398        let end_point = advance_ts_point(bytes, start_point, start, end);
399        ts_ranges.push(tree_sitter::Range {
400            start_byte: start,
401            end_byte: end,
402            start_point,
403            end_point,
404        });
405        point = end_point;
406        previous_end = end;
407    }
408    parse_source_with_cancellation(language, source, Some(&ts_ranges), cancellation)
409}
410
411/// Parse one parser-provided source range without recomputing its points.
412pub fn parse_source_range_with_cancellation(
413    language: &tree_sitter::Language,
414    source: &str,
415    range: tree_sitter::Range,
416    cancellation: Option<&CancellationToken>,
417) -> Option<tree_sitter::Tree> {
418    if range.start_byte >= range.end_byte
419        || range.end_byte > source.len()
420        || !source.is_char_boundary(range.start_byte)
421        || !source.is_char_boundary(range.end_byte)
422    {
423        return None;
424    }
425    parse_source_with_cancellation(language, source, Some(&[range]), cancellation)
426}
427
428/// Parse source with an optional set of validated tree-sitter ranges, stopping
429/// promptly when cancellation is requested.
430pub fn parse_source_with_cancellation(
431    language: &tree_sitter::Language,
432    source: &str,
433    included_ranges: Option<&[tree_sitter::Range]>,
434    cancellation: Option<&CancellationToken>,
435) -> Option<tree_sitter::Tree> {
436    if cancellation.is_some_and(CancellationToken::is_cancelled) {
437        return None;
438    }
439    let mut parser = tree_sitter::Parser::new();
440    parser.set_language(language).ok()?;
441    if let Some(ranges) = included_ranges {
442        parser.set_included_ranges(ranges).ok()?;
443    }
444    if let Some(cancellation) = cancellation {
445        let mut read = |offset: usize, _| &source.as_bytes()[offset..];
446        let mut progress = |_: &tree_sitter::ParseState| cancellation.is_cancelled();
447        parser.parse_with_options(
448            &mut read,
449            None,
450            Some(tree_sitter::ParseOptions::new().progress_callback(&mut progress)),
451        )
452    } else {
453        parser.parse(source, None)
454    }
455}
456
457/// Advance `point` across `bytes[from..to]`. Tree-sitter columns count bytes.
458///
459/// Public because every producer of `tree_sitter::Range` must compute points
460/// with this exact math: a point that disagrees with its byte offset corrupts
461/// the positions of every node in the parse.
462pub fn advance_ts_point(
463    bytes: &[u8],
464    point: tree_sitter::Point,
465    from: usize,
466    to: usize,
467) -> tree_sitter::Point {
468    let slice = &bytes[from..to];
469    match slice.iter().rposition(|&b| b == b'\n') {
470        None => tree_sitter::Point {
471            row: point.row,
472            column: point.column + slice.len(),
473        },
474        Some(last_newline) => tree_sitter::Point {
475            row: point.row + slice.iter().filter(|&&b| b == b'\n').count(),
476            column: slice.len() - last_newline - 1,
477        },
478    }
479}