chisel/
solidity_helper.rs

1//! SolidityHelper
2//!
3//! This module contains the `SolidityHelper`, a [rustyline::Helper] implementation for
4//! usage in Chisel. It is ported from [soli](https://github.com/jpopesculian/soli/blob/master/src/main.rs).
5
6use crate::{
7    dispatcher::PROMPT_ARROW,
8    prelude::{ChiselCommand, COMMAND_LEADER, PROMPT_ARROW_STR},
9};
10use rustyline::{
11    completion::Completer,
12    highlight::{CmdKind, Highlighter},
13    hint::Hinter,
14    validate::{ValidationContext, ValidationResult, Validator},
15    Helper,
16};
17use solar_parse::{
18    interface::{Session, SessionGlobals},
19    token::{Token, TokenKind},
20    Lexer,
21};
22use std::{borrow::Cow, ops::Range, str::FromStr};
23use yansi::{Color, Style};
24
25/// The maximum length of an ANSI prefix + suffix characters using [SolidityHelper].
26///
27/// * 5 - prefix:
28///   * 2 - start: `\x1B[`
29///   * 2 - fg: `3<fg_code>`
30///   * 1 - end: `m`
31/// * 4 - suffix: `\x1B[0m`
32const MAX_ANSI_LEN: usize = 9;
33
34/// A rustyline helper for Solidity code
35pub struct SolidityHelper {
36    errored: bool,
37
38    do_paint: bool,
39    sess: Session,
40    globals: SessionGlobals,
41}
42
43impl Default for SolidityHelper {
44    fn default() -> Self {
45        Self::new()
46    }
47}
48
49impl SolidityHelper {
50    /// Create a new SolidityHelper.
51    pub fn new() -> Self {
52        Self {
53            errored: false,
54            do_paint: yansi::is_enabled(),
55            sess: Session::builder().with_silent_emitter(None).build(),
56            globals: SessionGlobals::new(),
57        }
58    }
59
60    /// Returns whether the helper is in an errored state.
61    pub fn errored(&self) -> bool {
62        self.errored
63    }
64
65    /// Set the errored field.
66    pub fn set_errored(&mut self, errored: bool) -> &mut Self {
67        self.errored = errored;
68        self
69    }
70
71    /// Highlights a Solidity source string.
72    pub fn highlight<'a>(&self, input: &'a str) -> Cow<'a, str> {
73        if !self.do_paint() {
74            return Cow::Borrowed(input)
75        }
76
77        // Highlight commands separately
78        if input.starts_with(COMMAND_LEADER) {
79            let (cmd, rest) = match input.split_once(' ') {
80                Some((cmd, rest)) => (cmd, Some(rest)),
81                None => (input, None),
82            };
83            let cmd = cmd.strip_prefix(COMMAND_LEADER).unwrap_or(cmd);
84
85            let mut out = String::with_capacity(input.len() + MAX_ANSI_LEN);
86
87            // cmd
88            out.push(COMMAND_LEADER);
89            let cmd_res = ChiselCommand::from_str(cmd);
90            let style = (if cmd_res.is_ok() { Color::Green } else { Color::Red }).foreground();
91            Self::paint_unchecked(cmd, style, &mut out);
92
93            // rest
94            match rest {
95                Some(rest) if !rest.is_empty() => {
96                    out.push(' ');
97                    out.push_str(rest);
98                }
99                _ => {}
100            }
101
102            Cow::Owned(out)
103        } else {
104            let mut out = String::with_capacity(input.len() * 2);
105            self.with_contiguous_styles(input, |style, range| {
106                Self::paint_unchecked(&input[range], style, &mut out);
107            });
108            Cow::Owned(out)
109        }
110    }
111
112    /// Returns a list of styles and the ranges they should be applied to.
113    ///
114    /// Covers the entire source string, including any whitespace.
115    fn with_contiguous_styles(&self, input: &str, mut f: impl FnMut(Style, Range<usize>)) {
116        self.enter(|sess| {
117            let len = input.len();
118            let mut index = 0;
119            for token in Lexer::new(sess, input) {
120                let range = token.span.lo().to_usize()..token.span.hi().to_usize();
121                let style = token_style(&token);
122                if index < range.start {
123                    f(Style::default(), index..range.start);
124                }
125                index = range.end;
126                f(style, range);
127            }
128            if index < len {
129                f(Style::default(), index..len);
130            }
131        });
132    }
133
134    /// Validate that a source snippet is closed (i.e., all braces and parenthesis are matched).
135    fn validate_closed(&self, input: &str) -> ValidationResult {
136        let mut depth = [0usize; 3];
137        self.enter(|sess| {
138            for token in Lexer::new(sess, input) {
139                match token.kind {
140                    TokenKind::OpenDelim(delim) => {
141                        depth[delim as usize] += 1;
142                    }
143                    TokenKind::CloseDelim(delim) => {
144                        depth[delim as usize] = depth[delim as usize].saturating_sub(1);
145                    }
146                    _ => {}
147                }
148            }
149        });
150        if depth == [0; 3] {
151            ValidationResult::Valid(None)
152        } else {
153            ValidationResult::Incomplete
154        }
155    }
156
157    /// Formats `input` with `style` into `out`, without checking `style.wrapping` or
158    /// `self.do_paint`.
159    fn paint_unchecked(string: &str, style: Style, out: &mut String) {
160        if style == Style::default() {
161            out.push_str(string);
162        } else {
163            let _ = style.fmt_prefix(out);
164            out.push_str(string);
165            let _ = style.fmt_suffix(out);
166        }
167    }
168
169    fn paint_unchecked_owned(string: &str, style: Style) -> String {
170        let mut out = String::with_capacity(MAX_ANSI_LEN + string.len());
171        Self::paint_unchecked(string, style, &mut out);
172        out
173    }
174
175    /// Returns whether to color the output.
176    fn do_paint(&self) -> bool {
177        self.do_paint
178    }
179
180    /// Enters the session.
181    fn enter(&self, f: impl FnOnce(&Session)) {
182        self.globals.set(|| self.sess.enter(|| f(&self.sess)));
183    }
184}
185
186impl Highlighter for SolidityHelper {
187    fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> {
188        self.highlight(line)
189    }
190
191    fn highlight_char(&self, line: &str, pos: usize, _kind: CmdKind) -> bool {
192        pos == line.len()
193    }
194
195    fn highlight_prompt<'b, 's: 'b, 'p: 'b>(
196        &'s self,
197        prompt: &'p str,
198        _default: bool,
199    ) -> Cow<'b, str> {
200        if !self.do_paint() {
201            return Cow::Borrowed(prompt)
202        }
203
204        let mut out = prompt.to_string();
205
206        // `^(\(ID: .*?\) )? ➜ `
207        if prompt.starts_with("(ID: ") {
208            let id_end = prompt.find(')').unwrap();
209            let id_span = 5..id_end;
210            let id = &prompt[id_span.clone()];
211            out.replace_range(
212                id_span,
213                &Self::paint_unchecked_owned(id, Color::Yellow.foreground()),
214            );
215            out.replace_range(1..=2, &Self::paint_unchecked_owned("ID", Color::Cyan.foreground()));
216        }
217
218        if let Some(i) = out.find(PROMPT_ARROW) {
219            let style =
220                if self.errored { Color::Red.foreground() } else { Color::Green.foreground() };
221            out.replace_range(i..=i + 2, &Self::paint_unchecked_owned(PROMPT_ARROW_STR, style));
222        }
223
224        Cow::Owned(out)
225    }
226}
227
228impl Validator for SolidityHelper {
229    fn validate(&self, ctx: &mut ValidationContext<'_>) -> rustyline::Result<ValidationResult> {
230        Ok(self.validate_closed(ctx.input()))
231    }
232}
233
234impl Completer for SolidityHelper {
235    type Candidate = String;
236}
237
238impl Hinter for SolidityHelper {
239    type Hint = String;
240}
241
242impl Helper for SolidityHelper {}
243
244#[expect(non_upper_case_globals)]
245#[deny(unreachable_patterns)]
246fn token_style(token: &Token) -> Style {
247    use solar_parse::{
248        interface::kw::*,
249        token::{TokenKind::*, TokenLitKind::*},
250    };
251
252    match token.kind {
253        Literal(Str | HexStr | UnicodeStr, _) => Color::Green.foreground(),
254        Literal(..) => Color::Yellow.foreground(),
255
256        Ident(
257            Memory | Storage | Calldata | Public | Private | Internal | External | Constant |
258            Pure | View | Payable | Anonymous | Indexed | Abstract | Virtual | Override |
259            Modifier | Immutable | Unchecked,
260        ) => Color::Cyan.foreground(),
261
262        Ident(s) if s.is_elementary_type() => Color::Blue.foreground(),
263        Ident(Mapping) => Color::Blue.foreground(),
264
265        Ident(s) if s.is_used_keyword() || s.is_yul_keyword() => Color::Magenta.foreground(),
266        Arrow | FatArrow => Color::Magenta.foreground(),
267
268        Comment(..) => Color::Primary.dim(),
269
270        _ => Color::Primary.foreground(),
271    }
272}