use super::*;
#[test]
fn recovery_order_prioritizes_fewer_failed_grammar_productions() {
assert!(recovery_ordering_key((31_310, 1_301)) < recovery_ordering_key((29_009, 2_007)));
assert!(recovery_ordering_key((20, 3)) < recovery_ordering_key((40, 3)));
}
use bonsai_lang_api::{
AdapterContext, DeclIndex, GrammarHandler, ImportIndex, LanguageAdapter, LanguageCapabilities,
EMPTY_HANDLER,
};
struct TestPythonAdapter;
struct TestCAdapter;
struct TestTypeScriptAdapter;
const TEST_C_HANDLER: GrammarHandler = GrammarHandler {
parameter_container_kinds: &["parameter_list"],
parameter_kinds: &["parameter_declaration"],
fn_kinds: &["function_definition"],
call_kinds: &["call_expression"],
assignment_kinds: &["assignment_expression", "init_declarator"],
return_kinds: &["return_statement"],
if_kinds: &["if_statement"],
for_kinds: &["for_statement"],
while_kinds: &["while_statement"],
do_kinds: &["do_statement"],
..EMPTY_HANDLER
};
const TEST_TYPESCRIPT_HANDLER: GrammarHandler = GrammarHandler {
class_kinds: &["interface_declaration"],
call_kinds: &["call_expression"],
..EMPTY_HANDLER
};
impl LanguageAdapter for TestPythonAdapter {
fn language_id(&self) -> LanguageId {
LanguageId::new("python")
}
fn display_name(&self) -> &'static str {
"test python"
}
fn file_extensions(&self) -> &'static [&'static str] {
&["py"]
}
fn tree_sitter_language(&self) -> Result<tree_sitter::Language, AdapterError> {
bonsai_lang_api::kit::language_from_pack("python")
}
fn parse_normalization_edits(
&self,
snapshot: &bonsai_lang_api::FileSnapshot,
_vfs: &Vfs,
) -> Vec<bonsai_lang_api::ParseRecoveryEdit> {
(snapshot.path.file_name().and_then(|name| name.to_str()) == Some("template.py"))
.then(|| bonsai_lang_api::ParseRecoveryEdit::new(0, 6))
.into_iter()
.collect()
}
fn capabilities(&self) -> LanguageCapabilities {
LanguageCapabilities::unsupported()
}
fn extract_declarations(&self, file: FileId, _ctx: &AdapterContext<'_>) -> DeclIndex {
DeclIndex {
file,
..DeclIndex::default()
}
}
fn extract_imports(&self, file: FileId, _ctx: &AdapterContext<'_>) -> ImportIndex {
ImportIndex {
file,
imports: Vec::new(),
}
}
}
fn test_python_adapter() -> AdapterArc {
Arc::new(TestPythonAdapter)
}
impl LanguageAdapter for TestCAdapter {
fn language_id(&self) -> LanguageId {
LanguageId::new("c")
}
fn display_name(&self) -> &'static str {
"test C"
}
fn file_extensions(&self) -> &'static [&'static str] {
&["c"]
}
fn tree_sitter_language(&self) -> Result<tree_sitter::Language, AdapterError> {
bonsai_lang_api::kit::language_from_pack("c")
}
fn parse_recovery_edits(
&self,
snapshot: &bonsai_lang_api::FileSnapshot,
vfs: &Vfs,
tree: &Tree,
) -> Vec<bonsai_lang_api::ParseRecoveryEdit> {
bonsai_lang_api::c_family_declaration_macro_recovery_edits(
snapshot,
vfs,
tree,
&["va_arg", "__builtin_va_arg"],
)
}
fn capabilities(&self) -> LanguageCapabilities {
LanguageCapabilities::unsupported()
}
fn grammar_handler(&self) -> Option<&'static GrammarHandler> {
Some(&TEST_C_HANDLER)
}
fn extract_declarations(&self, file: FileId, _ctx: &AdapterContext<'_>) -> DeclIndex {
DeclIndex {
file,
..DeclIndex::default()
}
}
fn extract_imports(&self, file: FileId, _ctx: &AdapterContext<'_>) -> ImportIndex {
ImportIndex {
file,
imports: Vec::new(),
}
}
}
fn test_c_adapter() -> AdapterArc {
Arc::new(TestCAdapter)
}
impl LanguageAdapter for TestTypeScriptAdapter {
fn language_id(&self) -> LanguageId {
LanguageId::new("typescript")
}
fn display_name(&self) -> &'static str {
"test TypeScript"
}
fn file_extensions(&self) -> &'static [&'static str] {
&["ts"]
}
fn tree_sitter_language(&self) -> Result<tree_sitter::Language, AdapterError> {
bonsai_lang_api::kit::language_from_pack("typescript")
}
fn capabilities(&self) -> LanguageCapabilities {
LanguageCapabilities::unsupported()
}
fn grammar_handler(&self) -> Option<&'static GrammarHandler> {
Some(&TEST_TYPESCRIPT_HANDLER)
}
fn extract_declarations(&self, file: FileId, _ctx: &AdapterContext<'_>) -> DeclIndex {
DeclIndex {
file,
..DeclIndex::default()
}
}
fn extract_imports(&self, file: FileId, _ctx: &AdapterContext<'_>) -> ImportIndex {
ImportIndex {
file,
imports: Vec::new(),
}
}
}
#[test]
fn byte_offsets_saturate_instead_of_wrapping() {
assert_eq!(saturating_byte_offset(u64::MAX as usize), u64::MAX);
}
#[test]
fn zero_parse_timeout_disables_timeout() {
assert_eq!(parse_timeout_millis(0), None);
assert_eq!(parse_timeout_millis(5), Some(Duration::from_millis(5)));
}
#[test]
fn parser_default_is_uncapped_when_environment_is_unset() {
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _lock = ENV_LOCK.lock().expect("parse-timeout env lock");
let previous = std::env::var_os("BONSAI_PARSE_TIMEOUT_MS");
std::env::remove_var("BONSAI_PARSE_TIMEOUT_MS");
assert_eq!(ParserOptions::default().parse_timeout, None);
if let Some(previous) = previous {
std::env::set_var("BONSAI_PARSE_TIMEOUT_MS", previous);
} else {
std::env::remove_var("BONSAI_PARSE_TIMEOUT_MS");
}
}
#[test]
fn parse_timeout_diagnostic_is_file_level_warning() {
let diagnostic = parse_timeout_diagnostic(FileId::new(1), 42, Duration::from_millis(7));
assert_eq!(diagnostic.severity, Severity::Warning);
assert_eq!(diagnostic.code.as_deref(), Some("parse-timeout"));
assert_eq!(diagnostic.span, bonsai_common::Span::new(FileId::new(1), 0, 42));
assert_eq!(diagnostic.message, "file skipped: parse timeout after 7 ms");
}
#[test]
fn c_variadic_pointer_type_recovers_without_changing_source_coordinates() {
let source = "void exec_all(int count, ...) {\n\
va_list args;\n\
char *cmd = va_arg(args, char *);\n\
sink(cmd);\n\
}\n";
let cache = ParserCache::with_options(ParserOptions::with_parse_timeout(None));
let vfs = Vfs::new();
let file = vfs.write("variadic.c", source);
let parsed = cache
.parse(file, &test_c_adapter(), &vfs)
.expect("parse recovered C variadic fixture");
assert!(
parsed.used_recovery,
"pointer type operand should use grammar recovery"
);
assert!(!parsed.tree.root_node().has_error());
assert!(parsed.diagnostics.is_empty());
assert_eq!(parsed.source_text(), source);
}
#[test]
fn adapter_host_normalization_runs_before_the_first_parse() {
let source = "<html>\ndef embedded():\n return 1\n";
let cache = ParserCache::with_options(ParserOptions::with_parse_timeout(None));
let vfs = Vfs::new();
let file = vfs.write("template.py", source);
let parsed = cache
.parse(file, &test_python_adapter(), &vfs)
.expect("parse normalized host-language fixture");
assert!(parsed.used_recovery);
assert!(!parsed.tree.root_node().has_error());
assert!(parsed.diagnostics.is_empty());
assert_eq!(parsed.source_text(), source);
let function = parsed
.tree
.root_node()
.named_child(0)
.expect("embedded function declaration");
assert_eq!(function.kind(), "function_definition");
assert_eq!(function.start_byte(), 7);
}
#[test]
fn repeated_exact_snapshot_reuses_parsed_file_and_tree_arcs() {
let cache = ParserCache::with_options(ParserOptions::with_parse_timeout(None));
let vfs = Vfs::new();
let file = vfs.write("fixture.py", "def cached():\n return 1\n");
let adapter = test_python_adapter();
let first = cache.parse(file, &adapter, &vfs).expect("first parse");
let second = cache.parse(file, &adapter, &vfs).expect("cached parse");
assert!(Arc::ptr_eq(&first, &second));
assert!(Arc::ptr_eq(&first.tree, &second.tree));
}
#[test]
fn exact_release_evicts_only_the_lowered_workspace_file_and_language() {
let cache = ParserCache::with_options(ParserOptions::with_parse_timeout(None));
let vfs = Vfs::new();
let released_file = vfs.write("released.py", "def released():\n return 1\n");
let retained_file = vfs.write("retained.py", "def retained():\n return 2\n");
let adapter = test_python_adapter();
let released_before = cache
.parse(released_file, &adapter, &vfs)
.expect("parse released fixture");
let retained_before = cache
.parse(retained_file, &adapter, &vfs)
.expect("parse retained fixture");
cache.release(released_file, &adapter, &vfs);
let released_after = cache
.parse(released_file, &adapter, &vfs)
.expect("reparse released fixture");
let retained_after = cache
.parse(retained_file, &adapter, &vfs)
.expect("reuse retained fixture");
assert!(!Arc::ptr_eq(&released_before, &released_after));
assert!(!Arc::ptr_eq(&released_before.tree, &released_after.tree));
assert!(Arc::ptr_eq(&retained_before, &retained_after));
assert!(Arc::ptr_eq(&retained_before.tree, &retained_after.tree));
}
#[test]
fn reparsing_an_edit_keeps_tree_and_source_on_the_same_version() {
let cache = ParserCache::with_options(ParserOptions::with_parse_timeout(None));
let vfs = Vfs::new();
let file = vfs.write("fixture.py", "def before():\n return 1\n");
let adapter = test_python_adapter();
let before = cache.parse(file, &adapter, &vfs).expect("initial parse");
vfs.write("fixture.py", "def after():\n return 2\n");
let after = cache.parse(file, &adapter, &vfs).expect("edited parse");
assert_eq!(after.version, before.version + 1);
assert_eq!(after.source_text(), "def after():\n return 2\n");
assert_eq!(
first_node_text(&after.tree, after.source_text(), "identifier").as_deref(),
Some("after")
);
assert!(!Arc::ptr_eq(&before.tree, &after.tree));
}
#[test]
fn cache_identity_includes_the_vfs_instance() {
let cache = ParserCache::with_options(ParserOptions::with_parse_timeout(None));
let first_vfs = Vfs::new();
let second_vfs = Vfs::new();
let first_file = first_vfs.write("fixture.py", "def first():\n pass\n");
let second_file = second_vfs.write("fixture.py", "def second():\n pass\n");
assert_eq!(first_file, second_file, "fixture must exercise colliding FileIds");
let adapter = test_python_adapter();
let first = cache
.parse(first_file, &adapter, &first_vfs)
.expect("first workspace parse");
let second = cache
.parse(second_file, &adapter, &second_vfs)
.expect("second workspace parse");
assert_eq!(first.source_text(), "def first():\n pass\n");
assert_eq!(second.source_text(), "def second():\n pass\n");
assert!(!Arc::ptr_eq(&first.tree, &second.tree));
}
#[test]
fn immutable_snapshot_remains_parseable_after_live_file_removal() {
let cache = ParserCache::with_options(ParserOptions::with_parse_timeout(None));
let vfs = Vfs::new();
let file = vfs.write("removed.py", "def retained():\n return 1\n");
let snapshot = vfs.snapshot(file).unwrap();
vfs.remove(&snapshot.path);
let parsed = cache
.parse_snapshot(&snapshot, &test_python_adapter(), &vfs)
.unwrap();
assert_eq!(parsed.source_text(), snapshot.text.as_ref());
assert_eq!(
first_node_text(&parsed.tree, parsed.source_text(), "identifier").as_deref(),
Some("retained")
);
}
#[test]
fn same_language_worker_checkouts_are_not_globally_serialized() {
use std::sync::{mpsc, Condvar, Mutex as StdMutex};
let cache = ParserCache::with_options(ParserOptions::with_parse_timeout(None));
let release = Arc::new((StdMutex::new(false), Condvar::new()));
let (entered_tx, entered_rx) = mpsc::channel();
let mut workers = Vec::new();
for _ in 0..2 {
let cache = cache.clone();
let release = Arc::clone(&release);
let entered_tx = entered_tx.clone();
workers.push(std::thread::spawn(move || {
let _parser = cache.checkout_parser("python");
entered_tx.send(()).expect("announce parser checkout");
let (lock, wake) = &*release;
let mut released = lock.lock().expect("release lock");
while !*released {
released = wake.wait(released).expect("release wait");
}
}));
}
drop(entered_tx);
let first_entered = entered_rx.recv_timeout(Duration::from_secs(2));
let second_entered = entered_rx.recv_timeout(Duration::from_secs(2));
{
let (lock, wake) = &*release;
*lock.lock().expect("release lock") = true;
wake.notify_all();
}
for worker in workers {
worker.join().expect("parser worker");
}
assert!(first_entered.is_ok(), "first worker never checked out a parser");
assert!(
second_entered.is_ok(),
"same-language worker was serialized behind the first parser checkout"
);
}
#[test]
fn replacement_edit_uses_utf8_boundaries_and_exact_points() {
let old = "let café = 1;\nnext();\n";
let new = "let cañon = 22;\nnext();\n";
let edit = single_replacement_edit(old, new);
assert!(old.is_char_boundary(edit.start_byte));
assert!(old.is_char_boundary(edit.old_end_byte));
assert!(new.is_char_boundary(edit.new_end_byte));
assert_eq!(edit.start_position, point_at_byte(old, edit.start_byte));
assert_eq!(edit.old_end_position, point_at_byte(old, edit.old_end_byte));
assert_eq!(edit.new_end_position, point_at_byte(new, edit.new_end_byte));
}
#[test]
fn recovery_candidate_cannot_trade_away_a_clean_compiler_construct() {
let language = bonsai_lang_api::kit::language_from_pack("c").expect("C grammar");
let parse = |source: &str| {
let mut parser = Parser::new();
parser.set_language(&language).expect("set C grammar");
parser.parse(source, None).expect("parse monotonicity fixture")
};
let current_source = "int retained(void) { return 1; }\n@\n";
let retained_candidate = "int retained(void) { return 1; }\n \n";
let displaced_candidate = " ".repeat(current_source.len());
assert_eq!(current_source.len(), retained_candidate.len());
assert_eq!(current_source.len(), displaced_candidate.len());
let current = parse(current_source);
let retained = parse(retained_candidate);
let displaced = parse(&displaced_candidate);
let adapter = TestCAdapter;
let path = std::path::Path::new("fixture.c");
assert!(recovery_preserves_clean_compiler_nodes(
&adapter,
path,
¤t,
&retained,
&[]
));
assert!(
!recovery_preserves_clean_compiler_nodes(&adapter, path, ¤t, &displaced, &[]),
"a lower-damage parse that deletes an existing clean function is not a valid recovery"
);
}
#[test]
fn explicit_damaged_descendant_replacement_owns_only_its_exact_subtree() {
let language = bonsai_lang_api::kit::language_from_pack("typescript").expect("TypeScript grammar");
let parse = |source: &str| {
let mut parser = Parser::new();
parser.set_language(&language).expect("set TypeScript grammar");
parser.parse(source, None).expect("parse recovery fixture")
};
let source = "interface Store { get: () => import('pkg').Value[]; keep: string; }\nkeep();";
let replacement = "interface Store { get: () => IMPORTTYPE .Value[]; keep: string; }\nkeep();";
let displaced = "interface Store { get: () => IMPORTTYPE .Value[]; keep: string; }\n ";
assert_eq!(source.len(), replacement.len());
assert_eq!(source.len(), displaced.len());
let current = parse(source);
let candidate = parse(replacement);
let displaced = parse(displaced);
assert!(current.root_node().has_error());
assert!(!candidate.root_node().has_error());
let object = {
let mut pending = vec![current.root_node()];
loop {
let node = pending.pop().expect("damaged import-type call");
if node.kind() == "call_expression"
&& source.as_bytes().get(node.start_byte()..node.end_byte()) == Some(b"import('pkg')")
{
break node;
}
let mut cursor = node.walk();
pending.extend(node.named_children(&mut cursor));
}
};
let edit = bonsai_lang_api::ParseRecoveryEdit::replace_damaged_descendant_ascii(
object.start_byte(),
object.end_byte(),
b"IMPORTTYPE",
);
let adapter = TestTypeScriptAdapter;
assert!(recovery_preserves_clean_compiler_nodes(
&adapter,
std::path::Path::new("fixture.ts"),
¤t,
&candidate,
&[edit],
));
assert!(
!recovery_preserves_clean_compiler_nodes(
&adapter,
std::path::Path::new("fixture.ts"),
¤t,
&displaced,
&[edit],
),
"explicit ownership of the damaged import-type subtree must not permit a disjoint call to disappear"
);
}
#[test]
fn syntax_diagnostics_cover_nested_error_and_missing_nodes() {
use std::collections::BTreeMap;
fn error_spans(tree: &Tree) -> (BTreeMap<(u64, u64), usize>, bool) {
let mut spans = BTreeMap::new();
let mut nested = false;
let mut stack = vec![(tree.root_node(), false)];
while let Some((node, inside_error)) = stack.pop() {
let is_error = node.is_error() || node.is_missing();
if is_error {
*spans
.entry((node.start_byte() as u64, node.end_byte() as u64))
.or_insert(0) += 1;
nested |= inside_error;
}
let mut cursor = node.walk();
stack.extend(
node.children(&mut cursor)
.map(|child| (child, inside_error || is_error)),
);
}
(spans, nested)
}
let candidates = [
("nested.py", "def outer(\n if (\n value = [1, {\n"),
(
"nested.py",
"class Broken(\n def method(self, :\n return (\n",
),
("nested.c", "void broken( { if (value { call( ; }\n"),
("nested.c", "struct S { int x[; void f( { return ( ; }\n"),
];
let cache = ParserCache::with_options(ParserOptions::with_parse_timeout(None));
let vfs = Vfs::new();
let mut selected = None;
for (path, source) in candidates {
let file = vfs.write(path, source);
let adapter = if std::path::Path::new(path)
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("py"))
{
test_python_adapter()
} else {
test_c_adapter()
};
let parsed = cache
.parse(file, &adapter, &vfs)
.expect("malformed input still parses");
let (expected, has_nested) = error_spans(&parsed.tree);
if has_nested {
selected = Some((path, expected, parsed.diagnostics.clone()));
break;
}
}
let (path, expected, diagnostics) =
selected.expect("at least one malformed fixture must exercise nested parser recovery");
let mut actual = BTreeMap::new();
for diagnostic in diagnostics {
if diagnostic.code.as_deref() == Some("syntax-error") {
*actual
.entry((diagnostic.span.start, diagnostic.span.end))
.or_insert(0) += 1;
}
}
assert_eq!(
actual, expected,
"{path}: parser diagnostics must enumerate every ERROR/MISSING node, including descendants"
);
}
fn first_node_text(tree: &Tree, source: &str, kind: &str) -> Option<String> {
let mut stack = vec![tree.root_node()];
while let Some(node) = stack.pop() {
if node.kind() == kind {
return source.get(node.byte_range()).map(ToOwned::to_owned);
}
let mut cursor = node.walk();
stack.extend(node.named_children(&mut cursor));
}
None
}