use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
use harn_vm::process_sandbox::FsAccess;
use harn_vm::VmValue;
use streaming_iterator::StreamingIterator;
use tree_sitter::{Node, Query, QueryCursor, QueryError};
use crate::error::HostlibError;
use crate::tools::args::{
build_dict, dict_arg, optional_bool, optional_int, optional_string, require_string, str_value,
};
use crate::tools::permissions::enforce_path_scope;
use super::edit_common::{
first_syntax_error, format_query_error, read_source, resolve_target_capture, sha256_hex,
write_source,
};
use super::language::{Language, TEXT_PATCH_FALLBACK};
use super::parse::parse_source;
const BUILTIN: &str = "hostlib_ast_insert_at_anchor";
const DEFAULT_TARGET_CAPTURE: &str = "anchor";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Position {
Before,
After,
FirstChild,
LastChild,
}
impl Position {
fn parse(raw: &str) -> Result<Self, HostlibError> {
Ok(match raw {
"before" => Self::Before,
"after" => Self::After,
"first_child" => Self::FirstChild,
"last_child" => Self::LastChild,
other => {
return Err(HostlibError::InvalidParameter {
builtin: BUILTIN,
param: "position",
message: format!(
"expected one of [\"before\", \"after\", \"first_child\", \"last_child\"], \
got `{other}`"
),
});
}
})
}
fn as_str(self) -> &'static str {
match self {
Self::Before => "before",
Self::After => "after",
Self::FirstChild => "first_child",
Self::LastChild => "last_child",
}
}
}
pub(super) fn run(args: &[VmValue]) -> Result<VmValue, HostlibError> {
let raw = dict_arg(BUILTIN, args)?;
let dict = raw.as_ref();
let path_str = require_string(BUILTIN, dict, "path")?;
let query_text = require_string(BUILTIN, dict, "query")?;
let position_raw = require_string(BUILTIN, dict, "position")?;
let position = Position::parse(&position_raw)?;
let content = require_string(BUILTIN, dict, "content")?;
let language_hint = optional_string(BUILTIN, dict, "language")?;
let target_capture = optional_string(BUILTIN, dict, "target_capture")?
.unwrap_or_else(|| DEFAULT_TARGET_CAPTURE.to_string());
let indent_override = optional_string(BUILTIN, dict, "indent")?;
let reindent = optional_bool(BUILTIN, dict, "reindent", true)?;
let dry_run = optional_bool(BUILTIN, dict, "dry_run", false)?;
let validate = optional_bool(BUILTIN, dict, "validate", true)?;
let session_id = optional_string(BUILTIN, dict, "session_id")?;
let max_bytes = optional_int(BUILTIN, dict, "max_bytes", 0)?;
if max_bytes < 0 {
return Err(HostlibError::InvalidParameter {
builtin: BUILTIN,
param: "max_bytes",
message: "must be >= 0".into(),
});
}
let path = PathBuf::from(&path_str);
enforce_path_scope(BUILTIN, &path, FsAccess::Write)?;
let language = match Language::detect(&path, language_hint.as_deref()) {
Some(l) => l,
None => {
return Ok(unsupported_language_response(
&path_str,
language_hint.as_deref(),
));
}
};
let source = read_source(BUILTIN, &path, session_id.as_deref(), max_bytes as usize)?;
let query = match Query::new(&language.ts_language(), &query_text) {
Ok(q) => q,
Err(err) => return Ok(invalid_query_response(&query_text, &err)),
};
let target_index = match resolve_target_capture(&query, &target_capture) {
Ok(idx) => idx,
Err(detail) => {
return Ok(no_match_response(
&path_str,
&query_text,
&target_capture,
&detail,
));
}
};
let tree = parse_source(&source, language).map_err(|err| HostlibError::Backend {
builtin: BUILTIN,
message: err.to_string(),
})?;
let anchors = collect_anchors(&query, &tree, source.as_bytes(), target_index);
if anchors.is_empty() {
return Ok(no_match_response(
&path_str,
&query_text,
&target_capture,
"query produced zero anchors",
));
}
if anchors.len() > 1 {
return Ok(ambiguous_response(&anchors));
}
let anchor_node = locate_node(tree.root_node(), anchors[0].start_byte, anchors[0].end_byte)
.expect("anchor node must still be reachable in the parse tree");
let plan = match build_insertion(
&source,
anchor_node,
position,
language,
indent_override.as_deref(),
) {
Ok(p) => p,
Err(invalid) => {
return Ok(invalid_anchor_response(
&path_str,
position,
&anchors[0],
&invalid,
));
}
};
let body = if reindent {
reindent_lines(&content, &plan.target_indent)
} else {
content
};
let insertion_text = format!("{}{body}{}", plan.prefix, plan.suffix);
let patched = splice_insert(&source, plan.insertion_byte, &insertion_text);
if validate {
if let Some(detail) = first_syntax_error(&patched, language) {
return Ok(syntax_error_response(&path_str, &patched, &detail));
}
}
if !dry_run {
write_source(BUILTIN, &path, &patched, session_id.as_deref())?;
}
Ok(applied_response(
&path_str,
&source,
&patched,
&anchors[0],
plan.insertion_byte,
&insertion_text,
position,
&plan.target_indent,
dry_run,
))
}
#[derive(Debug, Clone)]
struct AnchorSpan {
start_byte: usize,
end_byte: usize,
start_row: usize,
start_col: usize,
end_row: usize,
end_col: usize,
text: String,
}
fn collect_anchors(
query: &Query,
tree: &tree_sitter::Tree,
source_bytes: &[u8],
target_index: u32,
) -> Vec<AnchorSpan> {
let mut cursor = QueryCursor::new();
let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
let mut seen: BTreeMap<(usize, usize), AnchorSpan> = BTreeMap::new();
while let Some(m) = matches.next() {
for capture in m.captures {
if capture.index != target_index {
continue;
}
let node = capture.node;
let key = (node.start_byte(), node.end_byte());
seen.entry(key).or_insert_with(|| {
let start = node.start_position();
let end = node.end_position();
let text = std::str::from_utf8(&source_bytes[node.start_byte()..node.end_byte()])
.unwrap_or_default()
.to_string();
AnchorSpan {
start_byte: node.start_byte(),
end_byte: node.end_byte(),
start_row: start.row,
start_col: start.column,
end_row: end.row,
end_col: end.column,
text,
}
});
}
}
let mut spans: Vec<AnchorSpan> = seen.into_values().collect();
spans.sort_by_key(|s| s.start_byte);
spans
}
fn locate_node<'a>(root: Node<'a>, start: usize, end: usize) -> Option<Node<'a>> {
if root.start_byte() == start && root.end_byte() == end {
return Some(root);
}
let mut stack = vec![root];
while let Some(node) = stack.pop() {
if node.start_byte() == start && node.end_byte() == end {
return Some(node);
}
if node.start_byte() <= start && node.end_byte() >= end {
for i in 0..node.child_count() {
if let Some(child) = node.child(i as u32) {
stack.push(child);
}
}
}
}
None
}
#[derive(Debug)]
struct InsertionPlan {
insertion_byte: usize,
target_indent: String,
prefix: String,
suffix: String,
}
fn build_insertion(
source: &str,
anchor: Node<'_>,
position: Position,
language: Language,
indent_override: Option<&str>,
) -> Result<InsertionPlan, String> {
let bytes = source.as_bytes();
let anchor_indent = line_indent(bytes, anchor.start_byte());
match position {
Position::Before => {
Ok(InsertionPlan {
insertion_byte: anchor.start_byte(),
target_indent: anchor_indent.clone(),
prefix: String::new(),
suffix: format!("\n{anchor_indent}"),
})
}
Position::After => {
Ok(InsertionPlan {
insertion_byte: anchor.end_byte(),
target_indent: anchor_indent.clone(),
prefix: format!("\n{anchor_indent}"),
suffix: String::new(),
})
}
Position::FirstChild | Position::LastChild => {
if anchor.child_count() == 0 {
return Err(format!(
"anchor `{}` has no children; cannot insert as {}",
anchor.kind(),
position.as_str(),
));
}
let unit = indent_unit(source, language, indent_override);
let child_indent = first_named_child_indent(bytes, anchor)
.unwrap_or_else(|| anchor_indent.clone() + &unit);
match position {
Position::FirstChild => first_child_plan(bytes, anchor, child_indent),
Position::LastChild => last_child_plan(bytes, anchor, child_indent, anchor_indent),
_ => unreachable!(),
}
}
}
}
fn first_child_plan(
bytes: &[u8],
anchor: Node<'_>,
child_indent: String,
) -> Result<InsertionPlan, String> {
if let Some(first_named) = first_named_child(anchor) {
let suffix = format!("\n{}", line_indent(bytes, first_named.start_byte()));
return Ok(InsertionPlan {
insertion_byte: first_named.start_byte(),
target_indent: child_indent,
prefix: String::new(),
suffix,
});
}
let opener = anchor.child(0).ok_or_else(|| {
format!(
"anchor `{}` has no children to anchor against",
anchor.kind()
)
})?;
let prefix = format!("\n{child_indent}");
Ok(InsertionPlan {
insertion_byte: opener.end_byte(),
target_indent: child_indent,
prefix,
suffix: String::new(),
})
}
fn last_child_plan(
bytes: &[u8],
anchor: Node<'_>,
child_indent: String,
anchor_indent: String,
) -> Result<InsertionPlan, String> {
if let Some(last_named) = last_named_child(anchor) {
let prefix = format!("\n{child_indent}");
return Ok(InsertionPlan {
insertion_byte: last_named.end_byte(),
target_indent: child_indent,
prefix,
suffix: String::new(),
});
}
let closer = anchor
.child((anchor.child_count() - 1) as u32)
.ok_or_else(|| {
format!(
"anchor `{}` has no children to anchor against",
anchor.kind()
)
})?;
let closer_line_start = closer.start_byte() - line_indent(bytes, closer.start_byte()).len();
let prefix = format!("\n{child_indent}");
Ok(InsertionPlan {
insertion_byte: closer_line_start,
target_indent: child_indent,
prefix,
suffix: format!("\n{anchor_indent}"),
})
}
fn first_named_child(node: Node<'_>) -> Option<Node<'_>> {
for i in 0..node.child_count() {
if let Some(child) = node.child(i as u32) {
if child.is_named() {
return Some(child);
}
}
}
None
}
fn last_named_child(node: Node<'_>) -> Option<Node<'_>> {
for i in (0..node.child_count()).rev() {
if let Some(child) = node.child(i as u32) {
if child.is_named() {
return Some(child);
}
}
}
None
}
fn first_named_child_indent(bytes: &[u8], anchor: Node<'_>) -> Option<String> {
let child = first_named_child(anchor)?;
Some(line_indent(bytes, child.start_byte()))
}
fn line_indent(bytes: &[u8], byte: usize) -> String {
let upper = byte.min(bytes.len());
let mut start = upper;
while start > 0 {
let prev = bytes[start - 1];
if prev == b' ' || prev == b'\t' {
start -= 1;
continue;
}
break;
}
let line_start = bytes[..upper]
.iter()
.rposition(|&b| b == b'\n')
.map(|i| i + 1)
.unwrap_or(0);
let begin = start.max(line_start);
std::str::from_utf8(&bytes[begin..upper])
.unwrap_or("")
.to_string()
}
fn indent_unit(source: &str, language: Language, override_unit: Option<&str>) -> String {
if let Some(unit) = override_unit {
if !unit.is_empty() {
return unit.to_string();
}
}
if let Some(unit) = scan_indent_unit(source) {
return unit;
}
language_default_indent(language).to_string()
}
fn scan_indent_unit(source: &str) -> Option<String> {
let mut tab_lines = 0usize;
let mut min_spaces = usize::MAX;
for line in source.lines() {
let mut iter = line.bytes();
match iter.next() {
Some(b'\t') => tab_lines += 1,
Some(b' ') => {
let spaces = 1 + iter.take_while(|&b| b == b' ').count();
if line.len() == spaces {
continue;
}
if spaces < min_spaces {
min_spaces = spaces;
}
}
_ => {}
}
}
if tab_lines > 0 {
return Some("\t".to_string());
}
if min_spaces == usize::MAX {
return None;
}
Some(" ".repeat(min_spaces))
}
fn language_default_indent(language: Language) -> &'static str {
match language {
Language::Go => "\t",
Language::JavaScript | Language::Jsx | Language::TypeScript | Language::Tsx => " ",
_ => " ",
}
}
fn reindent_lines(body: &str, indent: &str) -> String {
if indent.is_empty() {
return body.to_string();
}
let mut out = String::with_capacity(body.len() + indent.len() * 2);
for (i, line) in body.split('\n').enumerate() {
if i > 0 {
out.push('\n');
if !line.is_empty() {
out.push_str(indent);
}
}
out.push_str(line);
}
out
}
fn splice_insert(source: &str, byte: usize, text: &str) -> String {
let mut out = String::with_capacity(source.len() + text.len());
let cut = byte.min(source.len());
out.push_str(&source[..cut]);
out.push_str(text);
out.push_str(&source[cut..]);
out
}
#[allow(clippy::too_many_arguments)]
fn applied_response(
path: &str,
before: &str,
after: &str,
anchor: &AnchorSpan,
insertion_byte: usize,
inserted_text: &str,
position: Position,
target_indent: &str,
dry_run: bool,
) -> VmValue {
build_dict([
("result", str_value("applied")),
("applied", VmValue::Bool(true)),
("path", str_value(path)),
("dry_run", VmValue::Bool(dry_run)),
("position", str_value(position.as_str())),
("insertion_byte", VmValue::Int(insertion_byte as i64)),
("indent", str_value(target_indent)),
("inserted_text", str_value(inserted_text)),
("anchor", anchor_to_value(anchor)),
("before_sha256", str_value(sha256_hex(before.as_bytes()))),
("after_sha256", str_value(sha256_hex(after.as_bytes()))),
("preview", str_value(after)),
])
}
fn no_match_response(path: &str, query: &str, target_capture: &str, details: &str) -> VmValue {
build_dict([
("result", str_value("no_match")),
("applied", VmValue::Bool(false)),
("path", str_value(path)),
("query", str_value(query)),
("target_capture", str_value(target_capture)),
("details", str_value(details)),
])
}
fn ambiguous_response(anchors: &[AnchorSpan]) -> VmValue {
build_dict([
("result", str_value("ambiguous")),
("applied", VmValue::Bool(false)),
("match_count", VmValue::Int(anchors.len() as i64)),
(
"anchors",
VmValue::List(Arc::new(anchors.iter().map(anchor_to_value).collect())),
),
(
"details",
str_value(format!(
"anchor query must match exactly one node, found {}; \
tighten the query (e.g. add a `(#eq? @name \"...\")` predicate) \
to disambiguate",
anchors.len()
)),
),
])
}
fn invalid_query_response(query: &str, err: &QueryError) -> VmValue {
build_dict([
("result", str_value("invalid_query")),
("applied", VmValue::Bool(false)),
("query", str_value(query)),
("details", str_value(format_query_error(err))),
("error_row", VmValue::Int(err.row as i64)),
("error_column", VmValue::Int(err.column as i64)),
])
}
fn unsupported_language_response(path: &str, hint: Option<&str>) -> VmValue {
build_dict([
("result", str_value("unsupported_language")),
("applied", VmValue::Bool(false)),
("path", str_value(path)),
(
"details",
str_value(format!(
"could not infer a tree-sitter grammar for `{path}` (hint: {})",
hint.unwrap_or("none")
)),
),
("fallback_suggestion", str_value(TEXT_PATCH_FALLBACK)),
])
}
fn invalid_anchor_response(
path: &str,
position: Position,
anchor: &AnchorSpan,
detail: &str,
) -> VmValue {
build_dict([
("result", str_value("invalid_anchor")),
("applied", VmValue::Bool(false)),
("path", str_value(path)),
("position", str_value(position.as_str())),
("anchor", anchor_to_value(anchor)),
("details", str_value(detail)),
])
}
fn syntax_error_response(path: &str, after: &str, detail: &str) -> VmValue {
build_dict([
("result", str_value("syntax_error")),
("applied", VmValue::Bool(false)),
("path", str_value(path)),
("details", str_value(detail)),
("preview", str_value(after)),
])
}
fn anchor_to_value(span: &AnchorSpan) -> VmValue {
build_dict([
("start_byte", VmValue::Int(span.start_byte as i64)),
("end_byte", VmValue::Int(span.end_byte as i64)),
("start_row", VmValue::Int(span.start_row as i64)),
("start_col", VmValue::Int(span.start_col as i64)),
("end_row", VmValue::Int(span.end_row as i64)),
("end_col", VmValue::Int(span.end_col as i64)),
("text", str_value(&span.text)),
])
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn vm_string(s: &str) -> VmValue {
VmValue::String(Arc::from(s))
}
fn dict(pairs: &[(&str, VmValue)]) -> VmValue {
let mut map: BTreeMap<String, VmValue> = BTreeMap::new();
for (k, v) in pairs {
map.insert((*k).to_string(), v.clone());
}
VmValue::Dict(Arc::new(map))
}
fn field<'a>(value: &'a VmValue, key: &str) -> &'a VmValue {
match value {
VmValue::Dict(d) => d.get(key).expect("missing field"),
_ => panic!("expected dict"),
}
}
fn s(value: &VmValue) -> String {
match value {
VmValue::String(s) => s.to_string(),
other => panic!("expected string, got {other:?}"),
}
}
fn write_temp(extension: &str, source: &str) -> NamedTempFile {
let mut file = tempfile::Builder::new()
.suffix(&format!(".{extension}"))
.tempfile()
.expect("temp file");
file.write_all(source.as_bytes()).expect("write source");
file
}
fn invoke(payload: VmValue) -> VmValue {
run(&[payload]).expect("insert_at_anchor runs")
}
#[test]
fn after_inserts_sibling_function_at_anchor_depth() {
let source = "fn alpha() {\n 1\n}\n\nfn gamma() {\n 3\n}\n";
let file = write_temp("rs", source);
let path = file.path().to_string_lossy().to_string();
let result = invoke(dict(&[
("path", vm_string(&path)),
(
"query",
vm_string(
"(function_item name: (identifier) @name (#eq? @name \"alpha\")) @anchor",
),
),
("position", vm_string("after")),
("content", vm_string("fn beta() {\n 2\n}")),
]));
assert_eq!(s(field(&result, "result")), "applied");
let preview = s(field(&result, "preview"));
assert!(
preview.contains("fn alpha()")
&& preview.contains("fn beta()")
&& preview.contains("fn gamma()"),
"all three functions must be present:\n{preview}"
);
let alpha = preview.find("fn alpha()").unwrap();
let beta = preview.find("fn beta()").unwrap();
let gamma = preview.find("fn gamma()").unwrap();
assert!(
alpha < beta && beta < gamma,
"beta must land between alpha and gamma:\n{preview}"
);
}
#[test]
fn before_inserts_sibling_import_above_anchor() {
let source = "import { a } from \"./a\";\nimport { b } from \"./b\";\n\nconst x = 1;\n";
let file = write_temp("ts", source);
let path = file.path().to_string_lossy().to_string();
let result = invoke(dict(&[
("path", vm_string(&path)),
(
"query",
vm_string(
"(import_statement source: (string (string_fragment) @src) (#eq? @src \"./b\")) @anchor",
),
),
("position", vm_string("before")),
("content", vm_string("import { aa } from \"./aa\";")),
]));
assert_eq!(s(field(&result, "result")), "applied");
let preview = s(field(&result, "preview"));
let aa_pos = preview.find("./aa").expect("./aa present");
let b_pos = preview.find("./b\"").expect("./b present");
assert!(aa_pos < b_pos, "aa must land before b:\n{preview}");
}
#[test]
fn last_child_appends_test_to_rust_mod() {
let source = "#[cfg(test)]\nmod tests {\n #[test]\n fn one() {}\n}\n";
let file = write_temp("rs", source);
let path = file.path().to_string_lossy().to_string();
let result = invoke(dict(&[
("path", vm_string(&path)),
(
"query",
vm_string("(mod_item name: (identifier) @name (#eq? @name \"tests\") body: (declaration_list) @anchor)"),
),
("position", vm_string("last_child")),
(
"content",
vm_string("#[test]\nfn two() {}"),
),
]));
assert_eq!(s(field(&result, "result")), "applied");
let preview = s(field(&result, "preview"));
let one = preview.find("fn one()").unwrap();
let two = preview.find("fn two()").unwrap();
assert!(one < two, "two must follow one inside the mod:\n{preview}");
assert!(
preview.contains(" #[test]\n fn two() {}"),
"expected 4-space indented test, got:\n{preview}"
);
}
#[test]
fn first_child_prepends_to_empty_typescript_block() {
let source = "function noop() {}\n";
let file = write_temp("ts", source);
let path = file.path().to_string_lossy().to_string();
let result = invoke(dict(&[
("path", vm_string(&path)),
(
"query",
vm_string("(function_declaration body: (statement_block) @anchor)"),
),
("position", vm_string("first_child")),
("content", vm_string("return 1;")),
]));
assert_eq!(s(field(&result, "result")), "applied");
let preview = s(field(&result, "preview"));
assert!(
preview.contains("return 1;"),
"preview missing body:\n{preview}"
);
}
#[test]
fn ambiguous_anchor_rejects_multi_match() {
let source = "fn a() { 1 }\nfn b() { 2 }\n";
let file = write_temp("rs", source);
let path = file.path().to_string_lossy().to_string();
let result = invoke(dict(&[
("path", vm_string(&path)),
("query", vm_string("(function_item) @anchor")),
("position", vm_string("after")),
("content", vm_string("fn c() {}")),
]));
assert_eq!(s(field(&result, "result")), "ambiguous");
match field(&result, "match_count") {
VmValue::Int(n) => assert_eq!(*n, 2),
other => panic!("expected match_count int, got {other:?}"),
}
}
#[test]
fn syntax_error_is_rejected_and_file_untouched() {
let source = "fn one() {}\n";
let file = write_temp("rs", source);
let path = file.path().to_string_lossy().to_string();
let result = invoke(dict(&[
("path", vm_string(&path)),
(
"query",
vm_string("(function_item name: (identifier) @name (#eq? @name \"one\")) @anchor"),
),
("position", vm_string("after")),
("content", vm_string("fn broken( {")),
]));
assert_eq!(s(field(&result, "result")), "syntax_error");
let on_disk = std::fs::read_to_string(file.path()).expect("read");
assert_eq!(on_disk, source);
}
#[test]
fn dry_run_returns_preview_without_writing() {
let source = "fn alpha() {}\n";
let file = write_temp("rs", source);
let path = file.path().to_string_lossy().to_string();
let result = invoke(dict(&[
("path", vm_string(&path)),
(
"query",
vm_string(
"(function_item name: (identifier) @name (#eq? @name \"alpha\")) @anchor",
),
),
("position", vm_string("after")),
("content", vm_string("fn beta() {}")),
("dry_run", VmValue::Bool(true)),
]));
assert_eq!(s(field(&result, "result")), "applied");
assert!(s(field(&result, "preview")).contains("fn beta()"));
let on_disk = std::fs::read_to_string(file.path()).expect("read");
assert_eq!(on_disk, source);
}
#[test]
fn invalid_position_returns_structured_error() {
let source = "fn one() {}\n";
let file = write_temp("rs", source);
let path = file.path().to_string_lossy().to_string();
let err = run(&[dict(&[
("path", vm_string(&path)),
("query", vm_string("(function_item) @anchor")),
("position", vm_string("middle")),
("content", vm_string("fn x() {}")),
])])
.expect_err("unknown position must error");
match err {
HostlibError::InvalidParameter { param, .. } => assert_eq!(param, "position"),
other => panic!("expected InvalidParameter, got {other:?}"),
}
}
#[test]
fn invalid_query_returns_invalid_query() {
let source = "fn one() {}\n";
let file = write_temp("rs", source);
let path = file.path().to_string_lossy().to_string();
let result = invoke(dict(&[
("path", vm_string(&path)),
("query", vm_string("((((")),
("position", vm_string("after")),
("content", vm_string("fn x() {}")),
]));
assert_eq!(s(field(&result, "result")), "invalid_query");
}
#[test]
fn no_match_when_query_misses() {
let source = "fn alpha() {}\n";
let file = write_temp("rs", source);
let path = file.path().to_string_lossy().to_string();
let result = invoke(dict(&[
("path", vm_string(&path)),
(
"query",
vm_string("(function_item name: (identifier) @name (#eq? @name \"beta\")) @anchor"),
),
("position", vm_string("after")),
("content", vm_string("fn beta() {}")),
]));
assert_eq!(s(field(&result, "result")), "no_match");
}
#[test]
fn invalid_anchor_when_first_child_requested_on_leaf() {
let source = "let x = 1;\n";
let file = write_temp("ts", source);
let path = file.path().to_string_lossy().to_string();
let result = invoke(dict(&[
("path", vm_string(&path)),
(
"query",
vm_string("(variable_declarator name: (identifier) @anchor (#eq? @anchor \"x\"))"),
),
("position", vm_string("first_child")),
("content", vm_string("// noop")),
]));
assert_eq!(s(field(&result, "result")), "invalid_anchor");
}
#[test]
fn reindent_lines_preserves_first_line_and_indents_rest() {
let body = "fn a() {\n 1\n}";
let indented = reindent_lines(body, " ");
assert_eq!(indented, "fn a() {\n 1\n }");
}
#[test]
fn scan_indent_unit_prefers_tabs_when_present() {
let src = "fn a() {\n\treturn 1;\n}\n";
assert_eq!(scan_indent_unit(src), Some("\t".to_string()));
}
#[test]
fn scan_indent_unit_finds_smallest_space_run() {
let src = "fn a() {\n return 1;\n}\n";
assert_eq!(scan_indent_unit(src), Some(" ".to_string()));
}
#[test]
fn unsupported_language_when_extension_unknown() {
let file = write_temp("xyz", "noop\n");
let path = file.path().to_string_lossy().to_string();
let result = invoke(dict(&[
("path", vm_string(&path)),
("query", vm_string("(any) @anchor")),
("position", vm_string("after")),
("content", vm_string("noop")),
]));
assert_eq!(s(field(&result, "result")), "unsupported_language");
}
}