use crate::editor::mode::EditorStateRef;
use reedline::{ValidationResult, Validator};
pub struct RValidator {
editor_state: Option<EditorStateRef>,
}
impl RValidator {
pub fn new() -> Self {
Self { editor_state: None }
}
pub fn with_editor_state(mut self, state: EditorStateRef) -> Self {
self.editor_state = Some(state);
self
}
fn create_parser() -> tree_sitter::Parser {
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&tree_sitter_r::LANGUAGE.into())
.expect("Failed to load R grammar");
parser
}
fn is_incomplete(&self, tree: &tree_sitter::Tree, source: &[u8]) -> bool {
let root = tree.root_node();
if !root.has_error() {
return false;
}
let mut cursor = root.walk();
self.check_incomplete(&mut cursor, source)
}
fn check_incomplete(&self, cursor: &mut tree_sitter::TreeCursor, source: &[u8]) -> bool {
let node = cursor.node();
let content_end = source
.iter()
.rposition(|&b| !b.is_ascii_whitespace())
.map(|i| i + 1)
.unwrap_or(0);
if node.is_missing() {
return true;
}
if node.kind() == "ERROR" && node.end_byte() >= content_end {
return true;
}
if cursor.goto_first_child() {
loop {
if self.check_incomplete(cursor, source) {
return true;
}
if !cursor.goto_next_sibling() {
break;
}
}
cursor.goto_parent();
}
false
}
fn is_misparsed_raw_string(&self, root: &tree_sitter::Node, source: &[u8]) -> bool {
if root.child_count() < 2 {
return false;
}
let first = match root.child(0) {
Some(n) => n,
None => return false,
};
let second = match root.child(1) {
Some(n) => n,
None => return false,
};
if first.kind() != "identifier" {
return false;
}
let id_text = &source[first.start_byte()..first.end_byte()];
if id_text != b"r" && id_text != b"R" {
return false;
}
if second.kind() != "string" {
return false;
}
let string_start = second.start_byte();
if string_start + 1 >= source.len() {
return false;
}
let after_quote = source.get(string_start + 1).copied();
let is_raw_delimiter = matches!(
after_quote,
Some(b'(') | Some(b'[') | Some(b'{') | Some(b'-')
);
if is_raw_delimiter {
return true;
}
false
}
}
impl Default for RValidator {
fn default() -> Self {
Self::new()
}
}
impl Validator for RValidator {
fn validate(&self, line: &str) -> ValidationResult {
if let Some(ref state_ref) = self.editor_state
&& let Ok(mut state) = state_ref.lock()
{
if line.is_empty() {
state.reset();
} else if state.buffer != line {
state.buffer = line.to_string();
state.buffer_len = line.chars().count();
state.uncertain = true;
}
}
let escaped = escape_for_debug(line);
if line.trim().is_empty() {
debug_log(&format!("[Validator] {:?} -> Complete (empty)", escaped));
return ValidationResult::Complete;
}
if line.trim_start().starts_with(':') {
debug_log(&format!(
"[Validator] {:?} -> Complete (meta command)",
escaped
));
return ValidationResult::Complete;
}
let source = line.as_bytes();
let mut parser = Self::create_parser();
let tree = match parser.parse(source, None) {
Some(tree) => tree,
None => {
debug_log(&format!(
"[Validator] {:?} -> Complete (parse failed)",
escaped
));
return ValidationResult::Complete;
}
};
let root = tree.root_node();
debug_log(&format!(
"[Validator] {:?} has_error={} tree={}",
escaped,
root.has_error(),
root.to_sexp()
));
if self.is_misparsed_raw_string(&root, source) {
debug_log(&format!(
"[Validator] {:?} -> Incomplete (misparsed raw string)",
escaped
));
return ValidationResult::Incomplete;
}
let is_incomplete = self.is_incomplete(&tree, source);
let result = if is_incomplete {
ValidationResult::Incomplete
} else {
ValidationResult::Complete
};
debug_log(&format!(
"[Validator] {:?} -> {}",
escaped,
if is_incomplete {
"Incomplete"
} else {
"Complete"
}
));
result
}
}
fn debug_log(msg: &str) {
use std::io::Write;
use std::sync::OnceLock;
static DEBUG_ENABLED: OnceLock<bool> = OnceLock::new();
static LOG_PATH: OnceLock<std::path::PathBuf> = OnceLock::new();
let enabled = DEBUG_ENABLED.get_or_init(|| {
std::env::var("R_TERM_VALIDATOR_DEBUG")
.map(|v| v == "1")
.unwrap_or(false)
});
if !*enabled {
return;
}
let path = LOG_PATH.get_or_init(|| std::env::temp_dir().join("arf-validator.log"));
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
{
let _ = writeln!(
f,
"[{}] {}",
chrono::Local::now().format("%H:%M:%S%.3f"),
msg
);
}
}
#[allow(dead_code)]
fn escape_for_debug(s: &str) -> String {
s.chars()
.map(|c| match c {
'\n' => "\\n".to_string(),
'\r' => "\\r".to_string(),
'\t' => "\\t".to_string(),
c if c.is_ascii_graphic() || c == ' ' => c.to_string(),
c => format!("\\x{:02x}", c as u32),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn is_complete(result: ValidationResult) -> bool {
matches!(result, ValidationResult::Complete)
}
fn is_incomplete(result: ValidationResult) -> bool {
matches!(result, ValidationResult::Incomplete)
}
#[test]
fn test_validator_complete_expressions() {
let validator = RValidator::new();
assert!(is_complete(validator.validate("1 + 1")));
assert!(is_complete(validator.validate("x <- 1")));
assert!(is_complete(validator.validate("print(x)")));
assert!(is_complete(validator.validate("stop('Test error')")));
assert!(is_complete(validator.validate("mean(c(1, 2, 3))")));
assert!(is_complete(validator.validate("list(a = 1, b = 2)")));
assert!(is_complete(validator.validate("{ x <- 1; x }")));
assert!(is_complete(validator.validate("if (TRUE) 1 else 2")));
}
#[test]
fn test_validator_incomplete_expressions() {
let validator = RValidator::new();
assert!(is_incomplete(validator.validate("foo(")));
assert!(is_incomplete(validator.validate("mean(c(1, 2")));
assert!(is_incomplete(validator.validate("{")));
assert!(is_incomplete(validator.validate("function() {")));
assert!(is_incomplete(validator.validate("x[")));
assert!(is_incomplete(validator.validate("\"hello")));
assert!(is_incomplete(validator.validate("'world")));
assert!(is_incomplete(validator.validate("1 +")));
assert!(is_incomplete(validator.validate("x <-")));
}
#[test]
fn test_validator_meta_commands_are_complete() {
let validator = RValidator::new();
assert!(is_complete(validator.validate(":h")));
assert!(is_complete(validator.validate(":help")));
assert!(is_complete(validator.validate(":quit")));
assert!(is_complete(validator.validate(":cd /tmp/")));
assert!(is_complete(validator.validate(":cd ~/Documents/")));
assert!(is_complete(validator.validate(":pushd /tmp/")));
assert!(is_complete(validator.validate(":system ls /tmp/")));
}
#[test]
fn test_validator_empty_and_whitespace() {
let validator = RValidator::new();
assert!(is_complete(validator.validate("")));
assert!(is_complete(validator.validate(" ")));
assert!(is_complete(validator.validate("\t")));
}
#[test]
fn test_validator_raw_strings() {
let validator = RValidator::new();
assert!(is_complete(validator.validate(r#"r"(hello)""#)));
assert!(is_complete(validator.validate(r#"r"-(hello)-""#)));
assert!(is_complete(validator.validate(r#"r"(')""#)));
assert!(is_complete(validator.validate(r#"r"()""#)));
assert!(is_incomplete(validator.validate(r#"r"(hello"#)));
assert!(is_incomplete(validator.validate(concat!(r#"r"(')"#, "\n"))));
assert!(is_incomplete(
validator.validate(concat!(r#"r"(')"#, "\n\n"))
));
assert!(is_incomplete(
validator.validate(concat!(r#"r"("#, "\n", r#"""#))
)); assert!(is_incomplete(
validator.validate(concat!(r#"r"("#, "\n", r#""""#))
)); assert!(is_incomplete(
validator.validate(concat!(r#"r"("#, "\nhello", r#"""#))
));
assert!(is_complete(
validator.validate(concat!(r#"r"("#, "\n", r#")""#))
));
assert!(is_complete(
validator.validate(concat!(r#"r"(""#, "\n", r#")""#))
));
}
#[test]
fn test_validator_multiline() {
let validator = RValidator::new();
assert!(is_incomplete(validator.validate("(")));
assert!(is_incomplete(validator.validate("(\n")));
assert!(is_incomplete(validator.validate("(\n\n")));
assert!(is_incomplete(validator.validate("(\n\n\n")));
assert!(is_incomplete(validator.validate("(\n1")));
assert!(is_complete(validator.validate("(\n1\n)")));
assert!(is_incomplete(validator.validate("function() {")));
assert!(is_incomplete(validator.validate("function() {\n")));
assert!(is_complete(validator.validate("function() {\n1\n}")));
}
}