narju/repl/
highlight.rs

1//! Syntax highlighting and bracket validation for the narju REPL.
2
3use std::borrow::Cow::{self, Borrowed, Owned};
4
5use rustyline::completion::FilenameCompleter;
6use rustyline::highlight::Highlighter;
7use rustyline::hint::HistoryHinter;
8use rustyline::validate::{ValidationContext, ValidationResult, Validator};
9use rustyline_derive::{Completer, Helper, Hinter};
10
11use crate::colours::*;
12
13/// Surface forms rendered in the keyword colour.
14pub const KEYWORDS: &[&str] = &[
15    "lambda", "let", "if", "lift", "run", "quote",
16    "null?", "number?", "symbol?", "pair?", "car", "cdr", "cons", "eq?",
17    "lift-ref", "log", "code?", "read", "print",
18    "evalms", "trans", "catch",
19];
20
21/// The rustyline helper for the raw λ↑↓ REPL: syntax highlighting,
22/// filename completion (for `:load`), history hints, and bracket
23/// validation.
24#[derive(Helper, Completer, Hinter)]
25pub struct NarjuHelper {
26    /// Completes filenames, for `:load` arguments.
27    #[rustyline(Completer)]
28    pub completer: FilenameCompleter,
29    /// Offers the rest of a matching history entry as a dimmed hint.
30    #[rustyline(Hinter)]
31    pub hinter: HistoryHinter,
32}
33
34impl Highlighter for NarjuHelper {
35    fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> {
36        Owned(format!("{DIM}{hint}{RESET}"))
37    }
38
39    fn highlight_prompt<'b, 's: 'b, 'p: 'b>(
40        &'s self,
41        prompt: &'p str,
42        _default: bool,
43    ) -> Cow<'b, str> {
44        Borrowed(prompt)
45    }
46
47    fn highlight<'l>(&self, line: &'l str, pos: usize) -> Cow<'l, str> {
48        Owned(highlight_sexp(line, Some(pos)))
49    }
50
51    fn highlight_char(&self, _line: &str, _pos: usize, _forced: bool) -> bool {
52        true
53    }
54}
55
56/// Bracket-balance validator — waits for balanced parens before submitting.
57///
58/// Shared between `NarjuHelper` (raw REPL) and `NarjuHelperLite` (Purple REPL).
59pub fn validate_brackets(input: &str) -> rustyline::Result<ValidationResult> {
60    let mut depth: i32 = 0;
61    let mut in_comment = false;
62    for ch in input.chars() {
63        if in_comment {
64            if ch == '\n' {
65                in_comment = false;
66            }
67            continue;
68        }
69        match ch {
70            ';' => in_comment = true,
71            '(' => depth += 1,
72            ')' => depth -= 1,
73            _ => {}
74        }
75    }
76    if depth > 0 {
77        Ok(ValidationResult::Incomplete)
78    } else if depth < 0 {
79        Ok(ValidationResult::Invalid(Some(format!(
80            "  {WARN}unmatched ')'{RESET}"
81        ))))
82    } else {
83        Ok(ValidationResult::Valid(None))
84    }
85}
86
87/// Bracket-balance validator — waits for balanced parens before submitting.
88impl Validator for NarjuHelper {
89    fn validate(&self, ctx: &mut ValidationContext) -> rustyline::Result<ValidationResult> {
90        validate_brackets(ctx.input())
91    }
92}
93
94// ── Bracket pairing ──────────────────────────────────────────────────────────
95//
96// Rustyline reports cursor position as a *byte* offset into the input.
97// We pre-compute a map from each bracket's byte-offset to its match's
98// byte-offset, ignoring brackets inside line comments. When the cursor
99// is on a bracket whose match exists, we flash both with `BOLD + BRACK`;
100// other brackets render with plain `BRACK`. Unmatched brackets render
101// as plain `BRACK` (the validator catches them on Enter).
102
103/// Map every bracket's byte-offset to its matching bracket's byte-offset.
104/// Brackets inside line comments are ignored. Unmatched brackets do not
105/// appear in the map.
106fn bracket_pairs(src: &str) -> std::collections::HashMap<usize, usize> {
107    let mut pairs = std::collections::HashMap::new();
108    let mut stack: Vec<usize> = Vec::new();
109    let mut in_comment = false;
110    for (byte_off, ch) in src.char_indices() {
111        if in_comment {
112            if ch == '\n' {
113                in_comment = false;
114            }
115            continue;
116        }
117        match ch {
118            ';' => in_comment = true,
119            '(' => stack.push(byte_off),
120            ')' => {
121                if let Some(open) = stack.pop() {
122                    pairs.insert(open, byte_off);
123                    pairs.insert(byte_off, open);
124                }
125            }
126            _ => {}
127        }
128    }
129    pairs
130}
131
132// ── Purple-mode helper ───────────────────────────────────────────────────────
133//
134// `NarjuHelperLite` is the helper used by `TerminalIo` for the Purple REPL.
135// It provides syntax highlighting and bracket validation, but no filename
136// completion (confusing in a Pink context) and no history hinting (Pink
137// bindings aren't visible from `top.names`, so hints would be misleading).
138//
139// We implement the four sub-traits manually rather than via derive macros
140// because we want trivial no-op `Completer` and `Hinter` impls.
141
142/// The rustyline helper for the Purple REPL: highlighting and bracket
143/// validation only. See the comment block above for why completion and
144/// hinting are stubbed out.
145pub struct NarjuHelperLite;
146
147impl rustyline::completion::Completer for NarjuHelperLite {
148    type Candidate = String;
149    fn complete(
150        &self,
151        _line: &str,
152        _pos: usize,
153        _ctx: &rustyline::Context<'_>,
154    ) -> rustyline::Result<(usize, Vec<Self::Candidate>)> {
155        Ok((0, Vec::new()))
156    }
157}
158
159impl rustyline::hint::Hinter for NarjuHelperLite {
160    type Hint = String;
161    fn hint(&self, _line: &str, _pos: usize, _ctx: &rustyline::Context<'_>) -> Option<String> {
162        None
163    }
164}
165
166impl Highlighter for NarjuHelperLite {
167    fn highlight<'l>(&self, line: &'l str, pos: usize) -> Cow<'l, str> {
168        Owned(highlight_sexp(line, Some(pos)))
169    }
170
171    fn highlight_char(&self, _line: &str, _pos: usize, _forced: bool) -> bool {
172        true
173    }
174
175    fn highlight_prompt<'b, 's: 'b, 'p: 'b>(
176        &'s self,
177        prompt: &'p str,
178        _default: bool,
179    ) -> Cow<'b, str> {
180        Borrowed(prompt)
181    }
182}
183
184impl Validator for NarjuHelperLite {
185    fn validate(&self, ctx: &mut ValidationContext) -> rustyline::Result<ValidationResult> {
186        validate_brackets(ctx.input())
187    }
188}
189
190impl rustyline::Helper for NarjuHelperLite {}
191
192/// Render `src` with ANSI colours: keywords, literals, numbers,
193/// comments, quotes, and brackets each get their own colour.
194///
195/// If `cursor_pos` is `Some(b)`, byte-offset `b` is treated as the cursor.
196/// If `b` lands on a bracket whose match exists, both brackets render with
197/// `BOLD + BRACK`. Other brackets render with plain `BRACK`.
198pub fn highlight_sexp(src: &str, cursor_pos: Option<usize>) -> String {
199    let pairs = bracket_pairs(src);
200    let flash_at: Option<(usize, usize)> = cursor_pos.and_then(|c| pairs.get(&c).map(|&m| (c, m)));
201    let is_flash = |off: usize| flash_at.is_some_and(|(a, b)| off == a || off == b);
202
203    let mut out = String::with_capacity(src.len() * 2);
204    let chars: Vec<char> = src.chars().collect();
205    let char_byte_offs: Vec<usize> = src.char_indices().map(|(b, _)| b).collect();
206    let mut i = 0;
207
208    while i < chars.len() {
209        let c = chars[i];
210        let byte_off = char_byte_offs.get(i).copied().unwrap_or(src.len());
211
212        if c == ';' {
213            out.push_str(DIM);
214            while i < chars.len() && chars[i] != '\n' {
215                out.push(chars[i]);
216                i += 1;
217            }
218            out.push_str(RESET);
219            continue;
220        }
221
222        if c == '(' || c == ')' {
223            if is_flash(byte_off) {
224                out.push_str(BOLD);
225                out.push_str(BRACK);
226            } else {
227                out.push_str(BRACK);
228            }
229            out.push(c);
230            out.push_str(RESET);
231            i += 1;
232            continue;
233        }
234
235        if c == '\'' {
236            out.push_str(BLOOM);
237            out.push(c);
238            out.push_str(RESET);
239            i += 1;
240            continue;
241        }
242
243        if c.is_ascii_digit()
244            || (c == '-' && i + 1 < chars.len() && chars[i + 1].is_ascii_digit())
245        {
246            out.push_str(STEM);
247            out.push(c);
248            i += 1;
249            while i < chars.len() && chars[i].is_ascii_digit() {
250                out.push(chars[i]);
251                i += 1;
252            }
253            out.push_str(RESET);
254            continue;
255        }
256
257        if is_sym_char(c) {
258            let start = i;
259            while i < chars.len() && is_sym_char(chars[i]) {
260                i += 1;
261            }
262            let word: String = chars[start..i].iter().collect();
263            if KEYWORDS.contains(&word.as_str()) {
264                out.push_str(KW);
265            } else if word == "nil" || word == "#t" || word == "#f" {
266                out.push_str(BLOOM);
267            } else {
268                out.push_str(PETAL);
269            }
270            out.push_str(&word);
271            out.push_str(RESET);
272            continue;
273        }
274
275        out.push(c);
276        i += 1;
277    }
278    out
279}
280
281/// Whether `c` can appear in a symbol, for tokenising during
282/// highlighting. Mirrors the reader's symbol alphabet
283/// ([`crate::parse`]); the two must agree or highlighting splits tokens
284/// differently than the reader does.
285pub fn is_sym_char(c: char) -> bool {
286    c.is_alphanumeric()
287        || matches!(
288            c,
289            '+' | '-' | '*' | '/' | '?' | '!' | '<' | '>' | '=' | '_' | '#'
290        )
291}