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