Skip to main content

bonsai_parser/
lib.rs

1//! Tree-sitter parse cache.
2//!
3//! Handles the "parse this file with the right grammar, incrementally if we
4//! can" side of the pipeline. Parsing is not quite free — we keep the
5//! previous [`tree_sitter::Tree`] around so the next reparse can use it as a
6//! hint. Cache identity includes the VFS instance, file, adapter, and exact
7//! grammar variant; each entry retains the newest immutable source version it
8//! has parsed.
9
10use ahash::AHashMap;
11use bonsai_common::FileId;
12use bonsai_diagnostics::{Diagnostic, Severity};
13use bonsai_lang_api::{AdapterArc, AdapterError, LanguageId};
14use bonsai_vfs::{FileSnapshot, Vfs};
15use parking_lot::{Mutex, RwLock};
16use std::{
17    ops::{ControlFlow, Deref, DerefMut},
18    sync::Arc,
19    time::Duration,
20};
21use thiserror::Error;
22use tree_sitter::{InputEdit, Node, ParseOptions, Parser, Point, Tree};
23
24type ParseKey = (u64, FileId, LanguageId, &'static str);
25type ParserPool = Arc<Mutex<Vec<Parser>>>;
26
27/// Exclusive checkout from a language parser pool. The pool lock is held only
28/// while taking or returning a parser; tree-sitter parsing itself never holds
29/// a global or per-language lock.
30struct ParserLease {
31    parser: Option<Parser>,
32    pool: ParserPool,
33}
34
35impl Deref for ParserLease {
36    type Target = Parser;
37
38    fn deref(&self) -> &Self::Target {
39        self.parser.as_ref().expect("parser lease is populated")
40    }
41}
42
43impl DerefMut for ParserLease {
44    fn deref_mut(&mut self) -> &mut Self::Target {
45        self.parser.as_mut().expect("parser lease is populated")
46    }
47}
48
49impl Drop for ParserLease {
50    fn drop(&mut self) {
51        if let Some(parser) = self.parser.take() {
52            self.pool.lock().push(parser);
53        }
54    }
55}
56
57#[derive(Debug, Error)]
58pub enum ParseError {
59    #[error(transparent)]
60    Adapter(#[from] AdapterError),
61    #[error("no language adapter handles file {0:?}")]
62    NoAdapter(FileId),
63    #[error("vfs: {0}")]
64    Vfs(#[from] bonsai_vfs::VfsError),
65}
66
67#[derive(Clone)]
68pub struct ParsedFile {
69    pub file: FileId,
70    pub version: u64,
71    pub tree: Arc<Tree>,
72    pub diagnostics: Vec<Diagnostic>,
73    pub adapter_id: bonsai_lang_api::LanguageId,
74    /// Exact adapter-selected grammar variant used for this source path.
75    pub grammar_name: &'static str,
76    source: Arc<str>,
77    used_recovery: bool,
78}
79
80impl std::fmt::Debug for ParsedFile {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        f.debug_struct("ParsedFile")
83            .field("file", &self.file)
84            .field("version", &self.version)
85            .field("adapter_id", &self.adapter_id)
86            .field("grammar_name", &self.grammar_name)
87            .field("diagnostics", &self.diagnostics.len())
88            .field("used_recovery", &self.used_recovery)
89            .finish()
90    }
91}
92
93impl ParsedFile {
94    /// Original source text addressed by every byte range in [`Self::tree`].
95    ///
96    /// Consumers that interpret node byte ranges must use this text instead
97    /// of taking a fresh VFS snapshot, which may already be a newer version.
98    /// Grammar recovery can normalize a same-width private parser buffer, but
99    /// that buffer is never exposed and cannot alter these source slices.
100    #[must_use]
101    pub fn source_text(&self) -> &str {
102        &self.source
103    }
104}
105
106/// Parser-cache configuration.
107///
108/// Parsing runs to completion by default. Set `BONSAI_PARSE_TIMEOUT_MS` or
109/// use the SDK/CLI override only when an explicitly incomplete diagnostic
110/// run is desired; zero restores the uncapped behavior.
111#[derive(Copy, Clone, Debug, Eq, PartialEq)]
112pub struct ParserOptions {
113    pub parse_timeout: Option<Duration>,
114}
115
116impl Default for ParserOptions {
117    fn default() -> Self {
118        Self {
119            parse_timeout: parse_timeout_from_env(),
120        }
121    }
122}
123
124impl ParserOptions {
125    #[must_use]
126    pub fn with_parse_timeout(timeout: Option<Duration>) -> Self {
127        Self {
128            parse_timeout: timeout,
129        }
130    }
131}
132
133/// Concurrent parse cache. Cheap to clone; parser instances are pooled by
134/// exact grammar variant while parsed tree cache reads use an `RwLock`.
135///
136/// A checkout removes one parser from its pool (or creates one if every parser
137/// is busy), then releases the pool lock before parsing. Concurrent files in
138/// the same grammar therefore do not serialize behind one mutable parser,
139/// while completed workers still make their parser reusable. The tree cache
140/// is logically keyed by `(VFS instance, FileId, adapter, grammar, version)`.
141#[derive(Clone)]
142pub struct ParserCache {
143    parsers: Arc<Mutex<AHashMap<&'static str, ParserPool>>>,
144    cache: Arc<RwLock<AHashMap<ParseKey, Arc<ParsedFile>>>>,
145    options: ParserOptions,
146}
147
148impl Default for ParserCache {
149    fn default() -> Self {
150        Self::with_options(ParserOptions::default())
151    }
152}
153
154impl std::fmt::Debug for ParserCache {
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        // Snapshot each lock independently — never hold two locks
157        // simultaneously here. `parse()` acquires (cache, parsers,
158        // parser, cache) in distinct windows; `Debug::fmt`
159        // previously held (cache, parsers) at the same time, an
160        // AB-BA hazard if a peer ever held parsers then cache.
161        let cached_files = self.cache.read().len();
162        let pools = self.parsers.lock().values().cloned().collect::<Vec<_>>();
163        let idle_parsers = pools.iter().map(|pool| pool.lock().len()).sum::<usize>();
164        f.debug_struct("ParserCache")
165            .field("cached_files", &cached_files)
166            .field("parser_grammars", &pools.len())
167            .field("idle_parsers", &idle_parsers)
168            .field("parse_timeout", &self.options.parse_timeout)
169            .finish()
170    }
171}
172
173impl ParserCache {
174    /// Construct a cache with the default [`ParserOptions`] (which
175    /// reads `BONSAI_PARSE_TIMEOUT_MS` from the environment).
176    #[must_use]
177    pub fn new() -> Self {
178        Self::default()
179    }
180
181    /// Construct a cache with explicit options. Useful for tests or callers
182    /// that deliberately need a bounded diagnostic parse.
183    #[must_use]
184    pub fn with_options(options: ParserOptions) -> Self {
185        Self {
186            parsers: Arc::new(Mutex::new(AHashMap::new())),
187            cache: Arc::new(RwLock::new(AHashMap::new())),
188            options,
189        }
190    }
191
192    /// Parse `file` with `adapter`, using any cached tree as a correctly edited
193    /// incremental reparse hint.
194    pub fn parse(
195        &self,
196        file: FileId,
197        adapter: &AdapterArc,
198        vfs: &Vfs,
199    ) -> Result<Arc<ParsedFile>, ParseError> {
200        let snapshot = vfs.snapshot(file)?;
201        self.parse_snapshot(&snapshot, adapter, vfs)
202    }
203
204    /// Parse an exact immutable snapshot.
205    ///
206    /// This is the adapter bridge used by the analyzer database. It prevents a
207    /// concurrent VFS write from returning a tree for a different source
208    /// version than the snapshot an adapter is currently walking.
209    pub fn parse_snapshot(
210        &self,
211        snapshot: &FileSnapshot,
212        adapter: &AdapterArc,
213        vfs: &Vfs,
214    ) -> Result<Arc<ParsedFile>, ParseError> {
215        let file = snapshot.file_id;
216        let path = vfs.path(file)?;
217        let grammar_name = adapter.grammar_name_for_path(&path);
218        let key = (vfs.instance_id(), file, adapter.language_id(), grammar_name);
219        if let Some(entry) = self.cache.read().get(&key).cloned() {
220            if parsed_matches_snapshot(&entry, snapshot) {
221                return Ok(entry);
222            }
223        }
224
225        let language = adapter.tree_sitter_language_for_path(&path)?;
226        let mut parser = self.checkout_parser(grammar_name);
227        // Re-check after checkout. A peer may have finished this exact
228        // snapshot between the initial cache read and parser lookup.
229        if let Some(entry) = self.cache.read().get(&key).cloned() {
230            if parsed_matches_snapshot(&entry, snapshot) {
231                return Ok(entry);
232            }
233        }
234        let old = self.cache.read().get(&key).cloned();
235        parser
236            .set_language(&language)
237            .map_err(|e| AdapterError::ParserSetup(e.to_string()))?;
238
239        let incremental_tree = old
240            .as_deref()
241            .and_then(|parsed| incremental_tree(parsed, &snapshot.text));
242        let old_tree = incremental_tree.as_ref();
243        let (mut tree, timed_out) = parse_with_timeout(
244            &mut parser,
245            snapshot.text.as_ref(),
246            old_tree,
247            self.options.parse_timeout,
248        )?;
249        let mut used_recovery = false;
250        if timed_out.is_none() && tree.root_node().has_error() {
251            let mut recovery_source = snapshot.text.as_bytes().to_vec();
252            loop {
253                let edits = adapter.parse_recovery_edits(snapshot, vfs, &tree);
254                if !apply_recovery_edits(snapshot.text.as_ref(), &mut recovery_source, &edits) {
255                    break;
256                }
257                let recovery_text = std::str::from_utf8(&recovery_source)
258                    .expect("same-width recovery normalization preserves UTF-8");
259                let (candidate, candidate_timed_out) =
260                    parse_with_timeout(&mut parser, recovery_text, None, self.options.parse_timeout)?;
261                if candidate_timed_out.is_some()
262                    || bonsai_lang_api::syntax_damage_score(&candidate)
263                        >= bonsai_lang_api::syntax_damage_score(&tree)
264                {
265                    break;
266                }
267                tree = candidate;
268                used_recovery = true;
269            }
270        }
271        drop(parser);
272        drop(incremental_tree);
273
274        let diagnostics = diagnostics_for_tree(file, snapshot.text.len(), &tree, timed_out);
275
276        let parsed = Arc::new(ParsedFile {
277            file,
278            version: snapshot.version,
279            tree: Arc::new(tree),
280            diagnostics,
281            adapter_id: adapter.language_id(),
282            grammar_name,
283            source: Arc::clone(&snapshot.text),
284            used_recovery,
285        });
286        // Cache the newest version, but always return the tree for the exact
287        // snapshot requested by this caller. Returning a peer's newer entry
288        // here would pair that newer tree with the caller's older source.
289        let mut cache = self.cache.write();
290        if let Some(existing) = cache.get(&key) {
291            if parsed_matches_snapshot(existing, snapshot) {
292                return Ok(existing.clone());
293            }
294            if existing.version >= parsed.version {
295                return Ok(parsed);
296            }
297        }
298        cache.insert(key, parsed.clone());
299        Ok(parsed)
300    }
301
302    /// Release the cached tree for this exact workspace/file/language key.
303    ///
304    /// Compiler lowering phases call this after all durable facts have been
305    /// extracted. Exact removal keeps phase-local eviction O(1) and avoids
306    /// serializing parallel workers behind a whole-cache scan. Edit
307    /// invalidation remains broader because a file may have been parsed by
308    /// more than one adapter over the cache's lifetime.
309    pub fn release(&self, file: FileId, adapter: &AdapterArc, vfs: &Vfs) {
310        let Ok(path) = vfs.path(file) else {
311            return;
312        };
313        self.cache.write().remove(&(
314            vfs.instance_id(),
315            file,
316            adapter.language_id(),
317            adapter.grammar_name_for_path(&path),
318        ));
319    }
320
321    /// Invalidate every cached language interpretation of a single file.
322    pub fn invalidate(&self, file: FileId) {
323        self.cache
324            .write()
325            .retain(|(_, cached_file, _, _), _| *cached_file != file);
326    }
327
328    fn checkout_parser(&self, grammar_name: &'static str) -> ParserLease {
329        let pool = self
330            .parsers
331            .lock()
332            .entry(grammar_name)
333            .or_insert_with(|| Arc::new(Mutex::new(Vec::new())))
334            .clone();
335        let parser = pool.lock().pop().unwrap_or_default();
336        ParserLease {
337            parser: Some(parser),
338            pool,
339        }
340    }
341}
342
343fn parsed_matches_snapshot(parsed: &ParsedFile, snapshot: &FileSnapshot) -> bool {
344    parsed.version == snapshot.version && Arc::ptr_eq(&parsed.source, &snapshot.text)
345}
346
347/// Clone and edit the previous tree so tree-sitter's incremental parser sees
348/// coordinates for `new_source`. Passing an unedited old tree after source
349/// changes is not a hint: tree-sitter treats unchanged ranges as authoritative
350/// and may reuse stale syntax.
351fn incremental_tree(parsed: &ParsedFile, new_source: &str) -> Option<Tree> {
352    if parsed.used_recovery
353        || parsed
354            .diagnostics
355            .iter()
356            .any(|diagnostic| diagnostic.code.as_deref() == Some("parse-timeout"))
357    {
358        // A timeout stores an intentionally empty placeholder tree, which does
359        // not describe `parsed.source` and therefore cannot be edited safely.
360        return None;
361    }
362    let mut tree = parsed.tree.as_ref().clone();
363    if parsed.source.as_ref() != new_source {
364        tree.edit(&single_replacement_edit(&parsed.source, new_source));
365    }
366    Some(tree)
367}
368
369fn apply_recovery_edits(
370    source: &str,
371    recovered: &mut [u8],
372    edits: &[bonsai_lang_api::ParseRecoveryEdit],
373) -> bool {
374    let mut changed = false;
375    for edit in edits {
376        changed |= edit.apply_to(source, recovered);
377    }
378    changed
379}
380
381fn diagnostics_for_tree(
382    file: FileId,
383    text_len: usize,
384    tree: &Tree,
385    timed_out: Option<Duration>,
386) -> Vec<Diagnostic> {
387    let mut diagnostics = Vec::new();
388    if let Some(timeout) = timed_out {
389        diagnostics.push(parse_timeout_diagnostic(file, text_len, timeout));
390        return diagnostics;
391    }
392    if !tree.root_node().has_error() {
393        return diagnostics;
394    }
395
396    // Walk the tree and emit one diagnostic per ERROR / MISSING node so the
397    // user sees exactly where the parser choked instead of a single opaque
398    // "syntax errors present" that points at the whole file. Diagnostics are
399    // exhaustive; presentation layers may paginate them but
400    // the compiler query never suppresses syntax facts.
401    let mut stack = vec![tree.root_node()];
402    while let Some(node) = stack.pop() {
403        let is_error = node.is_error();
404        let is_missing = node.is_missing();
405        if is_error || is_missing {
406            let span = span_for_node(file, node);
407            let msg = if is_missing {
408                format!("missing `{}`", node.kind())
409            } else {
410                "syntax error".to_string()
411            };
412            diagnostics.push(Diagnostic::new(span, Severity::Warning, msg).with_code("syntax-error"));
413        }
414        if !is_error {
415            let mut cursor = node.walk();
416            for child in node.children(&mut cursor) {
417                if child.has_error() || child.is_missing() {
418                    stack.push(child);
419                }
420            }
421        }
422    }
423    if diagnostics.is_empty() {
424        diagnostics.push(
425            Diagnostic::new(
426                file_span(file, text_len),
427                Severity::Warning,
428                "syntax errors present",
429            )
430            .with_code("syntax-error"),
431        );
432    }
433    diagnostics
434}
435
436/// Describe an arbitrary source change as one replacement spanning the first
437/// and last changed UTF-8 boundaries. This remains exact for tree-sitter even
438/// when the VFS update arrived as a whole-file write rather than granular LSP
439/// edits.
440fn single_replacement_edit(old: &str, new: &str) -> InputEdit {
441    let old_bytes = old.as_bytes();
442    let new_bytes = new.as_bytes();
443    let mut prefix = old_bytes
444        .iter()
445        .zip(new_bytes)
446        .take_while(|(old, new)| old == new)
447        .count();
448    while prefix > 0 && (!old.is_char_boundary(prefix) || !new.is_char_boundary(prefix)) {
449        prefix -= 1;
450    }
451
452    let max_suffix = old
453        .len()
454        .saturating_sub(prefix)
455        .min(new.len().saturating_sub(prefix));
456    let mut suffix = old_bytes[old.len() - max_suffix..]
457        .iter()
458        .rev()
459        .zip(new_bytes[new.len() - max_suffix..].iter().rev())
460        .take_while(|(old, new)| old == new)
461        .count();
462    while suffix > 0
463        && (!old.is_char_boundary(old.len() - suffix) || !new.is_char_boundary(new.len() - suffix))
464    {
465        suffix -= 1;
466    }
467
468    let old_end = old.len() - suffix;
469    let new_end = new.len() - suffix;
470    InputEdit {
471        start_byte: prefix,
472        old_end_byte: old_end,
473        new_end_byte: new_end,
474        start_position: point_at_byte(old, prefix),
475        old_end_position: point_at_byte(old, old_end),
476        new_end_position: point_at_byte(new, new_end),
477    }
478}
479
480fn point_at_byte(text: &str, byte: usize) -> Point {
481    debug_assert!(byte <= text.len());
482    debug_assert!(text.is_char_boundary(byte));
483    let prefix = &text.as_bytes()[..byte];
484    let mut row = 0usize;
485    let mut line_start = 0usize;
486    for (index, value) in prefix.iter().enumerate() {
487        if *value == b'\n' {
488            row += 1;
489            line_start = index + 1;
490        }
491    }
492    let column = byte - line_start;
493    Point::new(row, column)
494}
495
496/// Read the parse-timeout override from `BONSAI_PARSE_TIMEOUT_MS`.
497/// Empty / unparseable values fall through to the uncapped default; `0`
498/// explicitly selects the same uncapped behavior.
499fn parse_timeout_from_env() -> Option<Duration> {
500    let Ok(raw) = std::env::var("BONSAI_PARSE_TIMEOUT_MS") else {
501        return None;
502    };
503    parse_timeout_millis(raw.trim().parse().ok()?)
504}
505
506/// Convert a raw millisecond count to a `Duration`. `0` means
507/// "no timeout"; everything else converts directly.
508fn parse_timeout_millis(ms: u64) -> Option<Duration> {
509    if ms == 0 {
510        None
511    } else {
512        Some(Duration::from_millis(ms))
513    }
514}
515
516/// Parse `text` under `parser`, falling back to an empty tree if the
517/// parse exceeds `timeout`. Returns `(tree, Some(timeout))` when the
518/// timeout fired so callers can attach a diagnostic.
519fn parse_with_timeout(
520    parser: &mut Parser,
521    text: &str,
522    old_tree: Option<&Tree>,
523    timeout: Option<Duration>,
524) -> Result<(Tree, Option<Duration>), ParseError> {
525    let Some(timeout) = timeout.filter(|timeout| !timeout.is_zero()) else {
526        let tree = parser
527            .parse(text.as_bytes(), old_tree)
528            .ok_or_else(|| AdapterError::Parse("tree-sitter returned None".to_string()))?;
529        return Ok((tree, None));
530    };
531
532    let start = std::time::Instant::now();
533    let bytes = text.as_bytes();
534    let len = bytes.len();
535    let mut timed_out = false;
536    let tree = {
537        let mut input = |byte_offset, _| {
538            if byte_offset < len {
539                &bytes[byte_offset..]
540            } else {
541                &[]
542            }
543        };
544        let mut progress = |_: &tree_sitter::ParseState| {
545            if start.elapsed() >= timeout {
546                timed_out = true;
547                ControlFlow::Break(())
548            } else {
549                ControlFlow::Continue(())
550            }
551        };
552        let options = ParseOptions::new().progress_callback(&mut progress);
553        parser.parse_with_options(&mut input, old_tree, Some(options))
554    };
555    match tree {
556        Some(tree) => Ok((tree, None)),
557        None if timed_out => {
558            parser.reset();
559            let empty_tree = parser.parse("", None).ok_or_else(|| {
560                AdapterError::Parse("tree-sitter returned None after parse timeout".to_string())
561            })?;
562            Ok((empty_tree, Some(timeout)))
563        }
564        None => Err(AdapterError::Parse("tree-sitter returned None".to_string()).into()),
565    }
566}
567
568/// Build a span covering a tree-sitter node, saturating byte offsets
569/// past `u64::MAX` (defensive — real source files are nowhere near).
570fn span_for_node(file: FileId, node: Node<'_>) -> bonsai_common::Span {
571    bonsai_common::Span::new(
572        file,
573        saturating_byte_offset(node.start_byte()),
574        saturating_byte_offset(node.end_byte()),
575    )
576}
577
578/// Span covering the whole file. Used for file-level diagnostics
579/// where a more specific node isn't applicable.
580fn file_span(file: FileId, text_len: usize) -> bonsai_common::Span {
581    bonsai_common::Span::new(file, 0, saturating_byte_offset(text_len))
582}
583
584fn saturating_byte_offset(byte: usize) -> u64 {
585    u64::try_from(byte).unwrap_or(u64::MAX)
586}
587
588/// File-level diagnostic for "this file timed out during parsing."
589fn parse_timeout_diagnostic(file: FileId, text_len: usize, timeout: Duration) -> Diagnostic {
590    Diagnostic::new(
591        file_span(file, text_len),
592        Severity::Warning,
593        format!("file skipped: parse timeout after {} ms", timeout.as_millis()),
594    )
595    .with_code("parse-timeout")
596}
597
598#[cfg(test)]
599#[path = "tests.rs"]
600mod tests;