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