From 2dbcf999627b4163518d8936e703d0507b6e5f52 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sun, 18 Oct 2026 21:42:00 +0200 Subject: [PATCH] uucore: draw diagnostics against any line of text Add SourceLine, for utilities whose instructions come from a script or a pattern file rather than the command line (sed, grep), and share the ariadne drawing with Snapshot. Also make use_color() public, decide enabled() once, and keep ariadne's "Help" when no localizer is set up. --- src/uucore/src/lib/features/diagnostics.rs | 225 ++++++++++++++++-- .../src/lib/features/diagnostics_stub.rs | 28 +++ 2 files changed, 235 insertions(+), 18 deletions(-) diff --git a/src/uucore/src/lib/features/diagnostics.rs b/src/uucore/src/lib/features/diagnostics.rs index 791854a00bf..4f7359b2da9 100644 --- a/src/uucore/src/lib/features/diagnostics.rs +++ b/src/uucore/src/lib/features/diagnostics.rs @@ -93,13 +93,18 @@ impl Mode { /// a script or a pipe, where whatever reads stderr gets the plain message it /// can grep for. pub fn enabled() -> bool { - // Read once: this runs before the argument capture of every caret. - static MODE: OnceLock = OnceLock::new(); - match MODE.get_or_init(|| Mode::from_env(env::var_os(MODE_VAR).as_deref())) { + // Decided once: a utility may ask for every location it records. + static ENABLED: OnceLock = OnceLock::new(); + *ENABLED.get_or_init(|| match Mode::from_env(env::var_os(MODE_VAR).as_deref()) { Mode::Always => true, Mode::Never => false, Mode::Auto => std::io::stderr().is_terminal(), - } + }) +} + +/// Whether a drawn report is colored: at a terminal, unless `NO_COLOR` is set. +pub fn use_color() -> bool { + env::var_os("NO_COLOR").is_none() && std::io::stderr().is_terminal() } /// Keep the arguments a diagnostic would point at, as they were typed. @@ -670,7 +675,8 @@ impl Snapshot { /// * `has_help` - Whether an advice line was asked for at all. fn localize_help(body: &str, has_help: bool) -> Cow<'_, str> { let localized = translate!("diagnostics-help-label"); - if !has_help || localized == "Help" { + // Without a localizer, the lookup hands back the id. + if !has_help || localized == "Help" || localized == "diagnostics-help-label" { return Cow::Borrowed(body); } let mut lines: Vec> = body.lines().map(Cow::Borrowed).collect(); @@ -713,13 +719,13 @@ impl Snapshot { /// `None` when ariadne could not render, in which case the caller falls /// back to the plain one-line message. fn render_body( - &self, + id: &str, + text: &str, span: Range, label: Option<&str>, help: Option<&str>, color: bool, ) -> Option { - let id = crate::util_name(); let config = Config::default() // ariadne counts characters unless told otherwise, which would drift // on multi-byte arguments. @@ -743,7 +749,7 @@ impl Snapshot { let mut rendered = Vec::new(); if report .finish() - .write((id, Source::from(self.text.as_str())), &mut rendered) + .write((id, Source::from(text)), &mut rendered) .is_err() { return None; @@ -769,14 +775,26 @@ impl Snapshot { help: Option<&str>, ) -> bool { let id = crate::util_name(); - let color = env::var_os("NO_COLOR").is_none() && std::io::stderr().is_terminal(); - let Some(body) = self.render_body(span, label, help, color) else { + let Some(body) = Self::draw(id, &self.text, span, label, help, use_color()) else { return false; }; + eprint!("{id}: {message}\n{body}"); + true + } + + /// The finished report for `span` in `text`, ending with a newline. + fn draw( + id: &str, + text: &str, + span: Range, + label: Option<&str>, + help: Option<&str>, + color: bool, + ) -> Option { + let body = Self::render_body(id, text, span, label, help, color)?; let body = Self::localize_help(&body, help.is_some()); - let body = body.as_ref(); if label.is_some() { - eprint!("{id}: {message}\n{body}"); + Some(body.into_owned()) } else { // The empty message left an arrow arm pointing at nothing: drop it // and flatten the tee, so only the underline remains. Only the two @@ -789,16 +807,113 @@ impl Snapshot { .filter(|&(row, line)| !(Self::is_label_row(row) && line.contains('╰'))) .map(|(row, line)| { if Self::is_label_row(row) { - Cow::Owned(line.replace('┬', "─")) + Cow::Owned(line.replace('┬', "─").trim_end().to_string()) } else { Cow::Borrowed(line) } }) .collect::>() .join("\n"); - eprintln!("{id}: {message}\n{body}"); + Some(body + "\n") } - true + } +} + +/// One line of a script or a pattern file, for a report to point into, as +/// [`Snapshot`] does into the arguments. +pub struct SourceLine<'a> { + name: &'a str, + line_number: usize, + text: &'a str, +} + +impl<'a> SourceLine<'a> { + /// Line `line_number` (1-based) of the text called `name`. + pub fn new(name: &'a str, line_number: usize, text: &'a str) -> Self { + Self { + name, + line_number, + text, + } + } + + /// The report pointing at the byte range `span` of the line, headed by + /// `name:line:column` with a byte column, without the message line. + /// An empty span marks one character, or the place just past the end. + pub fn report( + &self, + span: Range, + label: Option<&str>, + help: Option<&str>, + ) -> Option { + self.report_colored(span, label, help, use_color()) + } + + fn report_colored( + &self, + span: Range, + label: Option<&str>, + help: Option<&str>, + color: bool, + ) -> Option { + if self.line_number == 0 { + return None; + } + // The trailing space gives a caret past the end something to sit + // under; ariadne panics on a span past the text otherwise. + let text = format!("{} ", self.text); + let start = floor_boundary(self.text, span.start.min(self.text.len())); + let end = floor_boundary(self.text, span.end.clamp(start, self.text.len())); + let span = if start < end { + start..end + } else { + char_span(&text, start) + }; + let body = Snapshot::draw(self.name, &text, span, label, help, color)?; + self.renumber(&body, start) + } + + /// `body`, drawn as line 1, moved to the line's own number. + /// + /// ariadne applies a display offset to the header but not to the gutter, + /// so the gutter is widened here instead. Only the margin is touched, and + /// it holds no text of ours; the colors sit around its characters, so the + /// marks below are found whether or not the report is colored. + fn renumber(&self, body: &str, start: usize) -> Option { + /// Row holding the line itself, below the header and a blank gutter. + const SOURCE: usize = 2; + let number = self.line_number.to_string(); + let widen = number.len() - 1; + let rows: Vec<&str> = body.lines().collect(); + let last = rows.len().checked_sub(1)?; + let mut out = String::with_capacity(body.len() + rows.len() * widen); + for (row, line) in rows.iter().enumerate() { + if row == 0 { + // ariadne counts the header's column in characters, not bytes. + let drawn = format!(" {}:1:", self.name); + let from = line.find(&drawn)? + 1; + let to = from + line[from..].find(' ')?; + let location = format!("{}:{number}:{}", self.name, start + 1); + let _ = write!( + out, + "{}{}{location}{}", + " ".repeat(widen), + &line[..from], + &line[to..] + ); + } else if row == SOURCE { + out.push_str(&line.replacen("1 │", &format!("{number} │"), 1)); + } else if row == last { + out.push_str(&line.replacen('╯', &format!("{}╯", "─".repeat(widen)), 1)); + } else { + out.push_str(&" ".repeat(widen)); + out.push_str(line); + } + if row != last { + out.push('\n'); + } + } + Some(out) } } @@ -834,9 +949,15 @@ mod tests { #[test] fn the_rendered_report_has_the_row_shape_the_rewriting_assumes() { let snap = snapshot(&["-k", "1,0"]); - let body = snap - .render_body(3..6, Some("a label"), Some("some advice"), false) - .unwrap(); + let body = Snapshot::render_body( + "sort", + &snap.text, + 3..6, + Some("a label"), + Some("some advice"), + false, + ) + .unwrap(); let rows: Vec<&str> = body.lines().collect(); // Row 2 echoes the arguments; rewriting it would edit what was typed. @@ -859,6 +980,74 @@ mod tests { ); } + #[test] + fn a_source_line_keeps_its_number_and_counts_its_column_in_bytes() { + // `ü` is two bytes; the caret sits past the end of the line. + let report = SourceLine::new("f.sed", 12, "ü/b") + .report_colored(4..4, None, None, false) + .unwrap(); + assert_eq!( + report, + concat!( + " ╭─[ f.sed:12:5 ]\n", + " │\n", + " 12 │ ü/b\n", + " │ ─\n", + "────╯", + ) + ); + } + + #[test] + fn a_colored_source_line_still_gets_its_location() { + let report = SourceLine::new("f.sed", 12, "x/y") + .report_colored(1..2, Some("here"), None, true) + .unwrap(); + assert!(report.contains(" f.sed:12:2 "), "{report:?}"); + assert!(report.contains("12 │"), "{report:?}"); + assert!(!report.contains(":1:"), "{report:?}"); + } + + #[test] + fn an_empty_source_line_marks_its_first_column() { + let report = SourceLine::new("pats", 3, "") + .report_colored(0..0, None, None, false) + .unwrap(); + assert_eq!( + report, + concat!( + " ╭─[ pats:3:1 ]\n", + " │\n", + " 3 │ \n", + " │ ─\n", + "───╯", + ) + ); + } + + #[test] + fn a_far_source_line_is_drawn_alone() { + let report = SourceLine::new("big", 4_000_321, "q") + .report_colored(0..1, None, Some("hint"), false) + .unwrap(); + assert!(report.len() < 200, "{report:?}"); + let rows: Vec<&str> = report.lines().collect(); + assert_eq!(rows[0], " ╭─[ big:4000321:1 ]"); + assert_eq!(rows[2], " 4000321 │ q"); + assert_eq!(*rows.last().unwrap(), "─────────╯"); + // Every row keeps its gutter bar in the same column. + for row in &rows[1..rows.len() - 1] { + assert_eq!(row.chars().nth(9), Some('│'), "{row:?}"); + } + } + + #[test] + fn advice_keeps_its_label_without_a_localizer() { + // Unit tests set up no localizer. + let body = Snapshot::render_body("t", "abc", 1..2, Some("x"), Some("y"), false).unwrap(); + assert_eq!(Snapshot::localize_help(&body, true), body); + } + #[test] fn spans_cover_each_argument() { let snap = snapshot(&["a", "=", ""]); diff --git a/src/uucore/src/lib/features/diagnostics_stub.rs b/src/uucore/src/lib/features/diagnostics_stub.rs index aefc3f3f015..8c73c5e785c 100644 --- a/src/uucore/src/lib/features/diagnostics_stub.rs +++ b/src/uucore/src/lib/features/diagnostics_stub.rs @@ -12,6 +12,7 @@ //! message it would print anywhere but a terminal. use std::ffi::{OsStr, OsString}; +use std::marker::PhantomData; use std::ops::Range; /// Always `false`: rendering is compiled out. @@ -37,6 +38,11 @@ pub use crate::features::diagnostics_boundary::{ OptionValue, ValueOptions, char_span, floor_boundary, list_items, }; +/// Always `false`: nothing is drawn to color. +pub fn use_color() -> bool { + false +} + /// Always the error itself: nothing is ever drawn to replace it. pub fn error_after_report>>( _diag_args: Option<&[OsString]>, @@ -138,3 +144,25 @@ impl Snapshot { false } } + +/// A line of text that renders nothing. +/// +/// Borrows like the real `SourceLine`, so that a caller naming its lifetime +/// compiles either way. +pub struct SourceLine<'a>(PhantomData<&'a str>); + +#[allow(clippy::unused_self)] +impl<'a> SourceLine<'a> { + pub fn new(_name: &'a str, _line_number: usize, _text: &'a str) -> Self { + Self(PhantomData) + } + + pub fn report( + &self, + _span: Range, + _label: Option<&str>, + _help: Option<&str>, + ) -> Option { + None + } +}