Skip to main content

react_compiler_diagnostics/
code_frame.rs

1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7use crate::{CompilerDiagnosticDetail, CompilerError, CompilerErrorOrDiagnostic};
8
9const CODEFRAME_LINES_ABOVE: u32 = 2;
10const CODEFRAME_LINES_BELOW: u32 = 3;
11const CODEFRAME_MAX_LINES: u32 = 10;
12const CODEFRAME_ABBREVIATED_SOURCE_LINES: usize = 5;
13
14/// Split source text on newlines, matching Babel's NEWLINE regex: /\r\n|[\n\r\u2028\u2029]/
15fn split_lines(source: &str) -> Vec<&str> {
16    let mut lines = Vec::new();
17    let mut start = 0;
18    let bytes = source.as_bytes();
19    let len = bytes.len();
20    let mut i = 0;
21    while i < len {
22        let ch = bytes[i];
23        if ch == b'\r' {
24            lines.push(&source[start..i]);
25            if i + 1 < len && bytes[i + 1] == b'\n' {
26                i += 2;
27            } else {
28                i += 1;
29            }
30            start = i;
31        } else if ch == b'\n' {
32            lines.push(&source[start..i]);
33            i += 1;
34            start = i;
35        } else {
36            // Check for Unicode line separators U+2028 and U+2029
37            // These are encoded as E2 80 A8 and E2 80 A9 in UTF-8
38            if ch == 0xE2
39                && i + 2 < len
40                && bytes[i + 1] == 0x80
41                && (bytes[i + 2] == 0xA8 || bytes[i + 2] == 0xA9)
42            {
43                lines.push(&source[start..i]);
44                i += 3;
45                start = i;
46            } else {
47                i += 1;
48            }
49        }
50    }
51    lines.push(&source[start..]);
52    lines
53}
54
55/// Represents a marker line entry: either mark the whole line (true) or a [column, length] range.
56#[derive(Clone, Debug)]
57enum MarkerEntry {
58    WholeLine,
59    Range(usize, usize), // (start_column_1based, length)
60}
61
62/// Compute marker lines matching Babel's getMarkerLines().
63/// All column values here are 1-based (Babel convention).
64fn get_marker_lines(
65    start_line: u32,
66    start_column: u32, // 1-based
67    end_line: u32,
68    end_column: u32, // 1-based
69    source_line_count: usize,
70    lines_above: u32,
71    lines_below: u32,
72) -> (usize, usize, Vec<(usize, MarkerEntry)>) {
73    let start_line = start_line as usize;
74    let end_line = end_line as usize;
75    let start_column = start_column as usize;
76    let end_column = end_column as usize;
77
78    // Compute display range
79    let start = if start_line > (lines_above as usize + 1) {
80        start_line - (lines_above as usize + 1)
81    } else {
82        0
83    };
84    let end = std::cmp::min(source_line_count, end_line + lines_below as usize);
85
86    let line_diff = end_line - start_line;
87    let mut marker_lines: Vec<(usize, MarkerEntry)> = Vec::new();
88
89    if line_diff > 0 {
90        // Multi-line error
91        for i in 0..=line_diff {
92            let line_number = i + start_line;
93            if start_column == 0 {
94                marker_lines.push((line_number, MarkerEntry::WholeLine));
95            } else if i == 0 {
96                // First line: from start_column to end of source line
97                // source[lineNumber - 1] gives us the source line (0-indexed array, 1-indexed line numbers)
98                // But we don't have access to source lines here, so we pass the length through.
99                // Actually, Babel accesses source[lineNumber - 1].length. We need to thread source lines.
100                // For now, this is handled in code_frame_columns where we have access to source lines.
101                // We use a placeholder that will be filled in later.
102                marker_lines.push((line_number, MarkerEntry::Range(start_column, 0))); // 0 = placeholder
103            } else if i == line_diff {
104                marker_lines.push((line_number, MarkerEntry::Range(0, end_column)));
105            } else {
106                marker_lines.push((line_number, MarkerEntry::Range(0, 0))); // 0 = placeholder for full line
107            }
108        }
109    } else {
110        // Single-line error
111        if start_column == end_column {
112            if start_column != 0 {
113                marker_lines.push((start_line, MarkerEntry::Range(start_column, 0)));
114            } else {
115                marker_lines.push((start_line, MarkerEntry::WholeLine));
116            }
117        } else {
118            marker_lines.push((
119                start_line,
120                MarkerEntry::Range(start_column, end_column - start_column),
121            ));
122        }
123    }
124
125    (start, end, marker_lines)
126}
127
128/// Produce a code frame matching @babel/code-frame's codeFrameColumns() in non-highlighted mode.
129///
130/// Columns are 0-based (matching the Rust/AST convention). They are converted to 1-based
131/// internally to match Babel's convention (the JS caller already does column + 1).
132pub fn code_frame_columns(
133    source: &str,
134    start_line: u32,
135    start_col: u32,
136    end_line: u32,
137    end_col: u32,
138    message: &str,
139) -> String {
140    // Convert 0-based columns to 1-based (Babel convention)
141    let start_column_1 = start_col + 1;
142    let end_column_1 = end_col + 1;
143
144    let lines = split_lines(source);
145    let source_line_count = lines.len();
146
147    let (start, end, marker_lines_raw) = get_marker_lines(
148        start_line,
149        start_column_1,
150        end_line,
151        end_column_1,
152        source_line_count,
153        CODEFRAME_LINES_ABOVE,
154        CODEFRAME_LINES_BELOW,
155    );
156
157    let has_columns = start_column_1 > 0;
158    let number_max_width = format!("{}", end).len();
159
160    // Build a lookup map for marker lines
161    let mut marker_map: rustc_hash::FxHashMap<usize, MarkerEntry> =
162        rustc_hash::FxHashMap::default();
163    let line_diff = end_line as usize - start_line as usize;
164    for (line_number, entry) in marker_lines_raw {
165        // Resolve placeholder lengths using actual source lines
166        let resolved = match &entry {
167            MarkerEntry::Range(col, len) => {
168                if line_diff > 0 {
169                    let i = line_number - start_line as usize;
170                    if i == 0 && *len == 0 {
171                        // First line of multi-line: from start_column to end of line
172                        let source_length = if line_number >= 1 && line_number <= lines.len() {
173                            lines[line_number - 1].len()
174                        } else {
175                            0
176                        };
177                        MarkerEntry::Range(*col, source_length.saturating_sub(*col) + 1)
178                    } else if i > 0 && i < line_diff && *col == 0 && *len == 0 {
179                        // Middle line of multi-line: Babel uses source[lineNumber - i].length
180                        // which evaluates to source[startLine] (0-indexed array, 1-indexed line number).
181                        // This means all middle lines use the length of source[startLine],
182                        // which is the line at 0-indexed position startLine in the source array.
183                        let source_length = if (start_line as usize) < lines.len() {
184                            lines[start_line as usize].len()
185                        } else {
186                            0
187                        };
188                        MarkerEntry::Range(0, source_length)
189                    } else {
190                        entry
191                    }
192                } else {
193                    entry
194                }
195            }
196            _ => entry,
197        };
198        marker_map.insert(line_number, resolved);
199    }
200
201    // Build frame lines
202    let mut frame_parts: Vec<String> = Vec::new();
203    let display_lines = &lines[start..end];
204
205    for (index, line) in display_lines.iter().enumerate() {
206        let number = start + 1 + index;
207        // Right-align the line number: ` ${number}`.slice(-numberMaxWidth)
208        let number_str = format!("{}", number);
209        let padded_number = if number_str.len() >= number_max_width {
210            number_str
211        } else {
212            let padding = " ".repeat(number_max_width - number_str.len());
213            format!("{}{}", padding, number_str)
214        };
215        let gutter = format!(" {} |", padded_number);
216
217        let has_marker = marker_map.get(&number);
218        let has_next_marker = marker_map.contains_key(&(number + 1));
219        let last_marker_line = has_marker.is_some() && !has_next_marker;
220
221        if let Some(marker_entry) = has_marker {
222            // This is a marked line
223            let line_content = if line.is_empty() {
224                String::new()
225            } else {
226                format!(" {}", line)
227            };
228
229            let marker_line_str = match marker_entry {
230                MarkerEntry::Range(col, len) => {
231                    // Build marker spacing: replace non-tab chars with spaces
232                    let max_col = if *col > 0 { col - 1 } else { 0 };
233                    let byte_end = std::cmp::min(max_col, line.len());
234                    // Ensure we don't slice in the middle of a multi-byte UTF-8 character
235                    let safe_end = if byte_end < line.len() && !line.is_char_boundary(byte_end) {
236                        line.floor_char_boundary(byte_end)
237                    } else {
238                        byte_end
239                    };
240                    let prefix = &line[..safe_end];
241                    let marker_spacing: String = prefix
242                        .chars()
243                        .map(|c| if c == '\t' { '\t' } else { ' ' })
244                        .collect();
245                    let number_of_markers = if *len == 0 { 1 } else { *len };
246                    let carets = "^".repeat(number_of_markers);
247                    let gutter_spaces = gutter.replace(|c: char| c.is_ascii_digit(), " ");
248                    let mut marker_str =
249                        format!("\n {} {}{}", gutter_spaces, marker_spacing, carets);
250                    if last_marker_line && !message.is_empty() {
251                        marker_str.push(' ');
252                        marker_str.push_str(message);
253                    }
254                    marker_str
255                }
256                MarkerEntry::WholeLine => String::new(),
257            };
258
259            frame_parts.push(format!(">{}{}{}", gutter, line_content, marker_line_str));
260        } else {
261            // Non-marked line
262            let line_content = if line.is_empty() {
263                String::new()
264            } else {
265                format!(" {}", line)
266            };
267            frame_parts.push(format!(" {}{}", gutter, line_content));
268        }
269    }
270
271    let mut frame = frame_parts.join("\n");
272
273    // If message is set but no columns, prepend the message
274    if !message.is_empty() && !has_columns {
275        frame = format!("{}{}\n{}", " ".repeat(number_max_width + 1), message, frame);
276    }
277
278    frame
279}
280
281/// Format a code frame with abbreviation for long spans,
282/// matching the JS printCodeFrame() function.
283pub fn print_code_frame(
284    source: &str,
285    start_line: u32,
286    start_col: u32,
287    end_line: u32,
288    end_col: u32,
289    message: &str,
290) -> String {
291    let printed = code_frame_columns(source, start_line, start_col, end_line, end_col, message);
292
293    if end_line - start_line < CODEFRAME_MAX_LINES {
294        return printed;
295    }
296
297    // Abbreviate: truncate middle
298    let lines: Vec<&str> = printed.split('\n').collect();
299    let head_count = CODEFRAME_LINES_ABOVE as usize + CODEFRAME_ABBREVIATED_SOURCE_LINES;
300    let tail_count = CODEFRAME_LINES_BELOW as usize + CODEFRAME_ABBREVIATED_SOURCE_LINES;
301
302    if lines.len() <= head_count + tail_count {
303        return printed;
304    }
305
306    // Find the pipe index from the first line
307    let pipe_index = lines[0].find('|').unwrap_or(0);
308    let tail_start = lines.len() - tail_count;
309
310    let mut parts: Vec<String> = Vec::new();
311    for line in &lines[..head_count] {
312        parts.push(line.to_string());
313    }
314    parts.push(format!("{}\u{2026}", " ".repeat(pipe_index)));
315    for line in &lines[tail_start..] {
316        parts.push(line.to_string());
317    }
318    parts.join("\n")
319}
320
321use crate::format_category_heading;
322
323/// Format a CompilerError into a message string matching the TS compiler's
324/// CompilerError.printErrorMessage() / formatCompilerError() format.
325///
326/// The source parameter is the full source code of the file being compiled.
327/// The filename parameter is the source filename (e.g., "foo.ts") used in
328/// location displays.
329pub fn format_compiler_error(err: &CompilerError, source: &str, filename: Option<&str>) -> String {
330    let detail_messages: Vec<String> = err
331        .details
332        .iter()
333        .map(|d| format_error_detail(d, source, filename))
334        .collect();
335
336    let count = err.details.len();
337    let plural = if count == 1 { "" } else { "s" };
338    let header = format!("Found {} error{}:\n\n", count, plural);
339
340    let trimmed: Vec<String> = detail_messages
341        .iter()
342        .map(|m| m.trim().to_string())
343        .collect();
344    format!("{}{}", header, trimmed.join("\n\n"))
345}
346
347/// Format a single error detail (either Diagnostic or ErrorDetail).
348fn format_error_detail(
349    detail: &CompilerErrorOrDiagnostic,
350    source: &str,
351    filename: Option<&str>,
352) -> String {
353    match detail {
354        CompilerErrorOrDiagnostic::Diagnostic(d) => {
355            let heading = format_category_heading(d.category);
356            let mut buffer = vec![format!("{}: {}", heading, d.reason)];
357
358            if let Some(ref description) = d.description {
359                buffer.push(format!("\n\n{}.", description));
360            }
361            for item in &d.details {
362                match item {
363                    CompilerDiagnosticDetail::Error { loc, message, .. } => {
364                        if let Some(loc) = loc {
365                            let frame = print_code_frame(
366                                source,
367                                loc.start.line,
368                                loc.start.column,
369                                loc.end.line,
370                                loc.end.column,
371                                message.as_deref().unwrap_or(""),
372                            );
373                            buffer.push("\n\n".to_string());
374                            if let Some(fname) = filename {
375                                buffer.push(format!(
376                                    "{}:{}:{}\n",
377                                    fname, loc.start.line, loc.start.column
378                                ));
379                            }
380                            buffer.push(frame);
381                        }
382                    }
383                    CompilerDiagnosticDetail::Hint { message } => {
384                        buffer.push("\n\n".to_string());
385                        buffer.push(message.clone());
386                    }
387                }
388            }
389
390            buffer.join("")
391        }
392        CompilerErrorOrDiagnostic::ErrorDetail(d) => {
393            let heading = format_category_heading(d.category);
394            let mut buffer = vec![format!("{}: {}", heading, d.reason)];
395
396            if let Some(ref description) = d.description {
397                buffer.push(format!("\n\n{}.", description));
398                if let Some(ref loc) = d.loc {
399                    let frame = print_code_frame(
400                        source,
401                        loc.start.line,
402                        loc.start.column,
403                        loc.end.line,
404                        loc.end.column,
405                        &d.reason,
406                    );
407                    buffer.push("\n\n".to_string());
408                    if let Some(fname) = filename {
409                        buffer.push(format!(
410                            "{}:{}:{}\n",
411                            fname, loc.start.line, loc.start.column
412                        ));
413                    }
414                    buffer.push(frame);
415                    buffer.push("\n\n".to_string());
416                }
417            } else if let Some(ref loc) = d.loc {
418                let frame = print_code_frame(
419                    source,
420                    loc.start.line,
421                    loc.start.column,
422                    loc.end.line,
423                    loc.end.column,
424                    &d.reason,
425                );
426                buffer.push("\n\n".to_string());
427                if let Some(fname) = filename {
428                    buffer.push(format!(
429                        "{}:{}:{}\n",
430                        fname, loc.start.line, loc.start.column
431                    ));
432                }
433                buffer.push(frame);
434                buffer.push("\n\n".to_string());
435            }
436
437            buffer.join("")
438        }
439    }
440}