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}