use escriba_shirube::Severity;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GutterRole {
Number,
Mark(Severity),
NoMark,
Breakpoint,
NoBreakpoint,
Separator,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GutterMarks {
pub severity: Option<Severity>,
pub breakpoint: bool,
}
impl GutterMarks {
#[must_use]
pub const fn new(severity: Option<Severity>, breakpoint: bool) -> Self {
Self {
severity,
breakpoint,
}
}
#[must_use]
pub const fn clear() -> Self {
Self::new(None, false)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GutterCell {
pub text: String,
pub role: GutterRole,
}
pub const MIN_GUTTER_WIDTH: usize = 9;
const MIN_NUMBER_WIDTH: usize = 4;
const FURNITURE_WIDTH: usize = 5;
#[must_use]
pub fn gutter_width(line_count: u32) -> usize {
number_width(line_count) + FURNITURE_WIDTH
}
#[must_use]
pub fn number_width(line_count: u32) -> usize {
let digits = line_count.max(1).to_string().len();
digits.max(MIN_NUMBER_WIDTH)
}
#[must_use]
pub fn gutter_cells(line: u32, marks: GutterMarks, line_count: u32) -> Vec<GutterCell> {
let field = number_width(line_count);
let mut number = (line + 1).to_string();
if number.len() < field {
number = " ".repeat(field - number.len()) + &number;
}
vec![
GutterCell {
text: number,
role: GutterRole::Number,
},
GutterCell {
text: " ".to_string(),
role: GutterRole::Number,
},
if marks.breakpoint {
GutterCell {
text: BREAKPOINT_GLYPH.to_string(),
role: GutterRole::Breakpoint,
}
} else {
GutterCell {
text: " ".to_string(),
role: GutterRole::NoBreakpoint,
}
},
match marks.severity {
Some(s) => GutterCell {
text: mark_glyph(s).to_string(),
role: GutterRole::Mark(s),
},
None => GutterCell {
text: " ".to_string(),
role: GutterRole::NoMark,
},
},
GutterCell {
text: "│ ".to_string(),
role: GutterRole::Separator,
},
]
}
#[must_use]
pub const fn mark_glyph(severity: Severity) -> &'static str {
match severity {
Severity::Error => "\u{2716}", Severity::Warning => "\u{25b2}", Severity::Info => "\u{2022}", Severity::Hint => "\u{203a}", }
}
const BREAKPOINT_GLYPH: &str = "\u{25c9}";
#[must_use]
pub const fn breakpoint_glyph() -> &'static str {
BREAKPOINT_GLYPH
}
#[must_use]
pub fn gutter_text(line: u32, marks: GutterMarks, line_count: u32) -> String {
gutter_cells(line, marks, line_count)
.into_iter()
.map(|c| c.text)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_line_of_one_buffer_gets_the_same_width() {
for line_count in [1u32, 9, 10, 999, 1_000, 9_999, 10_000, 123_456] {
let want = gutter_width(line_count);
assert!(want >= MIN_GUTTER_WIDTH, "{line_count}: {want} below floor");
for line in [0, line_count / 2, line_count.saturating_sub(1)] {
for severity in [None, Some(Severity::Error), Some(Severity::Hint)] {
for breakpoint in [false, true] {
let marks = GutterMarks::new(severity, breakpoint);
let w = gutter_text(line, marks, line_count).chars().count();
assert_eq!(
w, want,
"buffer of {line_count} lines: line {line} marks \
{marks:?} rendered {w} columns, not {want}",
);
}
}
}
}
}
#[test]
fn the_last_line_of_a_buffer_always_fits_its_field() {
for line_count in [9u32, 10, 99, 100, 9_999, 10_000, 100_000] {
let label = line_count.to_string();
assert!(
label.len() <= number_width(line_count),
"a {line_count}-line buffer must fit the label {label:?}",
);
assert_eq!(
gutter_text(line_count - 1, GutterMarks::clear(), line_count)
.chars()
.count(),
gutter_width(line_count),
"the LAST line must not be wider than every other one",
);
}
}
#[test]
fn a_small_buffer_still_gets_the_floor() {
assert_eq!(gutter_width(3), MIN_GUTTER_WIDTH);
assert_eq!(gutter_width(9_999), MIN_GUTTER_WIDTH);
assert_eq!(gutter_width(10_000), MIN_GUTTER_WIDTH + 1);
}
#[test]
fn every_severity_has_a_distinct_glyph() {
let all = [
Severity::Error,
Severity::Warning,
Severity::Info,
Severity::Hint,
];
let mut seen = std::collections::BTreeSet::new();
for s in all {
assert!(seen.insert(mark_glyph(s)), "{s:?} duplicates another mark");
}
}
#[test]
fn no_mark_collides_with_a_fleet_signal() {
let fleet = ['\u{25c6}', '\u{25b8}', '\u{25ae}', '\u{25cf}'];
for s in [
Severity::Error,
Severity::Warning,
Severity::Info,
Severity::Hint,
] {
let g = mark_glyph(s).chars().next().expect("one glyph");
assert!(!fleet.contains(&g), "{s:?} reuses a fleet signal glyph");
}
}
#[test]
fn the_mark_sits_between_the_number_and_the_rule() {
let cells = gutter_cells(0, GutterMarks::new(Some(Severity::Error), false), 40);
let roles: Vec<GutterRole> = cells.iter().map(|c| c.role).collect();
assert_eq!(roles[0], GutterRole::Number);
assert_eq!(roles[2], GutterRole::NoBreakpoint);
assert_eq!(roles[3], GutterRole::Mark(Severity::Error));
assert_eq!(roles[4], GutterRole::Separator);
}
#[test]
fn a_breakpoint_and_an_error_on_one_line_are_both_visible() {
let text = gutter_text(0, GutterMarks::new(Some(Severity::Error), true), 40);
assert!(
text.contains(breakpoint_glyph()),
"the breakpoint must be painted: {text:?}",
);
assert!(
text.contains(mark_glyph(Severity::Error)),
"and the error must STILL be painted: {text:?}",
);
}
#[test]
fn the_breakpoint_glyph_collides_with_nothing_the_reader_already_knows() {
let g = breakpoint_glyph().chars().next().expect("one glyph");
for s in [
Severity::Error,
Severity::Warning,
Severity::Info,
Severity::Hint,
] {
assert_ne!(
breakpoint_glyph(),
mark_glyph(s),
"the breakpoint reuses {s:?}'s mark",
);
}
let fleet = ['\u{25c6}', '\u{25b8}', '\u{25ae}', '\u{25cf}'];
assert!(
!fleet.contains(&g),
"the breakpoint reuses a fleet signal glyph",
);
assert_eq!(
breakpoint_glyph().chars().count(),
1,
"the breakpoint cell is ONE column",
);
}
#[test]
fn a_line_with_no_breakpoint_still_reserves_its_column() {
let cells = gutter_cells(0, GutterMarks::clear(), 40);
assert_eq!(cells[2].role, GutterRole::NoBreakpoint);
assert_eq!(cells[2].text, " ");
assert_eq!(
cells.iter().map(|c| c.text.chars().count()).sum::<usize>(),
gutter_width(40),
);
}
}