use std::{
borrow::Cow,
io,
iter::FusedIterator,
num::NonZeroUsize,
ops::{Bound, RangeBounds},
};
use biome_console::{fmt, markup};
use biome_text_size::{TextLen, TextRange, TextSize};
use unicode_width::UnicodeWidthChar;
use crate::{
location::{BorrowedSourceCode, LineIndex},
LineIndexBuf, Location,
};
const fn unwrap<T: Copy>(option: Option<T>) -> T {
match option {
Some(value) => value,
None => panic!("unwrapping None"),
}
}
const ONE: NonZeroUsize = unwrap(NonZeroUsize::new(1));
pub(super) const CODE_FRAME_CONTEXT_LINES: NonZeroUsize = unwrap(NonZeroUsize::new(2));
const MAX_CODE_FRAME_LINES: usize = 8;
const HALF_MAX_CODE_FRAME_LINES: usize = MAX_CODE_FRAME_LINES / 2;
pub(super) fn print_frame(fmt: &mut fmt::Formatter<'_>, location: Location<'_>) -> io::Result<()> {
let source_span = location
.source_code
.and_then(|source_code| Some((source_code, location.span?)));
let (source_code, span) = match source_span {
Some(source_span) => source_span,
None => return Ok(()),
};
let source_file = SourceFile::new(source_code);
let start_index = span.start();
let start_location = match source_file.location(start_index) {
Ok(location) => location,
Err(_) => return Ok(()),
};
let end_index = span.end();
let end_location = match source_file.location(end_index) {
Ok(location) => location,
Err(_) => return Ok(()),
};
let context_start = start_location
.line_number
.saturating_sub(CODE_FRAME_CONTEXT_LINES.get());
let mut context_end = end_location
.line_number
.saturating_add(CODE_FRAME_CONTEXT_LINES.get())
.min(OneIndexed::new(source_file.line_starts.len()).unwrap_or(OneIndexed::MIN));
for line_index in IntoIter::new(context_start..=context_end).rev() {
if line_index == end_location.line_number {
break;
}
let line_start = match source_file.line_start(line_index.to_zero_indexed()) {
Ok(index) => index,
Err(_) => continue,
};
let line_end = match source_file.line_start(line_index.to_zero_indexed() + 1) {
Ok(index) => index,
Err(_) => continue,
};
let line_range = TextRange::new(line_start, line_end);
let line_text = source_file.source[line_range].trim();
if !line_text.is_empty() {
break;
}
context_end = line_index;
}
let range_len = (context_end.get() + 1).saturating_sub(context_start.get());
let ellipsis_range = if range_len > MAX_CODE_FRAME_LINES + 2 {
let ellipsis_start = context_start.saturating_add(HALF_MAX_CODE_FRAME_LINES);
let ellipsis_end = context_end.saturating_sub(HALF_MAX_CODE_FRAME_LINES);
Some(ellipsis_start..=ellipsis_end)
} else {
None
};
let max_gutter_len = calculate_print_width(context_end);
let mut printed_lines = false;
for line_index in IntoIter::new(context_start..=context_end) {
if let Some(ellipsis_range) = &ellipsis_range {
if ellipsis_range.contains(&line_index) {
if *ellipsis_range.start() == line_index {
for _ in 0..max_gutter_len.get() {
fmt.write_str(" ")?;
}
fmt.write_markup(markup! { <Emphasis>" ...\n"</Emphasis> })?;
printed_lines = true;
}
continue;
}
}
let line_start = match source_file.line_start(line_index.to_zero_indexed()) {
Ok(index) => index,
Err(_) => continue,
};
let line_end = match source_file.line_start(line_index.to_zero_indexed() + 1) {
Ok(index) => index,
Err(_) => continue,
};
let line_range = TextRange::new(line_start, line_end);
let line_text = source_file.source[line_range].trim_end_matches(['\r', '\n']);
if !printed_lines && line_index != start_location.line_number && line_text.trim().is_empty()
{
continue;
}
printed_lines = true;
let should_highlight =
line_index >= start_location.line_number && line_index <= end_location.line_number;
let padding_width = max_gutter_len
.get()
.saturating_sub(calculate_print_width(line_index).get());
for _ in 0..padding_width {
fmt.write_str(" ")?;
}
if should_highlight {
fmt.write_markup(markup! {
<Emphasis><Error>'>'</Error></Emphasis>' '
})?;
} else {
fmt.write_str(" ")?;
}
fmt.write_markup(markup! {
<Emphasis>{format_args!("{line_index} \u{2502} ")}</Emphasis>
})?;
print_invisibles(
fmt,
line_text,
PrintInvisiblesOptions {
ignore_trailing_carriage_return: true,
ignore_leading_tabs: true,
ignore_lone_spaces: true,
at_line_start: true,
at_line_end: true,
},
)?;
fmt.write_str("\n")?;
if should_highlight {
let is_first_line = line_index == start_location.line_number;
let is_last_line = line_index == end_location.line_number;
let start_index_relative_to_line =
start_index.max(line_range.start()) - line_range.start();
let end_index_relative_to_line = end_index.min(line_range.end()) - line_range.start();
let marker = if is_first_line && is_last_line {
Some(TextRange::new(
start_index_relative_to_line,
end_index_relative_to_line,
))
} else if is_first_line {
Some(TextRange::new(
start_index_relative_to_line,
line_text.text_len(),
))
} else if is_last_line {
let start_index = line_text
.text_len()
.checked_sub(line_text.trim_start().text_len())
.expect("integer overflow");
Some(TextRange::new(start_index, end_index_relative_to_line))
} else {
None
};
if let Some(marker) = marker {
for _ in 0..max_gutter_len.get() {
fmt.write_str(" ")?;
}
fmt.write_markup(markup! {
<Emphasis>" \u{2502} "</Emphasis>
})?;
let leading_range = TextRange::new(TextSize::from(0), marker.start());
for c in line_text[leading_range].chars() {
match c {
'\t' => fmt.write_str("\t")?,
_ => {
for _ in 0..char_width(c) {
fmt.write_str(" ")?;
}
}
}
}
let marker_width = text_width(&line_text[marker]);
for _ in 0..marker_width {
fmt.write_markup(markup! {
<Emphasis><Error>'^'</Error></Emphasis>
})?;
}
fmt.write_str("\n")?;
}
}
}
fmt.write_str("\n")
}
pub(super) fn print_highlighted_frame(
fmt: &mut fmt::Formatter<'_>,
location: Location<'_>,
) -> io::Result<()> {
let Some(span) = location.span else {
return Ok(());
};
let Some(source_code) = location.source_code else {
return Ok(());
};
let source = SourceFile::new(source_code);
let start = source.location(span.start())?;
let end = source.location(span.end())?;
let match_line_start = start.line_number;
let match_line_end = end.line_number.saturating_add(1);
for line_index in IntoIter::new(match_line_start..match_line_end) {
let current_range = source.line_range(line_index.to_zero_indexed());
let current_range = match current_range {
Ok(v) => v,
Err(_) => continue,
};
let current_text = source_code.text[current_range].trim_end_matches(['\r', '\n']);
let is_first_line = line_index == start.line_number;
let is_last_line = line_index == end.line_number;
let start_index_relative_to_line =
span.start().max(current_range.start()) - current_range.start();
let end_index_relative_to_line =
span.end().min(current_range.end()) - current_range.start();
let marker = if is_first_line && is_last_line {
TextRange::new(start_index_relative_to_line, end_index_relative_to_line)
} else if is_last_line {
let start_index: u32 = current_text.text_len().into();
let safe_start_index =
start_index.saturating_sub(current_text.trim_start().text_len().into());
TextRange::new(TextSize::from(safe_start_index), end_index_relative_to_line)
} else {
TextRange::new(start_index_relative_to_line, current_text.text_len())
};
fmt.write_markup(markup! {
<Emphasis>{format_args!("{line_index} \u{2502} ")}</Emphasis>
})?;
let start_range = ¤t_text[0..marker.start().into()];
let highlighted_range = ¤t_text[marker.start().into()..marker.end().into()];
let end_range = ¤t_text[marker.end().into()..current_text.text_len().into()];
write!(fmt, "{start_range}")?;
fmt.write_markup(markup! { <Emphasis><Info>{highlighted_range}</Info></Emphasis> })?;
write!(fmt, "{end_range}")?;
writeln!(fmt)?;
}
Ok(())
}
pub(super) fn calculate_print_width(mut value: OneIndexed) -> NonZeroUsize {
const TEN: OneIndexed = unwrap(OneIndexed::new(10));
let mut width = ONE;
while value >= TEN {
value = OneIndexed::new(value.get() / 10).unwrap_or(OneIndexed::MIN);
width = width.checked_add(1).unwrap();
}
width
}
pub(super) fn text_width(text: &str) -> usize {
text.chars().map(char_width).sum()
}
const TAB_WIDTH: usize = 2;
const ESOTERIC_SPACE_WIDTH: usize = 1;
pub(super) fn char_width(char: char) -> usize {
match char {
'\t' => TAB_WIDTH,
'\u{c}' => ESOTERIC_SPACE_WIDTH,
'\u{b}' => ESOTERIC_SPACE_WIDTH,
'\u{85}' => ESOTERIC_SPACE_WIDTH,
'\u{feff}' => ESOTERIC_SPACE_WIDTH,
'\u{180e}' => ESOTERIC_SPACE_WIDTH,
'\u{200b}' => ESOTERIC_SPACE_WIDTH,
'\u{3000}' => ESOTERIC_SPACE_WIDTH,
_ => char.width().unwrap_or(0),
}
}
pub(super) struct PrintInvisiblesOptions {
pub(super) ignore_leading_tabs: bool,
pub(super) ignore_lone_spaces: bool,
pub(super) ignore_trailing_carriage_return: bool,
pub(super) at_line_start: bool,
pub(super) at_line_end: bool,
}
pub(super) fn print_invisibles(
fmt: &mut fmt::Formatter<'_>,
input: &str,
options: PrintInvisiblesOptions,
) -> io::Result<bool> {
let mut had_non_whitespace = false;
let trailing_whitespace_index = input
.bytes()
.enumerate()
.rev()
.find(|(_, byte)| !byte.is_ascii_whitespace())
.map_or(input.len(), |(index, _)| index);
let mut iter = input.char_indices().peekable();
let mut prev_char_was_whitespace = false;
while let Some((i, char)) = iter.next() {
let mut show_invisible = true;
if char == ' ' && options.ignore_lone_spaces {
show_invisible = false;
let next_char_is_whitespace = iter
.peek()
.map_or(false, |(_, char)| char.is_ascii_whitespace());
if prev_char_was_whitespace || next_char_is_whitespace {
show_invisible = false;
}
}
prev_char_was_whitespace = char.is_ascii_whitespace();
if options.at_line_start
&& !had_non_whitespace
&& char == '\t'
&& options.ignore_leading_tabs
{
show_invisible = false;
}
if options.at_line_end && i >= trailing_whitespace_index {
show_invisible = true;
}
if options.ignore_trailing_carriage_return && char == '\r' {
let next_char_is_line_feed = iter.peek().map_or(false, |(_, char)| *char == '\n');
if next_char_is_line_feed {
continue;
}
}
if !show_invisible {
if !char.is_ascii_whitespace() {
had_non_whitespace = true;
}
write!(fmt, "{char}")?;
continue;
}
if let Some(visible) = show_invisible_char(char) {
fmt.write_markup(markup! { <Dim>{visible}</Dim> })?;
continue;
}
if (char.is_whitespace() && !char.is_ascii_whitespace()) || char.is_control() {
let code = u32::from(char);
fmt.write_markup(markup! { <Inverse>"U+"{format_args!("{code:x}")}</Inverse> })?;
continue;
}
write!(fmt, "{char}")?;
}
Ok(had_non_whitespace)
}
fn show_invisible_char(char: char) -> Option<&'static str> {
match char {
' ' => Some("\u{b7}"), '\r' => Some("\u{240d}"), '\n' => Some("\u{23ce}"), '\t' => Some("\u{2192} "), '\0' => Some("\u{2400}"), '\x0b' => Some("\u{240b}"), '\x08' => Some("\u{232b}"), '\x0c' => Some("\u{21a1}"), '\u{85}' => Some("\u{2420}"), '\u{a0}' => Some("\u{2420}"), '\u{1680}' => Some("\u{2420}"), '\u{2000}' => Some("\u{2420}"), '\u{2001}' => Some("\u{2420}"), '\u{2002}' => Some("\u{2420}"), '\u{2003}' => Some("\u{2420}"), '\u{2004}' => Some("\u{2420}"), '\u{2005}' => Some("\u{2420}"), '\u{2006}' => Some("\u{2420}"), '\u{2007}' => Some("\u{2420}"), '\u{2008}' => Some("\u{2420}"), '\u{2009}' => Some("\u{2420}"), '\u{200a}' => Some("\u{2420}"), '\u{202f}' => Some("\u{2420}"), '\u{205f}' => Some("\u{2420}"), '\u{3000}' => Some("\u{2420}"), _ => None,
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub struct SourceLocation {
pub line_number: OneIndexed,
pub column_number: OneIndexed,
}
#[derive(Clone)]
pub struct SourceFile<'diagnostic> {
source: &'diagnostic str,
line_starts: Cow<'diagnostic, LineIndex>,
}
impl<'diagnostic> SourceFile<'diagnostic> {
pub fn new(source_code: BorrowedSourceCode<'diagnostic>) -> Self {
Self {
source: source_code.text,
line_starts: source_code.line_starts.map_or_else(
|| Cow::Owned(LineIndexBuf::from_source_text(source_code.text)),
Cow::Borrowed,
),
}
}
fn line_start(&self, line_index: usize) -> io::Result<TextSize> {
use std::cmp::Ordering;
match line_index.cmp(&self.line_starts.len()) {
Ordering::Less => Ok(self
.line_starts
.get(line_index)
.copied()
.expect("failed despite previous check")),
Ordering::Equal => Ok(self.source.text_len()),
Ordering::Greater => Err(io::Error::new(
io::ErrorKind::InvalidInput,
"overflow error",
)),
}
}
fn line_index(&self, byte_index: TextSize) -> usize {
self.line_starts
.binary_search(&byte_index)
.unwrap_or_else(|next_line| next_line - 1)
}
fn line_range(&self, line_index: usize) -> io::Result<TextRange> {
let line_start = self.line_start(line_index)?;
let next_line_start = self.line_start(line_index + 1)?;
Ok(TextRange::new(line_start, next_line_start))
}
fn line_number(&self, line_index: usize) -> OneIndexed {
OneIndexed::from_zero_indexed(line_index)
}
fn column_number(&self, line_index: usize, byte_index: TextSize) -> io::Result<OneIndexed> {
let source = self.source;
let line_range = self.line_range(line_index)?;
let column_index = column_index(source, line_range, byte_index);
Ok(OneIndexed::from_zero_indexed(column_index))
}
pub fn location(&self, byte_index: TextSize) -> io::Result<SourceLocation> {
let line_index = self.line_index(byte_index);
Ok(SourceLocation {
line_number: self.line_number(line_index),
column_number: self.column_number(line_index, byte_index)?,
})
}
}
fn column_index(source: &str, line_range: TextRange, byte_index: TextSize) -> usize {
let end_index = std::cmp::min(
byte_index,
std::cmp::min(line_range.end(), source.text_len()),
);
(usize::from(line_range.start())..usize::from(end_index))
.filter(|byte_index| source.is_char_boundary(byte_index + 1))
.count()
}
#[repr(transparent)]
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct OneIndexed(NonZeroUsize);
impl OneIndexed {
pub const MIN: Self = unwrap(Self::new(1));
pub const MAX: Self = unwrap(Self::new(usize::MAX));
pub const fn new(value: usize) -> Option<Self> {
match NonZeroUsize::new(value) {
Some(value) => Some(Self(value)),
None => None,
}
}
pub const fn from_zero_indexed(value: usize) -> Self {
Self(ONE.saturating_add(value))
}
pub const fn get(self) -> usize {
self.0.get()
}
pub const fn to_zero_indexed(self) -> usize {
self.0.get() - 1
}
pub const fn saturating_add(self, rhs: usize) -> Self {
match NonZeroUsize::new(self.0.get().saturating_add(rhs)) {
Some(value) => Self(value),
None => Self::MAX,
}
}
pub const fn saturating_sub(self, rhs: usize) -> Self {
match NonZeroUsize::new(self.0.get().saturating_sub(rhs)) {
Some(value) => Self(value),
None => Self::MIN,
}
}
}
impl fmt::Display for OneIndexed {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> io::Result<()> {
self.0.get().fmt(f)
}
}
impl std::fmt::Display for OneIndexed {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.get().fmt(f)
}
}
pub struct IntoIter(std::ops::Range<usize>);
impl IntoIter {
pub fn new<R: RangeBounds<OneIndexed>>(range: R) -> Self {
let start = match range.start_bound() {
Bound::Included(value) => value.get(),
Bound::Excluded(value) => value.get() + 1,
Bound::Unbounded => 1,
};
let end = match range.end_bound() {
Bound::Included(value) => value.get() + 1,
Bound::Excluded(value) => value.get(),
Bound::Unbounded => usize::MAX,
};
Self(start..end)
}
}
impl Iterator for IntoIter {
type Item = OneIndexed;
fn next(&mut self) -> Option<Self::Item> {
self.0.next().map(|index| OneIndexed::new(index).unwrap())
}
fn size_hint(&self) -> (usize, Option<usize>) {
self.0.size_hint()
}
}
impl DoubleEndedIterator for IntoIter {
fn next_back(&mut self) -> Option<Self::Item> {
self.0
.next_back()
.map(|index| OneIndexed::new(index).unwrap())
}
}
impl FusedIterator for IntoIter {}
#[cfg(test)]
mod tests {
use std::num::NonZeroUsize;
use super::{calculate_print_width, OneIndexed};
#[test]
fn print_width() {
let one = NonZeroUsize::new(1).unwrap();
let two = NonZeroUsize::new(2).unwrap();
let three = NonZeroUsize::new(3).unwrap();
let four = NonZeroUsize::new(4).unwrap();
assert_eq!(calculate_print_width(OneIndexed::new(1).unwrap()), one);
assert_eq!(calculate_print_width(OneIndexed::new(9).unwrap()), one);
assert_eq!(calculate_print_width(OneIndexed::new(10).unwrap()), two);
assert_eq!(calculate_print_width(OneIndexed::new(11).unwrap()), two);
assert_eq!(calculate_print_width(OneIndexed::new(19).unwrap()), two);
assert_eq!(calculate_print_width(OneIndexed::new(20).unwrap()), two);
assert_eq!(calculate_print_width(OneIndexed::new(21).unwrap()), two);
assert_eq!(calculate_print_width(OneIndexed::new(99).unwrap()), two);
assert_eq!(calculate_print_width(OneIndexed::new(100).unwrap()), three);
assert_eq!(calculate_print_width(OneIndexed::new(101).unwrap()), three);
assert_eq!(calculate_print_width(OneIndexed::new(110).unwrap()), three);
assert_eq!(calculate_print_width(OneIndexed::new(199).unwrap()), three);
assert_eq!(calculate_print_width(OneIndexed::new(999).unwrap()), three);
assert_eq!(calculate_print_width(OneIndexed::new(1000).unwrap()), four);
}
}