use std::path::Path;
use serde::Serialize;
use super::{Language, ParseFailure, try_parse_source};
#[derive(Debug, Clone, Serialize)]
pub struct SyntaxError {
pub line: usize,
pub column: usize,
pub text: String,
}
#[derive(Debug, Serialize)]
pub struct ValidationResult {
pub valid: bool,
pub errors: Vec<SyntaxError>,
pub language: String,
}
fn validation_from_tree(
tree: &tree_sitter_lib::Tree,
source: &str,
lang: Language,
) -> ValidationResult {
let root = tree.root_node();
let mut errors = Vec::new();
if root.has_error() {
collect_errors(root, source, &mut errors);
}
ValidationResult {
valid: errors.is_empty(),
errors,
language: lang.to_string(),
}
}
pub fn validate_source(source: &str, lang: Language) -> Option<ValidationResult> {
match try_parse_source(source, lang) {
Ok((tree, _)) => Some(validation_from_tree(&tree, source, lang)),
Err(ParseFailure::NoGrammar) => None,
Err(ParseFailure::DeadlineExceeded) => Some(timeout_validation_source(lang)),
}
}
fn timeout_result(detail: String, lang: Language) -> ValidationResult {
ValidationResult {
valid: false,
errors: vec![SyntaxError {
line: 1,
column: 0,
text: detail,
}],
language: lang.to_string(),
}
}
fn timeout_validation(path: &Path, lang: Language) -> ValidationResult {
timeout_result(
format!("parse deadline exceeded for {}", path.display()),
lang,
)
}
fn timeout_validation_source(lang: Language) -> ValidationResult {
timeout_result(format!("parse deadline exceeded for {lang}"), lang)
}
pub fn validate_file(path: &Path, lang_hint: Option<Language>) -> anyhow::Result<ValidationResult> {
let lang = lang_hint.unwrap_or_else(|| Language::from_path(path));
if !lang.has_grammar() {
return Err(anyhow::Error::new(crate::exit::InvalidInputError {
msg: format!("no grammar available for {lang}"),
}));
}
let source = crate::files::load_text_strict(path, &path.display().to_string())?;
match try_parse_source(&source, lang) {
Ok((tree, _)) => Ok(validation_from_tree(&tree, &source, lang)),
Err(ParseFailure::DeadlineExceeded) => {
Err(anyhow::Error::new(crate::exit::ParseTimeoutError {
msg: format!("parse deadline exceeded for {}", path.display()),
}))
}
Err(ParseFailure::NoGrammar) => Err(anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!("failed to parse {}", path.display()),
})),
}
}
pub fn validate_file_for_walk(
path: &Path,
lang_hint: Option<Language>,
) -> Option<ValidationResult> {
match validate_file(path, lang_hint) {
Ok(result) => Some(result),
Err(e) if crate::exit::is_parse_timeout(&e) => {
let lang = lang_hint.unwrap_or_else(|| Language::from_path(path));
Some(timeout_validation(path, lang))
}
Err(_) => None,
}
}
fn error_node_text(node: tree_sitter_lib::Node, source: &str) -> String {
let start = node.start_byte();
let mut end = node.end_byte().min(start + 50);
while end > start && !source.is_char_boundary(end) {
end -= 1;
}
let span = source.get(start..end).unwrap_or("").trim();
if !span.is_empty() {
return span.to_string();
}
if node.is_missing() {
format!("missing {}", node.kind())
} else {
format!("invalid {}", node.kind())
}
}
fn collect_errors(node: tree_sitter_lib::Node, source: &str, errors: &mut Vec<SyntaxError>) {
if node.is_error() || node.is_missing() {
let start = node.start_byte();
let line = crate::ops::file::text_line_index(source, start) + 1;
let line_start = source[..start.min(source.len())]
.rfind(['\n', '\r'])
.map(|i| i + 1)
.unwrap_or(0);
let column = start.saturating_sub(line_start);
errors.push(SyntaxError {
line,
column,
text: error_node_text(node, source),
});
return; }
let mut cursor = node.walk();
if cursor.goto_first_child() {
loop {
let child = cursor.node();
if child.has_error() || child.is_error() || child.is_missing() {
collect_errors(child, source, errors);
}
if !cursor.goto_next_sibling() {
break;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_rust_code() {
let source = "fn main() { println!(\"hello\"); }\n";
let result = validate_source(source, Language::Rust).unwrap();
assert!(result.valid);
assert!(result.errors.is_empty());
}
#[test]
fn invalid_rust_code() {
let source = "fn main( { }\n";
let result = validate_source(source, Language::Rust).unwrap();
assert!(!result.valid);
assert!(!result.errors.is_empty());
}
#[test]
fn valid_python_code() {
let source = "def hello():\n pass\n";
let result = validate_source(source, Language::Python).unwrap();
assert!(result.valid);
}
#[test]
fn unknown_language_returns_none() {
let result = validate_source("anything", Language::Unknown);
assert!(result.is_none());
}
#[test]
fn error_location_is_correct() {
let source = "fn main(\n";
let result = validate_source(source, Language::Rust).unwrap();
assert!(!result.valid);
assert!(!result.errors.is_empty());
assert!(result.errors[0].line <= 2);
}
#[test]
fn cr_only_syntax_error_uses_same_line_as_lf() {
let lf = "fn ok() {}\nfn bad( {}\n";
let cr = "fn ok() {}\rfn bad( {}\r";
let crlf = "fn ok() {}\r\nfn bad( {}\r\n";
let lf_r = validate_source(lf, Language::Rust).unwrap();
let cr_r = validate_source(cr, Language::Rust).unwrap();
let crlf_r = validate_source(crlf, Language::Rust).unwrap();
assert!(!lf_r.valid && !cr_r.valid && !crlf_r.valid);
assert_eq!(lf_r.errors[0].line, 2);
assert_eq!(cr_r.errors[0].line, 2, "CR-only must not stay on line 1");
assert_eq!(crlf_r.errors[0].line, 2);
assert_eq!(cr_r.errors[0].column, lf_r.errors[0].column);
assert_eq!(crlf_r.errors[0].column, lf_r.errors[0].column);
}
#[test]
fn empty_span_error_has_nonempty_text() {
let source = "fn bad( {}\n";
let result = validate_source(source, Language::Rust).unwrap();
assert!(!result.valid);
assert!(
result.errors.iter().all(|e| !e.text.trim().is_empty()),
"empty error text: {:?}",
result.errors
);
assert!(
result
.errors
.iter()
.any(|e| e.text.starts_with("missing ") || e.text.starts_with("invalid ")),
"expected kind fallback text: {:?}",
result.errors
);
}
#[test]
fn validate_source_timeout_is_invalid() {
let source = crate::ast::nested_rust_source_for_timeout(80_000);
let _guard = crate::ast::ParseTimeoutGuard::set(std::time::Duration::from_millis(1));
let result = validate_source(&source, Language::Rust).expect("timeout stays Some");
assert!(!result.valid);
assert!(
result.errors.iter().any(|e| e.text.contains("deadline")),
"{:?}",
result.errors
);
}
#[test]
fn validate_file_timeout_is_parse_timeout() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("deep.rs");
std::fs::write(&path, crate::ast::nested_rust_source_for_timeout(80_000)).unwrap();
let _guard = crate::ast::ParseTimeoutGuard::set(std::time::Duration::from_millis(1));
let err = validate_file(&path, Some(Language::Rust)).unwrap_err();
assert!(
crate::exit::is_parse_timeout(&err),
"expected parse_timeout, got {err}"
);
assert_eq!(crate::fallback::error_kind_str(&err), Some("parse_timeout"));
}
#[test]
fn validate_file_for_walk_timeout_is_invalid() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("deep.rs");
std::fs::write(&path, crate::ast::nested_rust_source_for_timeout(80_000)).unwrap();
let _guard = crate::ast::ParseTimeoutGuard::set(std::time::Duration::from_millis(1));
let result = validate_file_for_walk(&path, Some(Language::Rust)).expect("timeout stays");
assert!(!result.valid);
assert!(
result.errors.iter().any(|e| e.text.contains("deadline")),
"{:?}",
result.errors
);
}
}