Skip to main content

r/repl/
validator.rs

1//! R input validation for multi-line editing.
2//!
3//! Determines whether an input line is a complete R expression or needs
4//! continuation (unclosed brackets, strings, trailing operators, etc.).
5//! When the expression is incomplete, reedline shows the `+ ` continuation
6//! prompt and lets the user keep typing.
7
8use reedline::{ValidationResult, Validator};
9
10pub struct RValidator;
11
12impl Validator for RValidator {
13    fn validate(&self, line: &str) -> ValidationResult {
14        if is_likely_incomplete(line) {
15            ValidationResult::Incomplete
16        } else {
17            ValidationResult::Complete
18        }
19    }
20}
21
22fn is_likely_incomplete(input: &str) -> bool {
23    let mut open_parens = 0i32;
24    let mut open_braces = 0i32;
25    let mut open_brackets = 0i32;
26    let mut in_string = false;
27    let mut string_char = ' ';
28    let mut prev_char = ' ';
29    let mut in_comment = false;
30    let mut in_raw_string = false;
31    let mut raw_close_bracket = ' ';
32    let mut raw_quote = ' ';
33
34    let chars: Vec<char> = input.chars().collect();
35    let len = chars.len();
36    let mut i = 0;
37
38    while i < len {
39        let c = chars[i];
40
41        if in_comment {
42            if c == '\n' {
43                in_comment = false;
44            }
45            prev_char = c;
46            i += 1;
47            continue;
48        }
49
50        if in_raw_string {
51            // Look for close_bracket followed by matching quote
52            if c == raw_close_bracket && i + 1 < len && chars[i + 1] == raw_quote {
53                in_raw_string = false;
54                prev_char = raw_quote;
55                i += 2;
56                continue;
57            }
58            prev_char = c;
59            i += 1;
60            continue;
61        }
62
63        if in_string {
64            if c == string_char && prev_char != '\\' {
65                in_string = false;
66            }
67            prev_char = c;
68            i += 1;
69            continue;
70        }
71
72        // Check for raw strings: r"(...)", R"[...]", r'{...}', R'{...}'
73        if (c == 'r' || c == 'R') && i + 2 < len {
74            let quote = chars[i + 1];
75            if quote == '"' || quote == '\'' {
76                let open = chars[i + 2];
77                let close = match open {
78                    '(' => Some(')'),
79                    '[' => Some(']'),
80                    '{' => Some('}'),
81                    _ => None,
82                };
83                if let Some(close_ch) = close {
84                    in_raw_string = true;
85                    raw_close_bracket = close_ch;
86                    raw_quote = quote;
87                    prev_char = open;
88                    i += 3; // skip r"( or R"[ etc.
89                    continue;
90                }
91            }
92        }
93
94        match c {
95            '#' => in_comment = true,
96            '"' | '\'' => {
97                in_string = true;
98                string_char = c;
99            }
100            '(' => open_parens += 1,
101            ')' => open_parens -= 1,
102            '{' => open_braces += 1,
103            '}' => open_braces -= 1,
104            '[' => open_brackets += 1,
105            ']' => open_brackets -= 1,
106            _ => {}
107        }
108        prev_char = c;
109        i += 1;
110    }
111
112    if open_parens > 0 || open_braces > 0 || open_brackets > 0 || in_string || in_raw_string {
113        return true;
114    }
115
116    // Trailing binary operator means the expression continues
117    let trimmed = input.trim_end();
118    // Strip trailing comments to find the real trailing token
119    let code_end = strip_trailing_comment(trimmed);
120    let trailing = code_end.trim_end();
121
122    if trailing.is_empty() {
123        return false;
124    }
125
126    // Check for trailing binary operators
127    if trailing.ends_with('+')
128        || trailing.ends_with('*')
129        || trailing.ends_with('/')
130        || trailing.ends_with(',')
131        || trailing.ends_with('|')
132        || trailing.ends_with('&')
133        || trailing.ends_with('~')
134        || trailing.ends_with("<-")
135        || trailing.ends_with("<<-")
136        || trailing.ends_with("|>")
137        || trailing.ends_with("||")
138        || trailing.ends_with("&&")
139        || trailing.ends_with("%>%")
140    {
141        return true;
142    }
143
144    // Trailing '-' that isn't part of '->' or '->>'
145    if trailing.ends_with('-') && !trailing.ends_with("->") {
146        return true;
147    }
148
149    false
150}
151
152/// Strip a trailing comment from a line, returning just the code portion.
153/// For multi-line input, only considers the last non-empty line.
154fn strip_trailing_comment(input: &str) -> &str {
155    // Find the last non-empty line
156    let last_line = input
157        .rsplit('\n')
158        .find(|l| !l.trim().is_empty())
159        .unwrap_or(input);
160
161    // Walk through the line respecting strings to find a comment
162    let mut in_string = false;
163    let mut string_char = ' ';
164    let mut prev = ' ';
165    for (idx, c) in last_line.char_indices() {
166        if in_string {
167            if c == string_char && prev != '\\' {
168                in_string = false;
169            }
170            prev = c;
171            continue;
172        }
173        match c {
174            '"' | '\'' => {
175                in_string = true;
176                string_char = c;
177            }
178            '#' => return &last_line[..idx],
179            _ => {}
180        }
181        prev = c;
182    }
183    last_line
184}