Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
225 changes: 207 additions & 18 deletions src/uucore/src/lib/features/diagnostics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Mode> = 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<bool> = 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()
}
Comment on lines +105 to 108

/// Keep the arguments a diagnostic would point at, as they were typed.
Expand Down Expand Up @@ -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<Cow<'_, str>> = body.lines().map(Cow::Borrowed).collect();
Expand Down Expand Up @@ -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<usize>,
label: Option<&str>,
help: Option<&str>,
color: bool,
) -> Option<String> {
let id = crate::util_name();
let config = Config::default()
// ariadne counts characters unless told otherwise, which would drift
// on multi-byte arguments.
Expand All @@ -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;
Expand All @@ -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<usize>,
label: Option<&str>,
help: Option<&str>,
color: bool,
) -> Option<String> {
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
Expand All @@ -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::<Vec<_>>()
.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<usize>,
label: Option<&str>,
help: Option<&str>,
) -> Option<String> {
self.report_colored(span, label, help, use_color())
}

fn report_colored(
&self,
span: Range<usize>,
label: Option<&str>,
help: Option<&str>,
color: bool,
) -> Option<String> {
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<String> {
/// 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)
}
}

Expand Down Expand Up @@ -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.
Expand All @@ -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", "=", ""]);
Expand Down
28 changes: 28 additions & 0 deletions src/uucore/src/lib/features/diagnostics_stub.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<E: Into<Box<dyn crate::error::UError>>>(
_diag_args: Option<&[OsString]>,
Expand Down Expand Up @@ -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<usize>,
_label: Option<&str>,
_help: Option<&str>,
) -> Option<String> {
None
}
}
Loading