use std::path::Path;
#[derive(Debug, Clone)]
pub struct SyntaxError {
pub line: usize,
pub column: usize,
pub snippet: String,
pub is_missing: bool,
pub expected: Option<String>,
}
impl SyntaxError {
pub fn render(&self) -> String {
match (self.is_missing, self.expected.as_deref()) {
(true, Some(tok)) if !tok.is_empty() && tok != "ERROR" => format!(
" missing `{}` at {}:{}: {}",
tok, self.line, self.column, self.snippet
),
(true, _) => format!(
" missing token at {}:{}: {}",
self.line, self.column, self.snippet
),
(false, _) => format!(
" syntax error at {}:{}: {}",
self.line, self.column, self.snippet
),
}
}
}
const MAX_ERRORS: usize = 10;
fn language_for_path(path: &Path) -> Option<tree_sitter::Language> {
let ext = path.extension()?.to_str()?.to_lowercase();
match ext.as_str() {
#[cfg(feature = "semantic-rust")]
"rs" => Some(tree_sitter_rust::LANGUAGE.into()),
#[cfg(feature = "semantic-ts")]
"ts" | "tsx" | "mts" | "cts" => Some(tree_sitter_typescript::LANGUAGE_TSX.into()),
#[cfg(feature = "semantic-ts")]
"js" | "jsx" | "mjs" | "cjs" => {
Some(tree_sitter_typescript::LANGUAGE_TSX.into())
}
#[cfg(feature = "semantic-python")]
"py" | "pyi" => Some(tree_sitter_python::LANGUAGE.into()),
#[cfg(feature = "semantic-go")]
"go" => Some(tree_sitter_go::LANGUAGE.into()),
#[cfg(feature = "semantic-ruby")]
"rb" | "rake" | "gemspec" => Some(tree_sitter_ruby::LANGUAGE.into()),
#[cfg(feature = "semantic-java")]
"java" => Some(tree_sitter_java::LANGUAGE.into()),
#[cfg(feature = "semantic-c")]
"c" => Some(tree_sitter_c::LANGUAGE.into()),
#[cfg(feature = "semantic-cpp")]
"cpp" | "cc" | "cxx" | "hpp" | "hh" | "hxx" => Some(tree_sitter_cpp::LANGUAGE.into()),
#[cfg(feature = "semantic-clojure")]
"clj" | "cljs" | "cljc" | "edn" | "bb" => Some(tree_sitter_clojure::LANGUAGE.into()),
#[cfg(feature = "semantic-bash")]
"sh" | "bash" => Some(tree_sitter_bash::LANGUAGE.into()),
_ => None,
}
}
fn collect_errors(tree: &tree_sitter::Tree, source: &str) -> Vec<SyntaxError> {
let mut errors: Vec<SyntaxError> = Vec::new();
let cursor = tree.walk();
let mut stack: Vec<tree_sitter::Node> = vec![tree.root_node()];
while let Some(node) = stack.pop() {
if errors.len() >= MAX_ERRORS {
break;
}
if node.is_error() || node.is_missing() {
let start = node.start_position();
let snippet = snippet_for(node, source);
let expected = if node.is_missing() {
Some(node.kind().to_string())
} else {
None
};
errors.push(SyntaxError {
line: start.row + 1,
column: start.column + 1,
snippet,
is_missing: node.is_missing(),
expected,
});
continue;
}
let _ = cursor; for i in (0..node.child_count()).rev() {
if let Some(child) = node.child(i) {
stack.push(child);
}
}
}
errors
}
fn snippet_for(node: tree_sitter::Node, source: &str) -> String {
let start = node.start_byte();
let end = node.end_byte().min(source.len());
if start >= end {
let line_start = source[..start].rfind('\n').map(|i| i + 1).unwrap_or(0);
let line_end = source[start..]
.find('\n')
.map(|i| start + i)
.unwrap_or(source.len());
return source[line_start..line_end]
.chars()
.take(80)
.collect::<String>()
.trim_end()
.to_string();
}
let raw = &source[start..end];
let line: String = raw.chars().take_while(|c| *c != '\n').collect();
line.chars()
.take(80)
.collect::<String>()
.trim_end()
.to_string()
}
pub fn check_syntax(path: &Path, content: &str) -> Result<(), Vec<SyntaxError>> {
let Some(lang) = language_for_path(path) else {
return match lex_rules_for_path(path) {
Some(rules) if delimiter_summary(content, rules).is_some() => {
Err(vec![SyntaxError {
line: 0,
column: 0,
snippet: String::new(),
is_missing: true,
expected: None,
}])
}
_ => Ok(()),
};
};
let mut parser = tree_sitter::Parser::new();
if parser.set_language(&lang).is_err() {
return Ok(());
}
let Some(tree) = parser.parse(content, None) else {
return Ok(());
};
if !tree.root_node().has_error() {
return Ok(());
}
let errors = collect_errors(&tree, content);
if errors.is_empty() {
return Ok(());
}
Err(errors)
}
struct LexRules {
line_comments: &'static [&'static str],
block_comments: &'static [(&'static str, &'static str)],
nested_block_comments: bool,
strings: &'static [(&'static str, &'static str, bool)],
char_squote: bool,
char_backslash: bool,
char_question: bool,
long_string_backtick: bool,
}
const RULES_C: LexRules = LexRules {
line_comments: &["//"],
block_comments: &[("/*", "*/")],
nested_block_comments: false,
strings: &[("\"", "\"", true)],
char_squote: true,
char_backslash: false,
char_question: false,
long_string_backtick: false,
};
const RULES_RUST: LexRules = LexRules {
line_comments: &["//"],
block_comments: &[("/*", "*/")],
nested_block_comments: true,
strings: &[
("r#\"", "\"#", false),
("r\"", "\"", false),
("\"", "\"", true),
],
char_squote: true,
char_backslash: false,
char_question: false,
long_string_backtick: false,
};
const RULES_GO: LexRules = LexRules {
line_comments: &["//"],
block_comments: &[("/*", "*/")],
nested_block_comments: false,
strings: &[("`", "`", false), ("\"", "\"", true)],
char_squote: true,
char_backslash: false,
char_question: false,
long_string_backtick: false,
};
const RULES_JAVA: LexRules = LexRules {
line_comments: &["//"],
block_comments: &[("/*", "*/")],
nested_block_comments: false,
strings: &[("\"\"\"", "\"\"\"", true), ("\"", "\"", true)],
char_squote: true,
char_backslash: false,
char_question: false,
long_string_backtick: false,
};
const RULES_PYTHON: LexRules = LexRules {
line_comments: &["#"],
block_comments: &[],
nested_block_comments: false,
strings: &[
("\"\"\"", "\"\"\"", true),
("'''", "'''", true),
("\"", "\"", true),
("'", "'", true),
],
char_squote: false,
char_backslash: false,
char_question: false,
long_string_backtick: false,
};
const RULES_LISP: LexRules = LexRules {
line_comments: &[";"],
block_comments: &[],
nested_block_comments: false,
strings: &[("\"", "\"", true)],
char_squote: false,
char_backslash: true,
char_question: false,
long_string_backtick: false,
};
const RULES_JANET: LexRules = LexRules {
line_comments: &["#"],
block_comments: &[],
nested_block_comments: false,
strings: &[("\"", "\"", true)],
char_squote: false,
char_backslash: false,
char_question: false,
long_string_backtick: true,
};
const RULES_SCHEME: LexRules = LexRules {
line_comments: &[";"],
block_comments: &[("#|", "|#")],
nested_block_comments: true,
strings: &[("\"", "\"", true)],
char_squote: false,
char_backslash: true,
char_question: false,
long_string_backtick: false,
};
const RULES_ELISP: LexRules = LexRules {
line_comments: &[";"],
block_comments: &[],
nested_block_comments: false,
strings: &[("\"", "\"", true)],
char_squote: false,
char_backslash: true,
char_question: true,
long_string_backtick: false,
};
fn lex_rules_for_path(path: &Path) -> Option<&'static LexRules> {
let ext = path.extension()?.to_str()?.to_lowercase();
Some(match ext.as_str() {
"rs" => &RULES_RUST,
"c" | "h" | "cpp" | "cc" | "cxx" | "hpp" | "hh" | "hxx" => &RULES_C,
"go" => &RULES_GO,
"java" => &RULES_JAVA,
"py" | "pyi" => &RULES_PYTHON,
"clj" | "cljs" | "cljc" | "cljd" | "edn" | "bb" | "fnl" => &RULES_LISP,
"janet" | "jdn" => &RULES_JANET,
"scm" | "ss" | "rkt" | "lisp" | "lsp" | "cl" => &RULES_SCHEME,
"el" => &RULES_ELISP,
_ => return None,
})
}
fn starts_at(b: &[u8], i: usize, p: &str) -> bool {
b[i..].starts_with(p.as_bytes())
}
fn adv(b: &[u8], i: &mut usize, line: &mut usize, col: &mut usize, count: usize) {
let to = i.saturating_add(count).min(b.len());
while *i < to {
if b[*i] == b'\n' {
*line += 1;
*col = 1;
} else {
*col += 1;
}
*i += 1;
}
}
enum DelimiterBalance {
Balanced,
Unclosed(Vec<(u8, usize, usize)>),
Stray(char, usize, usize),
}
fn delimiter_balance(content: &str, rules: &LexRules) -> DelimiterBalance {
let b = content.as_bytes();
let n = b.len();
let mut i = 0usize;
let (mut line, mut col) = (1usize, 1usize);
let mut stack: Vec<(u8, usize, usize)> = Vec::new();
'outer: while i < n {
for lc in rules.line_comments {
if starts_at(b, i, lc) {
let to_eol = b[i..].iter().position(|&c| c == b'\n').unwrap_or(n - i);
adv(b, &mut i, &mut line, &mut col, to_eol);
continue 'outer;
}
}
for (open, close) in rules.block_comments {
if starts_at(b, i, open) {
adv(b, &mut i, &mut line, &mut col, open.len());
let mut depth = 1usize;
while i < n && depth > 0 {
if rules.nested_block_comments && starts_at(b, i, open) {
depth += 1;
adv(b, &mut i, &mut line, &mut col, open.len());
} else if starts_at(b, i, close) {
depth -= 1;
adv(b, &mut i, &mut line, &mut col, close.len());
} else {
adv(b, &mut i, &mut line, &mut col, 1);
}
}
continue 'outer;
}
}
for (open, close, esc) in rules.strings {
if starts_at(b, i, open) {
adv(b, &mut i, &mut line, &mut col, open.len());
while i < n {
if *esc && b[i] == b'\\' {
adv(b, &mut i, &mut line, &mut col, 2);
} else if starts_at(b, i, close) {
adv(b, &mut i, &mut line, &mut col, close.len());
break;
} else {
adv(b, &mut i, &mut line, &mut col, 1);
}
}
continue 'outer;
}
}
if rules.char_backslash && b[i] == b'\\' {
adv(b, &mut i, &mut line, &mut col, 2);
continue 'outer;
}
if rules.char_squote && b[i] == b'\'' {
if b.get(i + 1) == Some(&b'\\') {
let mut j = i + 1;
while j < n {
if b[j] == b'\\' {
j += 2;
} else if b[j] == b'\'' {
j += 1;
break;
} else {
j += 1;
}
}
let count = j - i;
adv(b, &mut i, &mut line, &mut col, count);
continue 'outer;
} else if b.get(i + 2) == Some(&b'\'') {
adv(b, &mut i, &mut line, &mut col, 3); continue 'outer;
} else {
adv(b, &mut i, &mut line, &mut col, 1);
continue 'outer;
}
}
if rules.char_question && b[i] == b'?' {
if b.get(i + 1) == Some(&b'\\') {
adv(b, &mut i, &mut line, &mut col, 3); } else if i + 1 < n {
adv(b, &mut i, &mut line, &mut col, 2); } else {
adv(b, &mut i, &mut line, &mut col, 1);
}
continue 'outer;
}
if rules.long_string_backtick && b[i] == b'`' {
let mut k = 0usize;
while i + k < n && b[i + k] == b'`' {
k += 1;
}
adv(b, &mut i, &mut line, &mut col, k);
while i < n {
if b[i] == b'`' {
let mut j = 0usize;
while i + j < n && b[i + j] == b'`' {
j += 1;
}
if j >= k {
adv(b, &mut i, &mut line, &mut col, k);
break;
}
adv(b, &mut i, &mut line, &mut col, j);
} else {
adv(b, &mut i, &mut line, &mut col, 1);
}
}
continue 'outer;
}
match b[i] {
b'(' | b'[' | b'{' => stack.push((b[i], line, col)),
b')' | b']' | b'}' => {
let want = match b[i] {
b')' => b'(',
b']' => b'[',
_ => b'{',
};
match stack.last() {
Some(&(open, _, _)) if open == want => {
stack.pop();
}
_ => {
return DelimiterBalance::Stray(b[i] as char, line, col);
}
}
}
_ => {}
}
adv(b, &mut i, &mut line, &mut col, 1);
}
if stack.is_empty() {
DelimiterBalance::Balanced
} else {
DelimiterBalance::Unclosed(stack)
}
}
fn closer_for(open: u8) -> char {
match open {
b'(' => ')',
b'[' => ']',
_ => '}',
}
}
fn delimiter_summary(content: &str, rules: &LexRules) -> Option<String> {
match delimiter_balance(content, rules) {
DelimiterBalance::Balanced => None,
DelimiterBalance::Stray(c, line, col) => Some(format!(
"Delimiter imbalance: unexpected `{c}` at line {line}, col {col} \
with no matching open — remove an extra closer, or add the missing \
opener before it."
)),
DelimiterBalance::Unclosed(stack) => {
let (open, l, c) = stack[0];
let openc = open as char;
let close = closer_for(open);
Some(format!(
"Delimiter imbalance: {n} unclosed — the `{openc}` opened at line {l}, col {c} is \
never closed; add {n} matching `{close}` (do not count by hand — fix this delimiter).",
n = stack.len()
))
}
}
}
const MAX_TRAILING_NONBLANK_LINES: usize = 10;
fn is_trailing_truncation(content: &str, stack: &[(u8, usize, usize)]) -> bool {
let Some(&(_, open_line, _)) = stack.first() else {
return false;
};
let trailing_nonblank = content
.lines()
.skip(open_line)
.filter(|l| !l.trim().is_empty())
.count();
trailing_nonblank <= MAX_TRAILING_NONBLANK_LINES
}
pub fn repair_delimiters(path: &Path, content: &str) -> Option<(String, String)> {
let rules = lex_rules_for_path(path)?;
let DelimiterBalance::Unclosed(stack) = delimiter_balance(content, rules) else {
return None;
};
if !is_trailing_truncation(content, &stack) {
return None;
}
let closers: String = stack
.iter()
.rev()
.map(|&(open, _, _)| closer_for(open))
.collect();
let repaired = format!("{content}{closers}");
if check_syntax(path, &repaired).is_err() {
return None;
}
let (open, l, c) = stack[0];
let note = format!(
"auto-closed {n} unclosed delimiter(s) at a trailing truncation: appended `{closers}` to \
balance the `{openc}` opened at line {l}, col {c}. If that placement is wrong, resend the \
corrected text.",
n = stack.len(),
openc = open as char,
);
Some((repaired, note))
}
pub enum SyntaxOutcome {
Clean,
Repaired { content: String, note: String },
Rejected { message: String },
}
pub fn validate_or_repair(path: &Path, content: &str) -> SyntaxOutcome {
match check_syntax(path, content) {
Ok(()) => SyntaxOutcome::Clean,
Err(errors) => match repair_delimiters(path, content) {
Some((repaired, note)) => SyntaxOutcome::Repaired {
content: repaired,
note,
},
None => SyntaxOutcome::Rejected {
message: format_errors(path, content, &errors),
},
},
}
}
pub fn format_errors(path: &Path, content: &str, errors: &[SyntaxError]) -> String {
let has_grammar = language_for_path(path).is_some();
let mut out = if has_grammar {
format!(
"Syntax check failed for {}: {} error(s) detected by tree-sitter. \
Fix and re-submit. (This is a pre-write guard — the file was NOT modified.)\n",
path.display(),
errors.len(),
)
} else {
format!(
"Syntax check failed for {}: delimiters are unbalanced. \
Fix and re-submit. (This is a pre-write guard — the file was NOT modified.)\n",
path.display(),
)
};
if has_grammar {
for err in errors {
out.push_str(&err.render());
out.push('\n');
}
if errors.len() == MAX_ERRORS {
out.push_str(&format!(
" …(truncated at {} errors; fix the listed issues and re-check)\n",
MAX_ERRORS,
));
}
}
if let Some(rules) = lex_rules_for_path(path)
&& let Some(summary) = delimiter_summary(content, rules)
{
out.push_str(" ");
out.push_str(&summary);
out.push('\n');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn render_names_the_expected_missing_token() {
let e = SyntaxError {
line: 5,
column: 1,
snippet: "}".into(),
is_missing: true,
expected: Some("}".into()),
};
assert_eq!(e.render(), " missing `}` at 5:1: }");
let e2 = SyntaxError {
line: 1,
column: 1,
snippet: "@@@".into(),
is_missing: false,
expected: None,
};
assert!(e2.render().contains("syntax error at 1:1"));
}
#[test]
fn balanced_code_yields_no_summary_per_language() {
let rust = r####"
fn demo<'a>(x: &'a str) -> char {
// a closing brace } in a line comment
let s = "string with ( and { and }";
let r = r#"raw with ) and ] and "#;
let c = '}';
let q = '\'';
/* block ) with nested /* { */ still ) */
if x.len() > 0 { '(' } else { ')' }
}
"####;
assert_eq!(delimiter_summary(rust, &RULES_RUST), None, "rust");
let c = r#"
int main(void) {
char b = '{'; /* a ) in a block comment */
printf("a(b)[c]"); // a } in a line comment
return 0;
}
"#;
assert_eq!(delimiter_summary(c, &RULES_C), None, "c");
let go = "func f() {\n\ts := `raw ( { string`\n\tr := '}'\n\tm := map[int]int{}\n}\n";
assert_eq!(delimiter_summary(go, &RULES_GO), None, "go");
let java = "class A {\n String t = \"\"\" ( { still fine \"\"\";\n char c = ']';\n}\n";
assert_eq!(delimiter_summary(java, &RULES_JAVA), None, "java");
let py = "def f(x):\n s = \"a{b}c\"\n t = '''( { ['''\n # comment with )\n return [1, 2, {3: 4}]\n";
assert_eq!(delimiter_summary(py, &RULES_PYTHON), None, "python");
let lisp = r#"(defn f [x] (str "a(b)c" \( \) ))"#;
assert_eq!(delimiter_summary(lisp, &RULES_LISP), None, "lisp");
}
#[test]
fn unclosed_delimiter_is_localized_per_language() {
let s = delimiter_summary("fn f() {\n let x = (1 + 2;\n", &RULES_RUST)
.expect("rust imbalance");
assert!(s.contains("unclosed"), "{s}");
let s = delimiter_summary("int f() { return 0; }}\n", &RULES_C).expect("c extra");
assert!(
s.contains("unexpected") && s.contains("no matching open"),
"{s}"
);
let s = delimiter_summary("xs = [1, 2,\n", &RULES_PYTHON).expect("py imbalance");
assert!(s.contains("unclosed"), "{s}");
}
#[test]
fn repair_closes_truncated_form() {
let path = PathBuf::from("/tmp/x.janet");
let (repaired, note) = repair_delimiters(&path, "(defn f [x]\n (+ x 1")
.expect("truncated form is repairable");
assert_eq!(repaired, "(defn f [x]\n (+ x 1))");
assert!(
check_syntax(&path, &repaired).is_ok(),
"repaired must validate"
);
assert!(
note.contains("auto-closed 2"),
"note reports the fix: {note}"
);
}
#[test]
fn repair_ignores_delimiters_in_strings_and_comments() {
let path = PathBuf::from("/tmp/x.janet");
let src = "(def s \"a ) b\")\n# comment )\n(+ 1 2";
let (repaired, note) = repair_delimiters(&path, src).expect("string/comment-aware repair");
assert_eq!(repaired, "(def s \"a ) b\")\n# comment )\n(+ 1 2)");
assert!(check_syntax(&path, &repaired).is_ok());
assert!(note.contains("auto-closed 1"), "{note}");
}
#[test]
fn repair_declines_when_closer_would_land_in_a_comment() {
let path = PathBuf::from("/tmp/x.janet");
assert!(repair_delimiters(&path, "(+ 1 2 # oops").is_none());
}
#[test]
fn repair_refuses_stray_closer() {
let path = PathBuf::from("/tmp/x.janet");
assert!(repair_delimiters(&path, "(+ 1 2))").is_none());
}
#[test]
fn repair_unavailable_without_lex_rules() {
let path = PathBuf::from("/tmp/x.thisisntreal");
assert!(repair_delimiters(&path, "(((").is_none());
}
#[cfg(feature = "semantic-rust")]
#[test]
fn repair_closes_truncated_rust_and_validates() {
let path = PathBuf::from("/tmp/x.rs");
let (repaired, note) = repair_delimiters(&path, "fn main() {\n let x = 1;\n")
.expect("truncated rust block is repairable");
assert_eq!(repaired, "fn main() {\n let x = 1;\n}");
assert!(check_syntax(&path, &repaired).is_ok());
assert!(note.contains("auto-closed 1"), "{note}");
}
#[cfg(feature = "semantic-rust")]
#[test]
fn repair_declines_when_close_does_not_validate() {
let path = PathBuf::from("/tmp/x.rs");
assert!(
repair_delimiters(&path, "fn main( {").is_none(),
"a close that doesn't actually parse must be refused"
);
}
#[cfg(feature = "semantic-rust")]
#[test]
fn repair_rejects_mid_file_stray_opener_that_would_swallow_siblings() {
let path = PathBuf::from("/tmp/x.rs");
let mut src = String::from("fn stray() {\n");
for i in 0..12 {
src.push_str(&format!("fn f{i}() {{ let x = 1; }}\n"));
}
assert!(
check_syntax(&path, &format!("{src}}}")).is_ok(),
"precondition: appending `}}` yields valid (nested-fn) Rust",
);
assert!(
repair_delimiters(&path, &src).is_none(),
"a mid-file stray opener with complete code after it must not be auto-closed",
);
}
#[cfg(feature = "semantic-rust")]
#[test]
fn validate_or_repair_paths() {
let path = PathBuf::from("/tmp/x.rs");
assert!(matches!(
validate_or_repair(&path, "fn main() {}\n"),
SyntaxOutcome::Clean
));
assert!(matches!(
validate_or_repair(&path, "fn main() {\n let x = 1;\n"),
SyntaxOutcome::Repaired { .. }
));
match validate_or_repair(&path, "fn main() {}}\n") {
SyntaxOutcome::Rejected { message } => {
assert!(message.contains("Syntax check failed"), "{message}")
}
_ => panic!("stray closer must be rejected, not repaired"),
}
}
#[cfg(feature = "semantic-rust")]
#[test]
fn format_errors_appends_summary_for_rust() {
let path = PathBuf::from("/tmp/x.rs");
let content = "fn f() {\n let x = (1 + 2;\n"; let errors = check_syntax(&path, content).expect_err("expected errors");
let rendered = format_errors(&path, content, &errors);
assert!(
rendered.contains("Delimiter imbalance"),
"rust error should carry the balance hint now too: {rendered}"
);
}
#[cfg(feature = "semantic-rust")]
#[test]
fn clean_rust_passes() {
let path = PathBuf::from("/tmp/foo.rs");
assert!(check_syntax(&path, "fn main() {}\n").is_ok());
}
#[cfg(feature = "semantic-rust")]
#[test]
fn broken_rust_returns_errors() {
let path = PathBuf::from("/tmp/foo.rs");
let result = check_syntax(&path, "fn main() {\n let x = 1;\n");
let errors = result.expect_err("expected syntax errors");
assert!(!errors.is_empty());
}
#[test]
fn unknown_extension_skips_silently() {
let path = PathBuf::from("/tmp/foo.thisisntreal");
assert!(check_syntax(&path, "(((((").is_ok());
}
#[test]
fn no_extension_skips_silently() {
let path = PathBuf::from("/tmp/Makefile");
assert!(check_syntax(&path, "all:\n\techo hello\n").is_ok());
}
#[cfg(feature = "semantic-python")]
#[test]
fn broken_python_returns_errors() {
let path = PathBuf::from("/tmp/foo.py");
let result = check_syntax(&path, "def foo(\n");
let errors = result.expect_err("expected syntax errors");
assert!(!errors.is_empty());
}
#[cfg(feature = "semantic-rust")]
#[test]
fn format_errors_includes_path_and_count() {
let path = PathBuf::from("/tmp/x.rs");
let result = check_syntax(&path, "fn main( { ");
let errors = result.expect_err("expected errors");
let rendered = format_errors(&path, "fn main( { ", &errors);
assert!(rendered.contains("/tmp/x.rs"));
assert!(rendered.contains("error(s) detected"));
}
#[test]
fn lisp_summary_points_at_first_unclosed_open() {
let s = delimiter_summary("(defn f [x\n (+ x 1)", &RULES_LISP).expect("imbalanced");
assert!(s.contains("unclosed"), "{s}");
assert!(s.contains("line 1"), "should point at the first open: {s}");
}
#[test]
fn lisp_summary_flags_extra_closer() {
let s = delimiter_summary("(+ 1 2))", &RULES_LISP).expect("extra closer");
assert!(s.contains("unexpected"), "{s}");
assert!(s.contains("no matching open"), "{s}");
}
#[test]
fn lisp_summary_is_none_when_balanced() {
assert!(delimiter_summary("(defn f [x] (+ x 1))", &RULES_LISP).is_none());
assert!(delimiter_summary(r#"(str "a(b)c" \()"#, &RULES_LISP).is_none());
assert!(delimiter_summary("(+ 1 2) ; ) ) )", &RULES_LISP).is_none());
}
#[cfg(feature = "semantic-clojure")]
#[test]
fn format_errors_appends_delimiter_summary_for_clojure() {
let path = PathBuf::from("/tmp/x.cljs");
let content = "(defn f [x] (+ x 1)"; let errors = check_syntax(&path, content).expect_err("expected errors");
let rendered = format_errors(&path, content, &errors);
assert!(
rendered.contains("Delimiter imbalance"),
"Clojure error should carry the paren-balance hint: {rendered}"
);
}
#[test]
fn janet_unbalanced_flags_and_advises_not_to_count() {
let path = PathBuf::from("/tmp/x.janet");
let content = "(defn- f [x]\n (+ x 1)\n"; let errors = check_syntax(&path, content).expect_err("janet imbalance must be flagged");
let msg = format_errors(&path, content, &errors);
assert!(msg.contains("do not count by hand"), "{msg}");
assert!(
!msg.contains("tree-sitter"),
"no-grammar path must not claim tree-sitter: {msg}"
);
}
#[test]
fn janet_balanced_passes() {
let path = PathBuf::from("/tmp/x.janet");
assert!(check_syntax(&path, "(defn- f [x] (+ x 1))\n").is_ok());
}
#[test]
fn janet_hash_comment_parens_no_false_positive() {
let path = PathBuf::from("/tmp/x.janet");
let content = "# a comment with ( unbalanced paren\n(def x 1)\n";
assert!(
check_syntax(&path, content).is_ok(),
"`#` comment parens must be ignored for Janet"
);
}
#[test]
fn janet_backtick_long_string_parens_no_false_positive() {
let path = PathBuf::from("/tmp/x.janet");
let content = "(def s `a long string with ( unbalanced paren`)\n";
assert!(
check_syntax(&path, content).is_ok(),
"backtick long-string parens must be ignored for Janet"
);
}
#[test]
fn jdn_uses_janet_lexing() {
let path = PathBuf::from("/tmp/x.jdn");
assert!(check_syntax(&path, "# c (\n{:a 1}\n").is_ok());
}
#[test]
fn fennel_unbalanced_flags() {
let path = PathBuf::from("/tmp/x.fnl");
let errors = check_syntax(&path, "(fn f [x]\n (+ x 1)\n").expect_err("fennel imbalance");
assert!(format_errors(&path, "(fn f [x]\n (+ x 1)\n", &errors).contains("do not count"));
}
#[test]
fn fennel_semicolon_comment_no_false_positive() {
let path = PathBuf::from("/tmp/x.fnl");
assert!(check_syntax(&path, "; comment with (\n(local x 1)\n").is_ok());
}
#[test]
fn cljd_unbalanced_flags() {
let path = PathBuf::from("/tmp/x.cljd");
assert!(check_syntax(&path, "(defn f [x] (+ x 1)\n").is_err());
}
#[test]
fn scheme_block_comment_parens_no_false_positive() {
let path = PathBuf::from("/tmp/x.scm");
let content = "#| a block ( comment #| nested ) |# still |#\n(define x 1)\n";
assert!(
check_syntax(&path, content).is_ok(),
"`#| |#` block-comment parens must be ignored"
);
}
#[test]
fn scheme_unbalanced_flags() {
let path = PathBuf::from("/tmp/x.rkt");
assert!(check_syntax(&path, "(define (f x)\n (+ x 1)\n").is_err());
}
#[test]
fn commonlisp_block_comment_no_false_positive() {
let path = PathBuf::from("/tmp/x.lisp");
assert!(check_syntax(&path, "#| ( |#\n(defun f () 1)\n").is_ok());
}
#[test]
fn elisp_question_char_paren_no_false_positive() {
let path = PathBuf::from("/tmp/x.el");
let content = "(setq c ?\\()\n"; assert!(
check_syntax(&path, content).is_ok(),
"`?(`/`?\\(` char literals must not count as openers"
);
}
#[test]
fn elisp_unbalanced_flags() {
let path = PathBuf::from("/tmp/x.el");
assert!(check_syntax(&path, "(defun f ()\n (+ 1 2)\n").is_err());
}
}