| 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 | use crate::{CompilerDiagnosticDetail, CompilerError, CompilerErrorOrDiagnostic}; |
| 8 | |
| 9 | const CODEFRAME_LINES_ABOVE: u32 = 2; |
| 10 | const CODEFRAME_LINES_BELOW: u32 = 3; |
| 11 | const CODEFRAME_MAX_LINES: u32 = 10; |
| 12 | const CODEFRAME_ABBREVIATED_SOURCE_LINES: usize = 5; |
| 13 | |
| 14 | /// Split source text on newlines, matching Babel's NEWLINE regex: /\r\n|[\n\r\u2028\u2029]/ |
| 15 | fn 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)] |
| 57 | enum 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). |
| 64 | fn 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). |
| 132 | pub 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. |
| 283 | pub 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 | |
| 321 | use 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. |
| 329 | pub 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). |
| 348 | fn 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 | } |