use std::path::PathBuf;
use std::sync::Arc;
use harn_hostlib::{ast::AstCapability, BuiltinRegistry, HostlibCapability};
use harn_vm::VmValue;
fn ast_registry() -> BuiltinRegistry {
let mut registry = BuiltinRegistry::new();
AstCapability.register_builtins(&mut registry);
registry
}
fn dict(pairs: &[(&str, VmValue)]) -> VmValue {
let mut map: harn_vm::value::DictMap = Default::default();
for (k, v) in pairs {
map.insert((*k).into(), v.clone());
}
VmValue::dict(map)
}
fn fixture_path(rel: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/ast")
.join(rel)
}
fn structural_diff_fixture_path(rel: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/structural_diff")
.join(rel)
}
fn invoke(registry: &BuiltinRegistry, name: &str, payload: VmValue) -> VmValue {
let entry = registry
.find(name)
.unwrap_or_else(|| panic!("builtin {name} not registered"));
(entry.handler)(&[payload]).unwrap_or_else(|err| panic!("{name} failed: {err}"))
}
fn dict_field(value: &VmValue, key: &str) -> VmValue {
match value {
VmValue::Dict(d) => d
.get(key)
.cloned()
.unwrap_or_else(|| panic!("missing field `{key}` on {value:?}")),
other => panic!("expected dict, got {other:?}"),
}
}
fn string_value(value: &VmValue) -> &str {
match value {
VmValue::String(s) => s.as_str(),
other => panic!("expected string, got {other:?}"),
}
}
fn list_value(value: &VmValue) -> Arc<Vec<VmValue>> {
match value {
VmValue::List(l) => l.clone(),
other => panic!("expected list, got {other:?}"),
}
}
fn int_value(value: &VmValue) -> i64 {
match value {
VmValue::Int(n) => *n,
other => panic!("expected int, got {other:?}"),
}
}
fn bool_value(value: &VmValue) -> bool {
match value {
VmValue::Bool(b) => *b,
other => panic!("expected bool, got {other:?}"),
}
}
#[test]
fn parse_file_produces_flat_node_list_with_root_id_zero() {
let registry = ast_registry();
let path = fixture_path("rust/source.rs");
let payload = dict(&[(
"path",
VmValue::String(arcstr::ArcStr::from(path.to_string_lossy().as_ref())),
)]);
let result = invoke(®istry, "hostlib_ast_parse_file", payload);
assert_eq!(string_value(&dict_field(&result, "language")), "rust");
assert_eq!(int_value(&dict_field(&result, "root_id")), 0);
let nodes = list_value(&dict_field(&result, "nodes"));
assert!(
nodes.len() > 5,
"expected non-trivial tree, got {} nodes",
nodes.len()
);
let first = &nodes[0];
assert!(matches!(dict_field(first, "parent_id"), VmValue::Nil));
for node in nodes.iter().skip(1) {
match dict_field(node, "parent_id") {
VmValue::Int(n) => assert!(
n >= 0 && (n as usize) < nodes.len(),
"parent_id out of range: {n}"
),
other => panic!("expected int parent_id, got {other:?}"),
}
}
}
#[test]
fn parse_file_rejects_unknown_language() {
let registry = ast_registry();
let entry = registry.find("hostlib_ast_parse_file").expect("registered");
let payload = dict(&[
("path", VmValue::String(arcstr::ArcStr::from("foo.unknown"))),
("language", VmValue::String(arcstr::ArcStr::from("klingon"))),
]);
let err = (entry.handler)(&[payload]).expect_err("must reject unknown language");
assert!(
err.to_string().contains("klingon") || err.to_string().contains("could not infer"),
"unexpected error message: {err}",
);
}
#[test]
fn symbols_filters_by_kind() {
let registry = ast_registry();
let path = fixture_path("rust/source.rs");
let kinds = VmValue::List(Arc::new(vec![VmValue::String(arcstr::ArcStr::from(
"function",
))]));
let payload = dict(&[
(
"path",
VmValue::String(arcstr::ArcStr::from(path.to_string_lossy().as_ref())),
),
("kinds", kinds),
]);
let result = invoke(®istry, "hostlib_ast_symbols", payload);
let symbols = list_value(&dict_field(&result, "symbols"));
assert!(!symbols.is_empty());
for sym in symbols.iter() {
assert_eq!(string_value(&dict_field(sym, "kind")), "function");
}
}
#[test]
fn outline_caps_depth_when_max_depth_supplied() {
let registry = ast_registry();
let path = fixture_path("python/source.py");
let payload = dict(&[
(
"path",
VmValue::String(arcstr::ArcStr::from(path.to_string_lossy().as_ref())),
),
("max_depth", VmValue::Int(1)),
]);
let result = invoke(®istry, "hostlib_ast_outline", payload);
let items = list_value(&dict_field(&result, "items"));
assert!(!items.is_empty());
for item in items.iter() {
let children = list_value(&dict_field(item, "children"));
assert!(
children.is_empty(),
"max_depth=1 must cap to root level, got children {children:?}"
);
}
}
fn vm_string(s: &str) -> VmValue {
VmValue::String(arcstr::ArcStr::from(s))
}
#[test]
fn symbol_extract_returns_one_based_inclusive_lines() {
let registry = ast_registry();
let source = "fn alpha() {}\nfn beta() {}\nfn gamma() {}\n";
let payload = dict(&[
("source", vm_string(source)),
("language", vm_string("rust")),
("symbol_name", vm_string("beta")),
]);
let result = invoke(®istry, "hostlib_ast_symbol_extract", payload);
assert_eq!(string_value(&dict_field(&result, "result")), "extracted");
assert_eq!(int_value(&dict_field(&result, "start_line")), 2);
assert_eq!(int_value(&dict_field(&result, "end_line")), 2);
assert_eq!(string_value(&dict_field(&result, "text")), "fn beta() {}");
}
#[test]
fn symbol_delete_removes_function_and_keeps_syntax_valid() {
let registry = ast_registry();
let source = "fn alpha() {}\nfn beta() {}\nfn gamma() {}\n";
let payload = dict(&[
("source", vm_string(source)),
("language", vm_string("rust")),
("symbol_name", vm_string("beta")),
]);
let result = invoke(®istry, "hostlib_ast_symbol_delete", payload);
assert_eq!(string_value(&dict_field(&result, "result")), "removed");
let new_source = string_value(&dict_field(&result, "source")).to_string();
assert!(!new_source.contains("beta"));
assert!(new_source.contains("alpha"));
assert!(new_source.contains("gamma"));
}
#[test]
fn symbol_replace_swaps_in_caller_text() {
let registry = ast_registry();
let source = "fn alpha() {}\nfn beta() -> i32 { 0 }\n";
let payload = dict(&[
("source", vm_string(source)),
("language", vm_string("rust")),
("symbol_name", vm_string("beta")),
("new_text", vm_string("fn beta() -> i32 { 42 }")),
]);
let result = invoke(®istry, "hostlib_ast_symbol_replace", payload);
assert_eq!(string_value(&dict_field(&result, "result")), "replaced");
assert!(string_value(&dict_field(&result, "source")).contains("42"));
}
#[test]
fn symbol_replace_flags_post_edit_syntax_error() {
let registry = ast_registry();
let source = "fn alpha() {}\nfn beta() {}\n";
let payload = dict(&[
("source", vm_string(source)),
("language", vm_string("rust")),
("symbol_name", vm_string("beta")),
("new_text", vm_string("fn beta( {")),
]);
let result = invoke(®istry, "hostlib_ast_symbol_replace", payload);
assert_eq!(
string_value(&dict_field(&result, "result")),
"syntax_error_after_edit"
);
assert!(!string_value(&dict_field(&result, "details")).is_empty());
}
#[test]
fn symbol_lookup_reports_ambiguity_with_match_count() {
let registry = ast_registry();
let source = "class A:\n def greet(self): pass\nclass B:\n def greet(self): pass\n";
let payload = dict(&[
("source", vm_string(source)),
("language", vm_string("python")),
("symbol_name", vm_string("greet")),
]);
let result = invoke(®istry, "hostlib_ast_symbol_extract", payload);
assert_eq!(string_value(&dict_field(&result, "result")), "ambiguous");
assert!(int_value(&dict_field(&result, "match_count")) >= 2);
}
#[test]
fn symbol_lookup_qualified_name_disambiguates() {
let registry = ast_registry();
let source = "class A:\n def greet(self): pass\nclass B:\n def greet(self): pass\n";
let payload = dict(&[
("source", vm_string(source)),
("language", vm_string("python")),
("symbol_name", vm_string("B.greet")),
]);
let result = invoke(®istry, "hostlib_ast_symbol_extract", payload);
assert_eq!(string_value(&dict_field(&result, "result")), "extracted");
assert!(string_value(&dict_field(&result, "text")).contains("greet"));
assert_eq!(int_value(&dict_field(&result, "start_line")), 4);
}
#[test]
fn symbol_lookup_emits_suggestions_when_typo_misses() {
let registry = ast_registry();
let source = "fn parse_query() {}\nfn parse_other() {}\n";
let payload = dict(&[
("source", vm_string(source)),
("language", vm_string("rust")),
("symbol_name", vm_string("parse_qury")),
]);
let result = invoke(®istry, "hostlib_ast_symbol_extract", payload);
assert_eq!(string_value(&dict_field(&result, "result")), "not_found");
let suggestions = list_value(&dict_field(&result, "suggestions"));
assert!(!suggestions.is_empty());
let names: Vec<&str> = suggestions.iter().map(string_value).collect();
assert!(names.contains(&"parse_query"));
}
#[test]
fn unsupported_language_short_circuits_on_mutation_builtins() {
let registry = ast_registry();
for builtin in &[
"hostlib_ast_symbol_extract",
"hostlib_ast_symbol_delete",
"hostlib_ast_symbol_replace",
] {
let mut entries = vec![
("source", vm_string("hello")),
("language", vm_string("klingon")),
("symbol_name", vm_string("greet")),
];
if *builtin == "hostlib_ast_symbol_replace" {
entries.push(("new_text", vm_string("replacement")));
}
let result = invoke(®istry, builtin, dict(&entries));
assert_eq!(
string_value(&dict_field(&result, "result")),
"unsupported_language",
"{builtin} did not short-circuit on klingon",
);
}
}
#[test]
fn bracket_balance_counts_unmatched_opener() {
let registry = ast_registry();
let payload = dict(&[
("source", vm_string("fn foo() {")),
("language", vm_string("rust")),
]);
let result = invoke(®istry, "hostlib_ast_bracket_balance", payload);
assert_eq!(int_value(&dict_field(&result, "parens")), 0);
assert_eq!(int_value(&dict_field(&result, "brackets")), 0);
assert_eq!(int_value(&dict_field(&result, "braces")), 1);
}
#[test]
fn bracket_balance_ignores_brackets_inside_strings() {
let registry = ast_registry();
let payload = dict(&[
("source", vm_string(r#"let s = "}{)";"#)),
("language", vm_string("rust")),
]);
let result = invoke(®istry, "hostlib_ast_bracket_balance", payload);
assert_eq!(int_value(&dict_field(&result, "parens")), 0);
assert_eq!(int_value(&dict_field(&result, "braces")), 0);
}
#[test]
fn bracket_balance_python_uses_hash_comments() {
let registry = ast_registry();
let payload = dict(&[
("source", vm_string("x = 5 // 2 # cmt with [\n")),
("language", vm_string("python")),
]);
let result = invoke(®istry, "hostlib_ast_bracket_balance", payload);
assert_eq!(int_value(&dict_field(&result, "brackets")), 0);
assert_eq!(int_value(&dict_field(&result, "parens")), 0);
}
#[test]
fn parse_errors_returns_clean_payload_for_valid_python() {
let registry = ast_registry();
let payload = dict(&[
("content", VmValue::String(arcstr::ArcStr::from("x = 1\n"))),
("language", VmValue::String(arcstr::ArcStr::from("python"))),
]);
let result = invoke(®istry, "hostlib_ast_parse_errors", payload);
assert_eq!(string_value(&dict_field(&result, "language")), "python");
let errors = list_value(&dict_field(&result, "errors"));
assert!(errors.is_empty(), "valid Python should have no errors");
let supported = match dict_field(&result, "supported") {
VmValue::Bool(b) => b,
other => panic!("expected bool, got {other:?}"),
};
assert!(supported);
}
#[test]
fn parse_errors_flags_typescript_syntax_error() {
let registry = ast_registry();
let payload = dict(&[
(
"content",
VmValue::String(arcstr::ArcStr::from("function foo(\n return 1;\n}")),
),
(
"language",
VmValue::String(arcstr::ArcStr::from("typescript")),
),
]);
let result = invoke(®istry, "hostlib_ast_parse_errors", payload);
let errors = list_value(&dict_field(&result, "errors"));
assert!(!errors.is_empty(), "expected errors, got {errors:?}");
for entry in errors.iter() {
for field in [
"start_row",
"start_col",
"end_row",
"end_col",
"start_byte",
"end_byte",
"message",
"snippet",
"missing",
] {
let _ = dict_field(entry, field);
}
}
}
#[test]
fn parse_errors_top_level_decl_count_matches_swift_profile() {
let registry = ast_registry();
let cases = [
("rust", "fn a() {}\nfn b() {}\nstruct C;\n", 3),
("python", "def a():\n pass\nclass B:\n pass\n", 2),
(
"typescript",
"export function a() {}\nexport const b = 1;\nfunction c() {}\n",
5,
),
];
for (lang, src, want) in cases {
let payload = dict(&[
("content", VmValue::String(arcstr::ArcStr::from(src))),
("language", VmValue::String(arcstr::ArcStr::from(lang))),
]);
let result = invoke(®istry, "hostlib_ast_parse_errors", payload);
let count = int_value(&dict_field(&result, "top_level_decl_count"));
assert_eq!(count, want, "{lang} top_level_decl_count");
}
}
#[test]
fn undefined_names_python_returns_dedup_diagnostics() {
let registry = ast_registry();
let payload = dict(&[
(
"content",
VmValue::String(arcstr::ArcStr::from(
"import os\n\
def foo(x):\n \
return x + missing()\n\
missing()\n\
missing()\n",
)),
),
("language", VmValue::String(arcstr::ArcStr::from("python"))),
]);
let result = invoke(®istry, "hostlib_ast_undefined_names", payload);
let supported = match dict_field(&result, "supported") {
VmValue::Bool(b) => b,
other => panic!("expected bool, got {other:?}"),
};
assert!(supported);
let diagnostics = list_value(&dict_field(&result, "diagnostics"));
let names: Vec<String> = diagnostics
.iter()
.map(|d| string_value(&dict_field(d, "name")).to_string())
.collect();
assert_eq!(
names,
vec!["missing".to_string()],
"expected single dedup'd 'missing', got {names:?}"
);
for d in diagnostics.iter() {
let kind_value = dict_field(d, "kind");
let kind = string_value(&kind_value).to_string();
assert!(kind == "identifier" || kind == "type", "kind = {kind}");
let msg_value = dict_field(d, "message");
let message = string_value(&msg_value).to_string();
assert!(message.contains("undefined name"), "message = {message}");
}
}
#[test]
fn undefined_names_marks_unsupported_languages() {
let registry = ast_registry();
let payload = dict(&[
(
"content",
VmValue::String(arcstr::ArcStr::from("fn main() {}\n")),
),
("language", VmValue::String(arcstr::ArcStr::from("rust"))),
]);
let result = invoke(®istry, "hostlib_ast_undefined_names", payload);
let supported = match dict_field(&result, "supported") {
VmValue::Bool(b) => b,
other => panic!("expected bool, got {other:?}"),
};
assert!(
!supported,
"rust isn't in the undefined-name profile set; must report supported = false"
);
let diagnostics = list_value(&dict_field(&result, "diagnostics"));
assert!(diagnostics.is_empty());
}
fn structural_diff_payload(before: &str, after: &str, extra: &[(&str, VmValue)]) -> VmValue {
let before_path = structural_diff_fixture_path(before);
let after_path = structural_diff_fixture_path(after);
let mut pairs = vec![
(
"path_a",
VmValue::String(arcstr::ArcStr::from(before_path.to_string_lossy().as_ref())),
),
(
"path_b",
VmValue::String(arcstr::ArcStr::from(after_path.to_string_lossy().as_ref())),
),
("language", VmValue::String(arcstr::ArcStr::from("rust"))),
];
pairs.extend_from_slice(extra);
dict(&pairs)
}
fn structural_summary(result: &VmValue) -> VmValue {
dict_field(result, "summary")
}
#[test]
fn structural_diff_ignores_reformat_only_changes() {
let registry = ast_registry();
let payload = structural_diff_payload("reformat_before.rs", "reformat_after.rs", &[]);
let result = invoke(®istry, "hostlib_ast_structural_diff", payload);
assert_eq!(string_value(&dict_field(&result, "result")), "ok");
assert_eq!(string_value(&dict_field(&result, "mode")), "structural");
assert!(!bool_value(&dict_field(&result, "changed")));
let changes = list_value(&dict_field(&result, "changes"));
assert!(changes.is_empty());
assert_eq!(
int_value(&dict_field(&structural_summary(&result), "change_count")),
0
);
}
#[test]
fn structural_diff_reports_rename_as_identifier_replacement() {
let registry = ast_registry();
let payload = structural_diff_payload("rename_before.rs", "rename_after.rs", &[]);
let result = invoke(®istry, "hostlib_ast_structural_diff", payload);
assert_eq!(string_value(&dict_field(&result, "mode")), "structural");
let changes = list_value(&dict_field(&result, "changes"));
assert_eq!(
changes.len(),
1,
"rename should produce one syntax change: {changes:?}"
);
assert_eq!(string_value(&dict_field(&changes[0], "kind")), "replace");
assert_eq!(
string_value(&dict_field(&changes[0], "node_kind")),
"identifier"
);
assert_eq!(
string_value(&dict_field(&changes[0], "before_text")),
"value"
);
assert_eq!(
string_value(&dict_field(&changes[0], "after_text")),
"total"
);
}
#[test]
fn structural_diff_reports_moved_nodes_without_line_patch_noise() {
let registry = ast_registry();
let payload = structural_diff_payload("move_before.rs", "move_after.rs", &[]);
let result = invoke(®istry, "hostlib_ast_structural_diff", payload);
assert_eq!(string_value(&dict_field(&result, "mode")), "structural");
let summary = structural_summary(&result);
assert!(int_value(&dict_field(&summary, "moves")) >= 1);
let changes = list_value(&dict_field(&result, "changes"));
assert_eq!(string_value(&dict_field(&changes[0], "kind")), "move");
assert_eq!(
string_value(&dict_field(&changes[0], "node_kind")),
"function_item"
);
assert!(matches!(dict_field(&result, "line_diff"), VmValue::Nil));
}
#[test]
fn structural_diff_reports_logic_change_as_literal_replacement() {
let registry = ast_registry();
let payload = structural_diff_payload("logic_before.rs", "logic_after.rs", &[]);
let result = invoke(®istry, "hostlib_ast_structural_diff", payload);
assert_eq!(string_value(&dict_field(&result, "mode")), "structural");
let changes = list_value(&dict_field(&result, "changes"));
assert_eq!(
changes.len(),
1,
"logic change should stay focused: {changes:?}"
);
assert_eq!(string_value(&dict_field(&changes[0], "kind")), "replace");
assert_eq!(
string_value(&dict_field(&changes[0], "node_kind")),
"integer_literal"
);
assert_eq!(string_value(&dict_field(&changes[0], "before_text")), "41");
assert_eq!(string_value(&dict_field(&changes[0], "after_text")), "42");
}
#[test]
fn structural_diff_falls_back_to_line_diff_when_limit_is_exceeded() {
let registry = ast_registry();
let payload = structural_diff_payload(
"logic_before.rs",
"logic_after.rs",
&[("max_bytes", VmValue::Int(8))],
);
let result = invoke(®istry, "hostlib_ast_structural_diff", payload);
assert_eq!(string_value(&dict_field(&result, "result")), "fallback");
assert_eq!(string_value(&dict_field(&result, "mode")), "line");
assert!(string_value(&dict_field(&result, "fallback_reason")).contains("byte_limit_exceeded"));
let line_diff = dict_field(&result, "line_diff");
assert!(string_value(&dict_field(&line_diff, "diff")).contains("- 41"));
assert!(string_value(&dict_field(&line_diff, "diff")).contains("+ 42"));
}
#[test]
fn structural_diff_enforces_node_limit_before_descent() {
let registry = ast_registry();
let payload = structural_diff_payload(
"logic_before.rs",
"logic_after.rs",
&[("max_nodes", VmValue::Int(1))],
);
let result = invoke(®istry, "hostlib_ast_structural_diff", payload);
assert_eq!(string_value(&dict_field(&result, "result")), "fallback");
assert_eq!(string_value(&dict_field(&result, "mode")), "line");
assert!(string_value(&dict_field(&result, "fallback_reason")).contains("node_limit_exceeded"));
assert_eq!(
int_value(&dict_field(&structural_summary(&result), "nodes_before")),
0
);
}
#[test]
fn structural_diff_falls_back_to_line_diff_on_parse_error() {
let registry = ast_registry();
let payload = structural_diff_payload("logic_before.rs", "parse_error_after.rs", &[]);
let result = invoke(®istry, "hostlib_ast_structural_diff", payload);
assert_eq!(string_value(&dict_field(&result, "result")), "fallback");
assert_eq!(string_value(&dict_field(&result, "mode")), "line");
assert_eq!(
string_value(&dict_field(&result, "fallback_reason")),
"parse_error"
);
let line_diff = dict_field(&result, "line_diff");
assert!(!string_value(&dict_field(&line_diff, "diff")).is_empty());
}