Skip to main content

hermes_support/
render.rs

1//! Byte-compatible source-line + caret rendering. Port of
2//! `SourceErrorManager::buildSourceAndCaretLine` and `printDiagnosticHelper`.
3
4use crate::diag::{DiagHandler, DiagKind, OutputOptions, ResolvedDiagnostic};
5
6/// Build the (expanded) source line and the caret/underline line for a
7/// diagnostic. Faithful port of `buildSourceAndCaretLine`.
8///
9/// `col` is the 1-based byte column of the caret. `ranges` are 0-based byte
10/// `[start, end)` ranges to underline with `~`.
11pub fn build_source_and_caret_line(
12    source_line_text: &str,
13    col: u32,
14    ranges: &[(usize, usize)],
15    opts: &OutputOptions,
16) -> (String, String) {
17    // Decode our source line to UTF-32 (here: Vec<char>). Map from narrow byte
18    // to column as we go.
19    let mut byte_to_column: Vec<usize> = Vec::new();
20    let mut source_line: Vec<char> = Vec::new();
21    for ch in source_line_text.chars() {
22        // The column (code-point index) for this char is the current length of
23        // the decoded line; push it once per narrow byte the char spans.
24        let column = source_line.len();
25        for _ in 0..ch.len_utf8() {
26            byte_to_column.push(column);
27        }
28        source_line.push(ch);
29    }
30    let num_columns = source_line.len();
31
32    // Map a 0-based narrow byte offset to a code-point column. Out-of-range
33    // offsets map to one-past-the-end.
34    let widen_column = |narrow_column: usize| -> usize {
35        byte_to_column
36            .get(narrow_column)
37            .copied()
38            .unwrap_or(num_columns)
39    };
40
41    // getColumnNo() is 0-based byte col; our `col` is 1-based.
42    let column_no = widen_column((col as usize).saturating_sub(1));
43
44    let widened_ranges: Vec<(usize, usize)> = ranges
45        .iter()
46        .map(|&(s, e)| (widen_column(s), widen_column(e)))
47        .collect();
48
49    // Build the caret line as ASCII bytes (space/`~`/`^`/`.`).
50    let mut caret_line: Vec<u8> = vec![b' '; num_columns + 1];
51    for &(first, second) in &widened_ranges {
52        if first < caret_line.len() {
53            let end = std::cmp::min(second, caret_line.len());
54            for c in &mut caret_line[first..end] {
55                *c = b'~';
56            }
57        }
58    }
59    caret_line[std::cmp::min(column_no, num_columns)] = b'^';
60
61    // Trim trailing spaces: erase everything after the last non-space char.
62    if let Some(last) = caret_line.iter().rposition(|&c| c != b' ') {
63        caret_line.truncate(last + 1);
64    } else {
65        caret_line.clear();
66    }
67
68    // Expand tabs to spaces in both lines.
69    let tab_stop = OutputOptions::TAB_STOP;
70    let mut pos = 0;
71    while pos < source_line.len() {
72        if source_line[pos] == '\t' {
73            let expand_count = tab_stop - (pos % tab_stop);
74            // Replace the tab in the source line with `expand_count` spaces.
75            source_line.splice(pos..pos + 1, std::iter::repeat_n(' ', expand_count));
76            // Mirror the expansion in the caret line: a tab under '~' becomes
77            // more '~', otherwise spaces.
78            if pos < caret_line.len() {
79                let fill = caret_line[pos];
80                caret_line.splice(pos..pos + 1, std::iter::repeat_n(fill, expand_count));
81            }
82            pos += expand_count;
83        } else {
84            pos += 1;
85        }
86    }
87
88    // Trim to preferredMaxErrorWidth, focusing around caret / intersecting
89    // range. preferredMaxErrorWidth defaults to "unlimited" (usize::MAX), which
90    // skips the trim branch unless a finite width is set.
91    let preferred_max_error_width = opts.preferred_max_error_width.unwrap_or(usize::MAX);
92    let mut focus_start: usize = column_no;
93    let mut focus_length: usize = 1;
94    for &(first, second) in &widened_ranges {
95        if first <= column_no && column_no < second {
96            focus_start = first;
97            focus_length = second - first;
98            break;
99        }
100    }
101    let desired_line_length = std::cmp::max(
102        preferred_max_error_width,
103        focus_length + OutputOptions::MINIMUM_SOURCE_CONTEXT,
104    );
105    if source_line.len() > desired_line_length {
106        let focus_center = focus_start + focus_length / 2;
107        // leftTrimAmount can be negative in C++; guard with a signed compare.
108        let half = desired_line_length / 2;
109        if focus_center > half {
110            let left_trim_amount = focus_center - half;
111            // Erase the leading portion of both lines.
112            let ct = std::cmp::min(left_trim_amount, caret_line.len());
113            caret_line.drain(0..ct);
114            let st = std::cmp::min(left_trim_amount, source_line.len());
115            source_line.drain(0..st);
116            // Mark the truncation with up to three '.'.
117            for c in source_line.iter_mut().take(3) {
118                *c = '.';
119            }
120        }
121        if source_line.len() > desired_line_length {
122            let ce = std::cmp::min(caret_line.len(), desired_line_length);
123            caret_line.truncate(ce);
124            source_line.truncate(desired_line_length);
125            // Mark the right truncation with up to three '.'.
126            let len = source_line.len();
127            for c in source_line.iter_mut().skip(len.saturating_sub(3)) {
128                *c = '.';
129            }
130        }
131    }
132
133    // Re-encode sourceLine (UTF-32) back to narrow UTF-8.
134    let narrow_source_line: String = source_line.into_iter().collect();
135    let caret_string: String = caret_line.into_iter().map(|c| c as char).collect();
136    (narrow_source_line, caret_string)
137}
138
139/// Render a `ResolvedDiagnostic` to a `String`. The returned string ends with
140/// a newline. Produces:
141/// - `file:line:col: kind: message\n`
142/// - the source line + `\n` (if available)
143/// - the caret/underline line + `\n` (only for all-ASCII source lines)
144///
145/// Port of `printDiagnosticHelper`.
146pub fn render_diagnostic(diag: &ResolvedDiagnostic, opts: &OutputOptions) -> String {
147    let kind_str = match diag.kind {
148        DiagKind::Error => "error",
149        DiagKind::Warning => "warning",
150        DiagKind::Note => "note",
151    };
152    // The location prefix is conditional, exactly as in
153    // `printDiagnosticHelper` (SourceErrorManager.cpp:575-583): an empty
154    // filename prints no prefix at all, `-` prints as `<stdin>`, and the
155    // column is omitted when C++'s `columnNo` is -1. C++ builds that
156    // `columnNo` as `col - 1`, so "no column" is exactly `col == 0` here —
157    // unreachable for a resolved location (columns are 1-based) and what a
158    // location-less message carries. `lineNo` is never -1 in Hermes's use
159    // (`SourceMgr::GetMessage` leaves it 0 for an invalid location,
160    // SourceMgr.cpp:238-298), so it is always printed with the filename;
161    // that is what makes the "too many errors emitted" sentinel print as
162    // `<unknown>:0: error: ...`.
163    let mut out = String::new();
164    if !diag.file_name.is_empty() {
165        if diag.file_name == "-" {
166            out.push_str("<stdin>");
167        } else {
168            out.push_str(&diag.file_name);
169        }
170        out.push(':');
171        out.push_str(&diag.line.to_string());
172        if diag.col != 0 {
173            out.push(':');
174            out.push_str(&diag.col.to_string());
175        }
176        out.push_str(": ");
177    }
178    out.push_str(kind_str);
179    out.push_str(": ");
180    out.push_str(&diag.message);
181    out.push('\n');
182    if let Some(src) = &diag.source_line {
183        // Convert Option<(u32,u32)> to a one-element or empty slice of
184        // (usize, usize) so we can pass it to build_source_and_caret_line.
185        let range_arr: [(usize, usize); 1];
186        let ranges: &[(usize, usize)] = match diag.range_cols {
187            Some((s, e)) => {
188                range_arr = [(s as usize, e as usize)];
189                &range_arr
190            }
191            None => &[],
192        };
193        // Like C++ printDiagnosticHelper, always print the tab-expanded source
194        // line returned by build_source_and_caret_line (not the raw line), so the
195        // source and caret columns stay aligned. The caret line itself is only
196        // shown for all-ASCII source lines.
197        let (src_expanded, caret) = build_source_and_caret_line(src, diag.col, ranges, opts);
198        out.push_str(&src_expanded);
199        out.push('\n');
200        if src.is_ascii() {
201            out.push_str(&caret);
202            out.push('\n');
203        }
204    }
205    out
206}
207
208/// Default handler: prints `file:line:col: kind: message`, the source line, and
209/// (for all-ASCII lines) a caret/underline. Port of `printDiagnosticHelper`.
210pub struct StderrHandler {
211    opts: OutputOptions,
212}
213
214impl StderrHandler {
215    pub fn new(opts: OutputOptions) -> StderrHandler {
216        StderrHandler { opts }
217    }
218}
219
220impl DiagHandler for StderrHandler {
221    fn as_any(&self) -> &dyn std::any::Any {
222        self
223    }
224
225    fn handle(&mut self, diag: &ResolvedDiagnostic) {
226        let s = render_diagnostic(diag, &self.opts);
227        // The string already ends with '\n'; use eprint! to avoid a double newline.
228        eprint!("{}", s);
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use crate::diag::{DiagKind, OutputOptions};
236
237    /// `printDiagnosticHelper`'s location prefix is conditional
238    /// (SourceErrorManager.cpp:575-583): the whole prefix is skipped for an
239    /// empty filename, `-` renders as `<stdin>`, and the column is omitted
240    /// when `columnNo == -1` — which is what a location-less diagnostic has,
241    /// since `SMDiagnostic` gets `col - 1` and a location-less message has
242    /// col 0. The "too many errors emitted" sentinel is the one such message
243    /// hermesc actually emits, and it prints as `<unknown>:0: error: ...`
244    /// (`BufferID = "<unknown>"`, SourceMgr.cpp:246; `LineAndCol` defaults to
245    /// {0,0}).
246    #[test]
247    fn header_prefix_is_conditional() {
248        use crate::diag::ResolvedDiagnostic;
249        let base = |file: &str, line: u32, col: u32| ResolvedDiagnostic {
250            kind: DiagKind::Error,
251            file_name: file.into(),
252            line,
253            col,
254            message: "m".into(),
255            source_line: None,
256            range_cols: None,
257        };
258        let opts = OutputOptions::default();
259        // The sentinel's shape: no column, line 0, `<unknown>` filename.
260        assert_eq!(
261            render_diagnostic(&base("<unknown>", 0, 0), &opts),
262            "<unknown>:0: error: m\n"
263        );
264        // An empty filename drops the prefix entirely.
265        assert_eq!(render_diagnostic(&base("", 0, 0), &opts), "error: m\n");
266        // `-` is the stdin buffer name; it renders as `<stdin>`.
267        assert_eq!(
268            render_diagnostic(&base("-", 3, 4), &opts),
269            "<stdin>:3:4: error: m\n"
270        );
271        // The ordinary case is unchanged.
272        assert_eq!(
273            render_diagnostic(&base("t.js", 3, 4), &opts),
274            "t.js:3:4: error: m\n"
275        );
276    }
277
278    #[test]
279    fn ranged_caret_underline() {
280        use crate::diag::ResolvedDiagnostic;
281        let d = ResolvedDiagnostic {
282            kind: DiagKind::Error,
283            file_name: "t".into(),
284            line: 1,
285            col: 5,
286            message: "m".into(),
287            source_line: Some("let x = 1;".into()),
288            range_cols: Some((4, 9)),
289        };
290        let s = render_diagnostic(&d, &OutputOptions::default());
291        assert!(
292            s.contains("t:1:5: error: m"),
293            "header not found in: {:?}",
294            s
295        );
296        assert!(
297            s.contains("    ^~~~~"),
298            "caret underline not found in: {:?}",
299            s
300        );
301    }
302
303    #[test]
304    fn render_expands_tabs_in_source_line() {
305        use crate::diag::ResolvedDiagnostic;
306        // Like C++, the printed source line is tab-expanded so it stays aligned
307        // with the caret line. "\tx" with the caret on 'x' (col 2) -> 8 spaces.
308        let d = ResolvedDiagnostic {
309            kind: DiagKind::Error,
310            file_name: "t".into(),
311            line: 1,
312            col: 2,
313            message: "m".into(),
314            source_line: Some("\tx".into()),
315            range_cols: None,
316        };
317        let s = render_diagnostic(&d, &OutputOptions::default());
318        assert!(
319            s.contains("        x\n"),
320            "source not tab-expanded: {:?}",
321            s
322        );
323        assert!(s.contains("        ^\n"), "caret misaligned: {:?}", s);
324    }
325
326    #[test]
327    fn caret_under_single_column() {
328        let (src, caret) =
329            build_source_and_caret_line("let x = 1;", 5, &[], &OutputOptions::default());
330        assert_eq!(src, "let x = 1;");
331        assert_eq!(caret, "    ^");
332    }
333
334    #[test]
335    fn tabs_expand_to_spaces_tabstop_8() {
336        let (src, caret) = build_source_and_caret_line("\tx", 2, &[], &OutputOptions::default());
337        assert_eq!(src, "        x");
338        assert_eq!(caret, "        ^");
339    }
340
341    #[test]
342    fn range_underlined_with_tildes() {
343        let (_src, caret) =
344            build_source_and_caret_line("let x = 1;", 5, &[(4, 9)], &OutputOptions::default());
345        // Faithful C++ port: range [4,9) underlines columns 4..=8 (5 columns),
346        // and the '^' overwrites column 4. So 4 spaces, '^', then 4 '~'.
347        assert_eq!(caret, "    ^~~~~");
348    }
349
350    #[test]
351    fn non_ascii_columns_are_codepoints() {
352        // "éx": 'é' is 2 bytes (byte indices 0,1) / 1 column; 'x' is byte 2.
353        // `col` is a 1-based *byte* column (faithful to C++ getColumnNo, which
354        // is a 0-based byte offset). col=2 -> 0-based byte 1, which is the
355        // second byte of 'é' and widens back to column 0. So the caret lands on
356        // 'é' (column 0), yielding "^".
357        let (_src, caret) = build_source_and_caret_line("éx", 2, &[], &OutputOptions::default());
358        assert_eq!(caret, "^");
359    }
360
361    #[test]
362    fn non_ascii_columns_caret_on_second_char() {
363        // To land on 'x' (column 1), the caret must point at byte 2 ('x'),
364        // i.e. 1-based byte col 3. This confirms the byte->codepoint mapping:
365        // both bytes of 'é' map to column 0, and 'x' is column 1.
366        let (_src, caret) = build_source_and_caret_line("éx", 3, &[], &OutputOptions::default());
367        assert_eq!(caret, " ^");
368    }
369}