oxur_cli/repl/
sexp_validator.rs

1//! Validator for multi-line S-expression editing
2//!
3//! Checks if S-expressions have balanced parentheses, brackets, and braces
4//! to enable multi-line editing in the REPL. When brackets are unbalanced,
5//! reedline will show the continuation prompt instead of evaluating.
6
7use reedline::{ValidationResult, Validator};
8
9/// S-expression validator for multi-line editing
10///
11/// Implements reedline's `Validator` trait to determine if input is complete
12/// or needs more lines. Checks for:
13/// - Balanced parentheses `()`
14/// - Balanced square brackets `[]`
15/// - Balanced curly braces `{}`
16/// - Properly closed strings
17#[derive(Clone)]
18pub struct SExpValidator;
19
20impl SExpValidator {
21    /// Create a new S-expression validator
22    pub fn new() -> Self {
23        Self
24    }
25
26    /// Check if input has balanced brackets and closed strings
27    ///
28    /// Returns `true` if all brackets are balanced and strings are closed.
29    fn is_balanced(line: &str) -> bool {
30        let mut paren_count = 0;
31        let mut bracket_count = 0;
32        let mut brace_count = 0;
33        let mut in_string = false;
34        let mut escape_next = false;
35
36        for ch in line.chars() {
37            if escape_next {
38                escape_next = false;
39                continue;
40            }
41
42            match ch {
43                '\\' if in_string => escape_next = true,
44                '"' => in_string = !in_string,
45                '(' if !in_string => paren_count += 1,
46                ')' if !in_string => paren_count -= 1,
47                '[' if !in_string => bracket_count += 1,
48                ']' if !in_string => bracket_count -= 1,
49                '{' if !in_string => brace_count += 1,
50                '}' if !in_string => brace_count -= 1,
51                _ => {}
52            }
53
54            // Early exit if we have negative counts (more closing than opening)
55            if paren_count < 0 || bracket_count < 0 || brace_count < 0 {
56                return false;
57            }
58        }
59
60        // All brackets must be balanced and strings must be closed
61        paren_count == 0 && bracket_count == 0 && brace_count == 0 && !in_string
62    }
63}
64
65impl Default for SExpValidator {
66    fn default() -> Self {
67        Self::new()
68    }
69}
70
71impl Validator for SExpValidator {
72    fn validate(&self, line: &str) -> ValidationResult {
73        if Self::is_balanced(line) {
74            ValidationResult::Complete
75        } else {
76            ValidationResult::Incomplete
77        }
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    #[test]
86    fn test_balanced_simple_expression() {
87        assert!(SExpValidator::is_balanced("(+ 1 2)"));
88    }
89
90    #[test]
91    fn test_balanced_nested_parens() {
92        assert!(SExpValidator::is_balanced("((+ 1 2) (* 3 4))"));
93    }
94
95    #[test]
96    fn test_balanced_with_brackets() {
97        assert!(SExpValidator::is_balanced("[1 2 3]"));
98    }
99
100    #[test]
101    fn test_balanced_with_braces() {
102        assert!(SExpValidator::is_balanced("{:a 1 :b 2}"));
103    }
104
105    #[test]
106    fn test_balanced_mixed_brackets() {
107        assert!(SExpValidator::is_balanced("(let [x 1] {:result x})"));
108    }
109
110    #[test]
111    fn test_balanced_with_string() {
112        assert!(SExpValidator::is_balanced(r#"(print "hello")"#));
113    }
114
115    #[test]
116    fn test_balanced_with_escaped_quote() {
117        assert!(SExpValidator::is_balanced(r#"(print "say \"hi\"")"#));
118    }
119
120    #[test]
121    fn test_unbalanced_open_paren() {
122        assert!(!SExpValidator::is_balanced("(+ 1 2"));
123    }
124
125    #[test]
126    fn test_unbalanced_close_paren() {
127        assert!(!SExpValidator::is_balanced("+ 1 2)"));
128    }
129
130    #[test]
131    fn test_unbalanced_nested() {
132        assert!(!SExpValidator::is_balanced("((+ 1 2)"));
133    }
134
135    #[test]
136    fn test_unbalanced_bracket() {
137        assert!(!SExpValidator::is_balanced("[1 2 3"));
138    }
139
140    #[test]
141    fn test_unbalanced_brace() {
142        assert!(!SExpValidator::is_balanced("{:a 1"));
143    }
144
145    #[test]
146    fn test_unclosed_string() {
147        assert!(!SExpValidator::is_balanced(r#"(print "hello"#));
148    }
149
150    #[test]
151    fn test_string_with_paren_inside() {
152        assert!(SExpValidator::is_balanced(r#"(print "foo(bar)")"#));
153    }
154
155    #[test]
156    fn test_empty_string() {
157        assert!(SExpValidator::is_balanced(""));
158    }
159
160    #[test]
161    fn test_validator_complete() {
162        let validator = SExpValidator::new();
163        assert!(matches!(validator.validate("(+ 1 2)"), ValidationResult::Complete));
164    }
165
166    #[test]
167    fn test_validator_incomplete() {
168        let validator = SExpValidator::new();
169        assert!(matches!(validator.validate("(+ 1 2"), ValidationResult::Incomplete));
170    }
171
172    #[test]
173    fn test_multiline_complete() {
174        let input = "(deffn square [x]\n  (* x x))";
175        assert!(SExpValidator::is_balanced(input));
176    }
177
178    #[test]
179    fn test_multiline_incomplete() {
180        let input = "(deffn square [x]\n  (* x x)";
181        assert!(!SExpValidator::is_balanced(input));
182    }
183}