use ahash::AHashMap;
use bonsai_common::FileId;
use bonsai_diagnostics::{Diagnostic, Severity};
use bonsai_lang_api::{AdapterArc, AdapterError, LanguageId};
use bonsai_vfs::{FileSnapshot, Vfs};
use parking_lot::{Mutex, RwLock};
use std::{
ops::{ControlFlow, Deref, DerefMut},
sync::Arc,
time::Duration,
};
use thiserror::Error;
use tree_sitter::{InputEdit, Node, ParseOptions, Parser, Point, Tree};
type ParseKey = (u64, FileId, LanguageId, &'static str);
type ParserPool = Arc<Mutex<Vec<Parser>>>;
struct ParserLease {
parser: Option<Parser>,
pool: ParserPool,
}
impl Deref for ParserLease {
type Target = Parser;
fn deref(&self) -> &Self::Target {
self.parser.as_ref().expect("parser lease is populated")
}
}
impl DerefMut for ParserLease {
fn deref_mut(&mut self) -> &mut Self::Target {
self.parser.as_mut().expect("parser lease is populated")
}
}
impl Drop for ParserLease {
fn drop(&mut self) {
if let Some(parser) = self.parser.take() {
self.pool.lock().push(parser);
}
}
}
#[derive(Debug, Error)]
pub enum ParseError {
#[error(transparent)]
Adapter(#[from] AdapterError),
#[error("no language adapter handles file {0:?}")]
NoAdapter(FileId),
#[error("vfs: {0}")]
Vfs(#[from] bonsai_vfs::VfsError),
}
#[derive(Clone)]
pub struct ParsedFile {
pub file: FileId,
pub version: u64,
pub tree: Arc<Tree>,
pub diagnostics: Vec<Diagnostic>,
pub adapter_id: bonsai_lang_api::LanguageId,
pub grammar_name: &'static str,
source: Arc<str>,
used_recovery: bool,
}
impl std::fmt::Debug for ParsedFile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ParsedFile")
.field("file", &self.file)
.field("version", &self.version)
.field("adapter_id", &self.adapter_id)
.field("grammar_name", &self.grammar_name)
.field("diagnostics", &self.diagnostics.len())
.field("used_recovery", &self.used_recovery)
.finish()
}
}
impl ParsedFile {
#[must_use]
pub fn source_text(&self) -> &str {
&self.source
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub struct ParserOptions {
pub parse_timeout: Option<Duration>,
}
impl Default for ParserOptions {
fn default() -> Self {
Self {
parse_timeout: parse_timeout_from_env(),
}
}
}
impl ParserOptions {
#[must_use]
pub fn with_parse_timeout(timeout: Option<Duration>) -> Self {
Self {
parse_timeout: timeout,
}
}
}
#[derive(Clone)]
pub struct ParserCache {
parsers: Arc<Mutex<AHashMap<&'static str, ParserPool>>>,
cache: Arc<RwLock<AHashMap<ParseKey, Arc<ParsedFile>>>>,
options: ParserOptions,
}
impl Default for ParserCache {
fn default() -> Self {
Self::with_options(ParserOptions::default())
}
}
impl std::fmt::Debug for ParserCache {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let cached_files = self.cache.read().len();
let pools = self.parsers.lock().values().cloned().collect::<Vec<_>>();
let idle_parsers = pools.iter().map(|pool| pool.lock().len()).sum::<usize>();
f.debug_struct("ParserCache")
.field("cached_files", &cached_files)
.field("parser_grammars", &pools.len())
.field("idle_parsers", &idle_parsers)
.field("parse_timeout", &self.options.parse_timeout)
.finish()
}
}
impl ParserCache {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_options(options: ParserOptions) -> Self {
Self {
parsers: Arc::new(Mutex::new(AHashMap::new())),
cache: Arc::new(RwLock::new(AHashMap::new())),
options,
}
}
pub fn parse(
&self,
file: FileId,
adapter: &AdapterArc,
vfs: &Vfs,
) -> Result<Arc<ParsedFile>, ParseError> {
let snapshot = vfs.snapshot(file)?;
self.parse_snapshot(&snapshot, adapter, vfs)
}
pub fn parse_snapshot(
&self,
snapshot: &FileSnapshot,
adapter: &AdapterArc,
vfs: &Vfs,
) -> Result<Arc<ParsedFile>, ParseError> {
let file = snapshot.file_id;
let path = vfs.path(file)?;
let grammar_name = adapter.grammar_name_for_path(&path);
let key = (vfs.instance_id(), file, adapter.language_id(), grammar_name);
if let Some(entry) = self.cache.read().get(&key).cloned() {
if parsed_matches_snapshot(&entry, snapshot) {
return Ok(entry);
}
}
let language = adapter.tree_sitter_language_for_path(&path)?;
let mut parser = self.checkout_parser(grammar_name);
if let Some(entry) = self.cache.read().get(&key).cloned() {
if parsed_matches_snapshot(&entry, snapshot) {
return Ok(entry);
}
}
let old = self.cache.read().get(&key).cloned();
parser
.set_language(&language)
.map_err(|e| AdapterError::ParserSetup(e.to_string()))?;
let incremental_tree = old
.as_deref()
.and_then(|parsed| incremental_tree(parsed, &snapshot.text));
let old_tree = incremental_tree.as_ref();
let (mut tree, timed_out) = parse_with_timeout(
&mut parser,
snapshot.text.as_ref(),
old_tree,
self.options.parse_timeout,
)?;
let mut used_recovery = false;
if timed_out.is_none() && tree.root_node().has_error() {
let mut recovery_source = snapshot.text.as_bytes().to_vec();
loop {
let edits = adapter.parse_recovery_edits(snapshot, vfs, &tree);
if !apply_recovery_edits(snapshot.text.as_ref(), &mut recovery_source, &edits) {
break;
}
let recovery_text = std::str::from_utf8(&recovery_source)
.expect("same-width recovery normalization preserves UTF-8");
let (candidate, candidate_timed_out) =
parse_with_timeout(&mut parser, recovery_text, None, self.options.parse_timeout)?;
if candidate_timed_out.is_some()
|| bonsai_lang_api::syntax_damage_score(&candidate)
>= bonsai_lang_api::syntax_damage_score(&tree)
{
break;
}
tree = candidate;
used_recovery = true;
}
}
drop(parser);
drop(incremental_tree);
let diagnostics = diagnostics_for_tree(file, snapshot.text.len(), &tree, timed_out);
let parsed = Arc::new(ParsedFile {
file,
version: snapshot.version,
tree: Arc::new(tree),
diagnostics,
adapter_id: adapter.language_id(),
grammar_name,
source: Arc::clone(&snapshot.text),
used_recovery,
});
let mut cache = self.cache.write();
if let Some(existing) = cache.get(&key) {
if parsed_matches_snapshot(existing, snapshot) {
return Ok(existing.clone());
}
if existing.version >= parsed.version {
return Ok(parsed);
}
}
cache.insert(key, parsed.clone());
Ok(parsed)
}
pub fn release(&self, file: FileId, adapter: &AdapterArc, vfs: &Vfs) {
let Ok(path) = vfs.path(file) else {
return;
};
self.cache.write().remove(&(
vfs.instance_id(),
file,
adapter.language_id(),
adapter.grammar_name_for_path(&path),
));
}
pub fn invalidate(&self, file: FileId) {
self.cache
.write()
.retain(|(_, cached_file, _, _), _| *cached_file != file);
}
fn checkout_parser(&self, grammar_name: &'static str) -> ParserLease {
let pool = self
.parsers
.lock()
.entry(grammar_name)
.or_insert_with(|| Arc::new(Mutex::new(Vec::new())))
.clone();
let parser = pool.lock().pop().unwrap_or_default();
ParserLease {
parser: Some(parser),
pool,
}
}
}
fn parsed_matches_snapshot(parsed: &ParsedFile, snapshot: &FileSnapshot) -> bool {
parsed.version == snapshot.version && Arc::ptr_eq(&parsed.source, &snapshot.text)
}
fn incremental_tree(parsed: &ParsedFile, new_source: &str) -> Option<Tree> {
if parsed.used_recovery
|| parsed
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code.as_deref() == Some("parse-timeout"))
{
return None;
}
let mut tree = parsed.tree.as_ref().clone();
if parsed.source.as_ref() != new_source {
tree.edit(&single_replacement_edit(&parsed.source, new_source));
}
Some(tree)
}
fn apply_recovery_edits(
source: &str,
recovered: &mut [u8],
edits: &[bonsai_lang_api::ParseRecoveryEdit],
) -> bool {
let mut changed = false;
for edit in edits {
changed |= edit.apply_to(source, recovered);
}
changed
}
fn diagnostics_for_tree(
file: FileId,
text_len: usize,
tree: &Tree,
timed_out: Option<Duration>,
) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
if let Some(timeout) = timed_out {
diagnostics.push(parse_timeout_diagnostic(file, text_len, timeout));
return diagnostics;
}
if !tree.root_node().has_error() {
return diagnostics;
}
let mut stack = vec![tree.root_node()];
while let Some(node) = stack.pop() {
let is_error = node.is_error();
let is_missing = node.is_missing();
if is_error || is_missing {
let span = span_for_node(file, node);
let msg = if is_missing {
format!("missing `{}`", node.kind())
} else {
"syntax error".to_string()
};
diagnostics.push(Diagnostic::new(span, Severity::Warning, msg).with_code("syntax-error"));
}
if !is_error {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.has_error() || child.is_missing() {
stack.push(child);
}
}
}
}
if diagnostics.is_empty() {
diagnostics.push(
Diagnostic::new(
file_span(file, text_len),
Severity::Warning,
"syntax errors present",
)
.with_code("syntax-error"),
);
}
diagnostics
}
fn single_replacement_edit(old: &str, new: &str) -> InputEdit {
let old_bytes = old.as_bytes();
let new_bytes = new.as_bytes();
let mut prefix = old_bytes
.iter()
.zip(new_bytes)
.take_while(|(old, new)| old == new)
.count();
while prefix > 0 && (!old.is_char_boundary(prefix) || !new.is_char_boundary(prefix)) {
prefix -= 1;
}
let max_suffix = old
.len()
.saturating_sub(prefix)
.min(new.len().saturating_sub(prefix));
let mut suffix = old_bytes[old.len() - max_suffix..]
.iter()
.rev()
.zip(new_bytes[new.len() - max_suffix..].iter().rev())
.take_while(|(old, new)| old == new)
.count();
while suffix > 0
&& (!old.is_char_boundary(old.len() - suffix) || !new.is_char_boundary(new.len() - suffix))
{
suffix -= 1;
}
let old_end = old.len() - suffix;
let new_end = new.len() - suffix;
InputEdit {
start_byte: prefix,
old_end_byte: old_end,
new_end_byte: new_end,
start_position: point_at_byte(old, prefix),
old_end_position: point_at_byte(old, old_end),
new_end_position: point_at_byte(new, new_end),
}
}
fn point_at_byte(text: &str, byte: usize) -> Point {
debug_assert!(byte <= text.len());
debug_assert!(text.is_char_boundary(byte));
let prefix = &text.as_bytes()[..byte];
let mut row = 0usize;
let mut line_start = 0usize;
for (index, value) in prefix.iter().enumerate() {
if *value == b'\n' {
row += 1;
line_start = index + 1;
}
}
let column = byte - line_start;
Point::new(row, column)
}
fn parse_timeout_from_env() -> Option<Duration> {
let Ok(raw) = std::env::var("BONSAI_PARSE_TIMEOUT_MS") else {
return None;
};
parse_timeout_millis(raw.trim().parse().ok()?)
}
fn parse_timeout_millis(ms: u64) -> Option<Duration> {
if ms == 0 {
None
} else {
Some(Duration::from_millis(ms))
}
}
fn parse_with_timeout(
parser: &mut Parser,
text: &str,
old_tree: Option<&Tree>,
timeout: Option<Duration>,
) -> Result<(Tree, Option<Duration>), ParseError> {
let Some(timeout) = timeout.filter(|timeout| !timeout.is_zero()) else {
let tree = parser
.parse(text.as_bytes(), old_tree)
.ok_or_else(|| AdapterError::Parse("tree-sitter returned None".to_string()))?;
return Ok((tree, None));
};
let start = std::time::Instant::now();
let bytes = text.as_bytes();
let len = bytes.len();
let mut timed_out = false;
let tree = {
let mut input = |byte_offset, _| {
if byte_offset < len {
&bytes[byte_offset..]
} else {
&[]
}
};
let mut progress = |_: &tree_sitter::ParseState| {
if start.elapsed() >= timeout {
timed_out = true;
ControlFlow::Break(())
} else {
ControlFlow::Continue(())
}
};
let options = ParseOptions::new().progress_callback(&mut progress);
parser.parse_with_options(&mut input, old_tree, Some(options))
};
match tree {
Some(tree) => Ok((tree, None)),
None if timed_out => {
parser.reset();
let empty_tree = parser.parse("", None).ok_or_else(|| {
AdapterError::Parse("tree-sitter returned None after parse timeout".to_string())
})?;
Ok((empty_tree, Some(timeout)))
}
None => Err(AdapterError::Parse("tree-sitter returned None".to_string()).into()),
}
}
fn span_for_node(file: FileId, node: Node<'_>) -> bonsai_common::Span {
bonsai_common::Span::new(
file,
saturating_byte_offset(node.start_byte()),
saturating_byte_offset(node.end_byte()),
)
}
fn file_span(file: FileId, text_len: usize) -> bonsai_common::Span {
bonsai_common::Span::new(file, 0, saturating_byte_offset(text_len))
}
fn saturating_byte_offset(byte: usize) -> u64 {
u64::try_from(byte).unwrap_or(u64::MAX)
}
fn parse_timeout_diagnostic(file: FileId, text_len: usize, timeout: Duration) -> Diagnostic {
Diagnostic::new(
file_span(file, text_len),
Severity::Warning,
format!("file skipped: parse timeout after {} ms", timeout.as_millis()),
)
.with_code("parse-timeout")
}
#[cfg(test)]
#[path = "tests.rs"]
mod tests;