From 258c6720852d676902f9116519cba4afb638f069 Mon Sep 17 00:00:00 2001 From: tinger Date: Thu, 1 Aug 2024 11:06:37 +0200 Subject: [PATCH] chore(cli): Add cli::ui This adds various abstrations for easier composability of different output effects. --- Cargo.lock | 38 + Cargo.toml | 1 + crates/typst-test-cli/Cargo.toml | 1 + crates/typst-test-cli/src/main.rs | 1 + .../typst_test__ui__tests__counted.snap | 7 + .../typst_test__ui__tests__heading.snap | 8 + ...st_test__ui__tests__heading_set_color.snap | 6 + .../typst_test__ui__tests__indented.snap | 7 + ...t_test__ui__tests__indented_continued.snap | 7 + ...ypst_test__ui__tests__indented_nested.snap | 7 + ...t_test__ui__tests__indented_set_color.snap | 7 + crates/typst-test-cli/src/ui.rs | 739 ++++++++++++++++++ just/test.just | 4 + 13 files changed, 833 insertions(+) create mode 100644 crates/typst-test-cli/src/snapshots/typst_test__ui__tests__counted.snap create mode 100644 crates/typst-test-cli/src/snapshots/typst_test__ui__tests__heading.snap create mode 100644 crates/typst-test-cli/src/snapshots/typst_test__ui__tests__heading_set_color.snap create mode 100644 crates/typst-test-cli/src/snapshots/typst_test__ui__tests__indented.snap create mode 100644 crates/typst-test-cli/src/snapshots/typst_test__ui__tests__indented_continued.snap create mode 100644 crates/typst-test-cli/src/snapshots/typst_test__ui__tests__indented_nested.snap create mode 100644 crates/typst-test-cli/src/snapshots/typst_test__ui__tests__indented_set_color.snap create mode 100644 crates/typst-test-cli/src/ui.rs diff --git a/Cargo.lock b/Cargo.lock index 40f9a12..ef65a3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -426,6 +426,18 @@ dependencies = [ "syn", ] +[[package]] +name = "console" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb" +dependencies = [ + "encode_unicode", + "lazy_static", + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -615,6 +627,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + [[package]] name = "enum-ordinalize" version = "4.3.0" @@ -1153,6 +1171,19 @@ version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" +[[package]] +name = "insta" +version = "1.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "810ae6042d48e2c9e9215043563a58a80b877bc863228a74cf10c49d4620a6f5" +dependencies = [ + "console", + "lazy_static", + "linked-hash-map", + "serde", + "similar", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.0" @@ -2094,6 +2125,12 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +[[package]] +name = "similar" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de1d4f81173b03af4c0cbed3c898f6bff5b870e4a7f5d6f4057d62a7a4b686e" + [[package]] name = "simplecss" version = "0.2.1" @@ -2688,6 +2725,7 @@ dependencies = [ "env_proxy", "flate2", "fontdb 0.18.0", + "insta", "native-tls", "once_cell", "rayon", diff --git a/Cargo.toml b/Cargo.toml index 45e64b3..10425b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ env_proxy = "0.4.1" flate2 = "1.0.30" fontdb = "0.18.0" indoc = "2.0.5" +insta = "1.39.0" ignore = "0.4.22" native-tls = "0.2.12" once_cell = "1.19.0" diff --git a/crates/typst-test-cli/Cargo.toml b/crates/typst-test-cli/Cargo.toml index fea5df0..d7d43d1 100644 --- a/crates/typst-test-cli/Cargo.toml +++ b/crates/typst-test-cli/Cargo.toml @@ -31,6 +31,7 @@ ecow.workspace = true env_proxy.workspace = true flate2.workspace = true fontdb.workspace = true +insta = { workspace = true, features = ["yaml"] } native-tls.workspace = true once_cell.workspace = true rayon.workspace = true diff --git a/crates/typst-test-cli/src/main.rs b/crates/typst-test-cli/src/main.rs index bf76e99..3c8874c 100644 --- a/crates/typst-test-cli/src/main.rs +++ b/crates/typst-test-cli/src/main.rs @@ -21,6 +21,7 @@ mod package; mod project; mod report; mod test; +mod ui; mod world; fn color_stream(color: ColorChoice, is_stderr: bool) -> StandardStream { diff --git a/crates/typst-test-cli/src/snapshots/typst_test__ui__tests__counted.snap b/crates/typst-test-cli/src/snapshots/typst_test__ui__tests__counted.snap new file mode 100644 index 0000000..fb592bd --- /dev/null +++ b/crates/typst-test-cli/src/snapshots/typst_test__ui__tests__counted.snap @@ -0,0 +1,7 @@ +--- +source: crates/typst-test-cli/src/ui.rs +expression: str +--- +Hello + +World diff --git a/crates/typst-test-cli/src/snapshots/typst_test__ui__tests__heading.snap b/crates/typst-test-cli/src/snapshots/typst_test__ui__tests__heading.snap new file mode 100644 index 0000000..65f29d6 --- /dev/null +++ b/crates/typst-test-cli/src/snapshots/typst_test__ui__tests__heading.snap @@ -0,0 +1,8 @@ +--- +source: crates/typst-test-cli/src/ui.rs +expression: str +--- +Heading + Hello + + World diff --git a/crates/typst-test-cli/src/snapshots/typst_test__ui__tests__heading_set_color.snap b/crates/typst-test-cli/src/snapshots/typst_test__ui__tests__heading_set_color.snap new file mode 100644 index 0000000..3ab7b30 --- /dev/null +++ b/crates/typst-test-cli/src/snapshots/typst_test__ui__tests__heading_set_color.snap @@ -0,0 +1,6 @@ +--- +source: crates/typst-test-cli/src/ui.rs +expression: str +--- +Heading + Hello World diff --git a/crates/typst-test-cli/src/snapshots/typst_test__ui__tests__indented.snap b/crates/typst-test-cli/src/snapshots/typst_test__ui__tests__indented.snap new file mode 100644 index 0000000..7e97b49 --- /dev/null +++ b/crates/typst-test-cli/src/snapshots/typst_test__ui__tests__indented.snap @@ -0,0 +1,7 @@ +--- +source: crates/typst-test-cli/src/ui.rs +expression: str +--- + Hello + + World diff --git a/crates/typst-test-cli/src/snapshots/typst_test__ui__tests__indented_continued.snap b/crates/typst-test-cli/src/snapshots/typst_test__ui__tests__indented_continued.snap new file mode 100644 index 0000000..d446aae --- /dev/null +++ b/crates/typst-test-cli/src/snapshots/typst_test__ui__tests__indented_continued.snap @@ -0,0 +1,7 @@ +--- +source: crates/typst-test-cli/src/ui.rs +expression: str +--- +Hello + + World diff --git a/crates/typst-test-cli/src/snapshots/typst_test__ui__tests__indented_nested.snap b/crates/typst-test-cli/src/snapshots/typst_test__ui__tests__indented_nested.snap new file mode 100644 index 0000000..3910821 --- /dev/null +++ b/crates/typst-test-cli/src/snapshots/typst_test__ui__tests__indented_nested.snap @@ -0,0 +1,7 @@ +--- +source: crates/typst-test-cli/src/ui.rs +expression: str +--- + Hello + + World diff --git a/crates/typst-test-cli/src/snapshots/typst_test__ui__tests__indented_set_color.snap b/crates/typst-test-cli/src/snapshots/typst_test__ui__tests__indented_set_color.snap new file mode 100644 index 0000000..15d7c29 --- /dev/null +++ b/crates/typst-test-cli/src/snapshots/typst_test__ui__tests__indented_set_color.snap @@ -0,0 +1,7 @@ +--- +source: crates/typst-test-cli/src/ui.rs +expression: str +--- + Hello + + World diff --git a/crates/typst-test-cli/src/ui.rs b/crates/typst-test-cli/src/ui.rs new file mode 100644 index 0000000..a7fb049 --- /dev/null +++ b/crates/typst-test-cli/src/ui.rs @@ -0,0 +1,739 @@ +#![allow(dead_code)] + +use std::fmt::{Debug, Display}; +use std::io::{IsTerminal, Write}; +use std::{fmt, io}; + +/// The maximum needed padding to align all standard annotations. The longest of +/// which is currently `warning:` at 8 bytes. +/// +/// This is used in all annotated messages of [`Ui`]. +pub const ANNOTATION_MAX_PADDING: usize = 8; + +use termcolor::{ + Color, ColorChoice, ColorSpec, HyperlinkSpec, StandardStream, StandardStreamLock, WriteColor, +}; + +#[derive(Debug)] +pub struct Ui { + stdout: StandardStream, + stderr: StandardStream, +} + +fn check_terminal(t: T, choice: ColorChoice) -> ColorChoice { + match choice { + // NOTE: when we use auto and the stream is not a terminal we disable + // it since termcolor does not check for this, in any other case we let + // termcolor figure out what to do + ColorChoice::Auto if !t.is_terminal() => ColorChoice::Never, + other => other, + } +} + +impl Ui { + /// Creates a new `Ui`. + pub fn new(out: ColorChoice, err: ColorChoice) -> Self { + Self { + stdout: StandardStream::stdout(check_terminal(io::stdout(), out)), + stderr: StandardStream::stderr(check_terminal(io::stderr(), err)), + } + } + + /// Returns an exclusive lock to stdout. + pub fn stdout(&self) -> StandardStreamLock<'_> { + self.stdout.lock() + } + + /// Returns an exclusive lock to stderr. + pub fn stderr(&self) -> StandardStreamLock<'_> { + self.stderr.lock() + } + + /// Writes the given closure with an error annotation header. + pub fn error_with( + &self, + f: impl FnOnce(&mut Indented<&mut StandardStreamLock<'_>>) -> io::Result<()>, + ) -> io::Result<()> { + write_error_with(&mut self.stderr(), ANNOTATION_MAX_PADDING, f) + } + + /// Writes the given closure with a warning annotation header. + pub fn warning_with( + &self, + f: impl FnOnce(&mut Indented<&mut StandardStreamLock<'_>>) -> io::Result<()>, + ) -> io::Result<()> { + write_warning_with(&mut self.stderr(), ANNOTATION_MAX_PADDING, f) + } + + /// Writes the given closure with a hint annotation header. + pub fn hint_with( + &self, + f: impl FnOnce(&mut Indented<&mut StandardStreamLock<'_>>) -> io::Result<()>, + ) -> io::Result<()> { + write_hint_with(&mut self.stderr(), ANNOTATION_MAX_PADDING, f) + } + + /// Writes the given closure with an error annotation header. + pub fn error_hinted_with( + &self, + f: impl FnOnce(&mut Indented<&mut StandardStreamLock<'_>>) -> io::Result<()>, + h: impl FnOnce(&mut Indented<&mut StandardStreamLock<'_>>) -> io::Result<()>, + ) -> io::Result<()> { + write_error_with(&mut self.stderr(), ANNOTATION_MAX_PADDING, f)?; + write_hint_with(&mut self.stderr(), ANNOTATION_MAX_PADDING, h) + } + + /// Writes the given closure with a warning annotation header. + pub fn warning_hinted_with( + &self, + f: impl FnOnce(&mut Indented<&mut StandardStreamLock<'_>>) -> io::Result<()>, + h: impl FnOnce(&mut Indented<&mut StandardStreamLock<'_>>) -> io::Result<()>, + ) -> io::Result<()> { + write_warning_with(&mut self.stderr(), ANNOTATION_MAX_PADDING, f)?; + write_hint_with(&mut self.stderr(), ANNOTATION_MAX_PADDING, h) + } + + /// A shorthand for [`Self::error_with`]. + pub fn error(&self, message: M) -> io::Result<()> { + self.error_with(|w| writeln!(w, "{message}")) + } + + /// A shorthand for [`Self::warning_with`]. + pub fn warning(&self, message: M) -> io::Result<()> { + self.warning_with(|w| writeln!(w, "{message}")) + } + + /// A shorthand for [`Self::hint_with`]. + pub fn hint(&self, message: M) -> io::Result<()> { + self.hint_with(|w| writeln!(w, "{message}")) + } + + /// Writes a hinted error to stderr. + pub fn error_hinted(&self, message: M, hint: H) -> io::Result<()> { + self.error_hinted_with(|w| writeln!(w, "{message}"), |w| writeln!(w, "{hint}")) + } + + /// Writes a hinted warning to stderr. + pub fn warning_hinted(&self, message: M, hint: H) -> io::Result<()> { + self.warning_hinted_with(|w| writeln!(w, "{message}"), |w| writeln!(w, "{hint}")) + } +} + +/// Executes the given closure with custom set and reset style closures. +pub fn write_with( + w: &mut W, + set: impl FnOnce(&mut ColorSpec) -> &mut ColorSpec, + unset: impl FnOnce(&mut ColorSpec) -> &mut ColorSpec, + f: impl FnOnce(&mut W) -> io::Result<()>, +) -> io::Result<()> { + w.set_color(set(&mut ColorSpec::new()))?; + f(w)?; + w.set_color(unset(&mut ColorSpec::new()))?; + Ok(()) +} + +/// A shorthand for [`write_with`] which writes bold. +pub fn write_bold( + w: &mut W, + f: impl FnOnce(&mut W) -> io::Result<()>, +) -> io::Result<()> { + write_with(w, |c| c.set_bold(true), |c| c.set_bold(false), f) +} + +/// A shorthand for [`write_with`] which writes with the given color. +pub fn write_colored( + w: &mut W, + color: Color, + f: impl FnOnce(&mut W) -> io::Result<()>, +) -> io::Result<()> { + write_with(w, |c| c.set_fg(Some(color)), |c| c.set_fg(None), f) +} + +/// A shorthand for [`write_with`] which writes bold and with the given color. +pub fn write_bold_colored( + w: &mut W, + color: Color, + f: impl FnOnce(&mut W) -> io::Result<()>, +) -> io::Result<()> { + write_with( + w, + |c| c.set_bold(true).set_fg(Some(color)), + |c| c.set_bold(false).set_fg(None), + f, + ) +} + +/// A shorthand for [`write_bold_colored`] with cyan as the color. +pub fn write_ident( + w: &mut W, + f: impl FnOnce(&mut W) -> io::Result<()>, +) -> io::Result<()> { + write_with( + w, + |c| c.set_bold(true).set_fg(Some(Color::Cyan)), + |c| c.set_bold(false).set_fg(None), + f, + ) +} + +/// Writes the given closure as an annotation, that is, it is written with a +/// header after which each line is indented by the header length. +/// +/// The maximum hanging indent can be set. +pub fn write_annotated( + w: &mut W, + header: &str, + color: Color, + max_align: impl Into>, + f: impl FnOnce(&mut Indented<&mut W>) -> io::Result<()>, +) -> io::Result<()> { + let align = max_align.into().unwrap_or(header.len()); + write_bold_colored(w, color, |w| write!(w, "{header:>align$} "))?; + + // NOTE: when taking the indent from the header length, we need to account + // for the additonal space + f(&mut Indented::continued(w, align + 1))?; + Ok(()) +} + +/// Writes the given closure with an error annotation header. +pub fn write_error_with( + w: &mut W, + pad: impl Into>, + f: impl FnOnce(&mut Indented<&mut W>) -> io::Result<()>, +) -> io::Result<()> { + write_annotated(w, "error:", Color::Red, pad, f) +} + +/// Writes the given closure with a warning annotation header. +pub fn write_warning_with( + w: &mut W, + pad: impl Into>, + f: impl FnOnce(&mut Indented<&mut W>) -> io::Result<()>, +) -> io::Result<()> { + write_annotated(w, "warning:", Color::Yellow, pad, f) +} + +/// Writes the given closure with a hint annotation header. +pub fn write_hint_with( + w: &mut W, + pad: impl Into>, + f: impl FnOnce(&mut Indented<&mut W>) -> io::Result<()>, +) -> io::Result<()> { + write_annotated(w, "hint:", Color::Cyan, pad, f) +} + +/// A shorthand for [`write_error_with`]. +pub fn write_error( + w: &mut W, + pad: impl Into>, + message: M, +) -> io::Result<()> { + write_error_with(w, pad, |w| writeln!(w, "{message}")) +} + +/// A shorthand for [`write_warning_with`]. +pub fn write_warning( + w: &mut W, + pad: impl Into>, + message: M, +) -> io::Result<()> { + write_warning_with(w, pad, |w| writeln!(w, "{message}")) +} + +/// A shorthand for [`write_hint_with`]. +pub fn write_hint( + w: &mut W, + pad: impl Into>, + message: M, +) -> io::Result<()> { + write_hint_with(w, pad, |w| writeln!(w, "{message}")) +} + +/// Writes the ANSI escape codes to clear the given number of last lines. +pub fn clear_last_lines(w: &mut W, lines: usize) -> io::Result<()> { + if lines != 0 { + write!(w, "\x1B[{}F\x1B[0J", lines)?; + } + + Ok(()) +} + +#[derive(Debug)] +pub struct Counted { + /// The writer to write to. + writer: W, + + /// The currently counted lines. + lines: usize, +} + +impl Counted { + /// Creates a new writer which counts the number of lines printed. + pub fn new(writer: W) -> Self { + Self { writer, lines: 0 } + } + + /// Returns a mutable reference to the inner writer. + pub fn inner(&mut self) -> &mut W { + &mut self.writer + } + + /// Returns the number of lines since the last reset. + pub fn lines(&self) -> usize { + self.lines + } + + /// Resets the line counter to `0`. + pub fn reset_lines(&mut self) { + self.lines = 0; + } + + /// Returns the inner writer. + pub fn into_inner(self) -> W { + self.writer + } +} + +impl fmt::Write for Counted { + fn write_str(&mut self, s: &str) -> fmt::Result { + self.write_all(s.as_bytes()).map_err(|_| fmt::Error) + } +} + +impl Write for Counted { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.writer.write(buf).inspect(|&len| { + self.lines += buf[..len].iter().filter(|&&b| b == b'\n').count(); + }) + } + + fn flush(&mut self) -> io::Result<()> { + self.writer.flush() + } + + fn write_all(&mut self, buf: &[u8]) -> io::Result<()> { + self.writer.write_all(buf)?; + self.lines += buf.iter().filter(|&&b| b == b'\n').count(); + Ok(()) + } +} + +impl WriteColor for Counted { + fn supports_color(&self) -> bool { + self.writer.supports_color() + } + + fn set_color(&mut self, spec: &ColorSpec) -> io::Result<()> { + self.writer.set_color(spec) + } + + fn reset(&mut self) -> io::Result<()> { + self.writer.reset() + } + + fn is_synchronous(&self) -> bool { + self.writer.is_synchronous() + } + + fn set_hyperlink(&mut self, link: &HyperlinkSpec) -> io::Result<()> { + self.writer.set_hyperlink(link) + } + + fn supports_hyperlinks(&self) -> bool { + self.writer.supports_hyperlinks() + } +} + +#[derive(Debug)] +pub struct Indented { + /// The writer to write to. + writer: W, + + /// The current indent. + indent: usize, + + /// Whether an indent is required at the next newline. + need_indent: bool, + + /// The color spec to reactivate after the next indent. + spec: Option, +} + +impl Indented { + /// Creates a new writer which indents every non-empty line. + pub fn new(writer: W, indent: usize) -> Self { + Self { + writer, + indent, + need_indent: true, + spec: None, + } + } + + /// Creates a new writer which indents every non-empty line after the first + /// one. This is useful for writers which start on a non-empty line. + pub fn continued(writer: W, indent: usize) -> Self { + Self { + writer, + indent, + need_indent: false, + spec: None, + } + } + + /// Returns a mutable reference to the inner writer. + pub fn inner(&mut self) -> &mut W { + &mut self.writer + } + + /// Executes the given closure with an additional indent which is later reset. + pub fn write_with(&mut self, indent: usize, f: impl FnOnce(&mut Self) -> R) -> R { + self.indent += indent; + let res = f(self); + self.indent -= indent; + res + } + + /// Returns the inner writer. + pub fn into_inner(self) -> W { + self.writer + } +} + +impl fmt::Write for Indented { + fn write_str(&mut self, s: &str) -> fmt::Result { + self.write_all(s.as_bytes()).map_err(|_| fmt::Error) + } +} + +impl Write for Indented { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.write_all(buf).map(|_| buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + self.writer.flush() + } + + fn write_all(&mut self, mut buf: &[u8]) -> io::Result<()> { + let pad = " ".repeat(self.indent); + + loop { + if self.need_indent { + match buf.iter().position(|&b| b != b'\n') { + None => break self.writer.write_all(buf), + Some(len) => { + let (head, tail) = buf.split_at(len); + self.writer.write_all(head)?; + if self.spec.is_some() { + self.writer.reset()?; + } + self.writer.write_all(pad.as_bytes())?; + if let Some(spec) = &self.spec { + self.writer.set_color(spec)?; + } + self.need_indent = false; + buf = tail; + } + } + } else { + match buf.iter().position(|&b| b == b'\n') { + None => break self.writer.write_all(buf), + Some(len) => { + let (head, tail) = buf.split_at(len + 1); + self.writer.write_all(head)?; + self.need_indent = true; + buf = tail; + } + } + } + } + } +} + +impl WriteColor for Indented { + fn supports_color(&self) -> bool { + self.writer.supports_color() + } + + fn set_color(&mut self, spec: &ColorSpec) -> io::Result<()> { + self.spec = Some(spec.clone()); + self.writer.set_color(spec) + } + + fn reset(&mut self) -> io::Result<()> { + self.spec = None; + self.writer.reset() + } + + fn is_synchronous(&self) -> bool { + self.writer.is_synchronous() + } + + fn set_hyperlink(&mut self, link: &HyperlinkSpec) -> io::Result<()> { + self.writer.set_hyperlink(link) + } + + fn supports_hyperlinks(&self) -> bool { + self.writer.supports_hyperlinks() + } +} + +#[derive(Debug)] +pub struct Heading { + /// The writer to write to. + writer: Indented, + + /// The heading to write once. + heading: String, + + /// Whether the heading has been written. + written: bool, + + /// The spec to write after the heading. + spec: Option, +} + +impl Heading { + /// Creates a new writer which writes a heading on the first write and + /// indents all subsequent lines. + pub fn new(writer: W, heading: S) -> Self { + Self { + writer: Indented::new(writer, 2), + heading: heading.to_string(), + written: false, + spec: None, + } + } + + /// Returns a mutable reference to the inner writer. + pub fn inner(&mut self) -> &mut Indented { + &mut self.writer + } + + /// Returns the inner writer. + pub fn into_inner(self) -> Indented { + self.writer + } +} + +impl fmt::Write for Heading { + fn write_str(&mut self, s: &str) -> fmt::Result { + self.write_all(s.as_bytes()).map_err(|_| fmt::Error) + } +} + +impl Write for Heading { + fn write(&mut self, buf: &[u8]) -> io::Result { + if !self.written { + self.writer.set_color(ColorSpec::new().set_bold(true))?; + self.writer.inner().write_all(self.heading.as_bytes())?; + self.writer.reset()?; + writeln!(self.writer.inner())?; + if let Some(spec) = &self.spec { + self.writer.set_color(spec)?; + } + self.written = true; + } + self.writer.write(buf) + } + + fn flush(&mut self) -> io::Result<()> { + self.writer.flush() + } + + fn write_all(&mut self, buf: &[u8]) -> io::Result<()> { + if !self.written { + self.writer.set_color(ColorSpec::new().set_bold(true))?; + self.writer.inner().write_all(self.heading.as_bytes())?; + self.writer.reset()?; + writeln!(self.writer.inner())?; + if let Some(spec) = &self.spec { + self.writer.set_color(spec)?; + } + self.written = true; + } + self.writer.write_all(buf) + } +} + +impl WriteColor for Heading { + fn supports_color(&self) -> bool { + self.writer.supports_color() + } + + fn set_color(&mut self, spec: &ColorSpec) -> io::Result<()> { + self.spec = Some(spec.clone()); + if self.written { + self.writer.set_color(spec)?; + } + + Ok(()) + } + + fn reset(&mut self) -> io::Result<()> { + self.spec = None; + if self.written { + self.writer.reset()?; + } + + Ok(()) + } + + fn is_synchronous(&self) -> bool { + self.writer.is_synchronous() + } + + fn set_hyperlink(&mut self, link: &HyperlinkSpec) -> io::Result<()> { + self.writer.set_hyperlink(link) + } + + fn supports_hyperlinks(&self) -> bool { + self.writer.supports_hyperlinks() + } +} + +#[derive(Debug)] +pub struct Live { + /// The writer to write to. + writer: W, + + /// The lines to clear before each live reporting. + to_clear: usize, +} + +impl Live { + /// Creates a new writer, which will clear previous lines for each live write. + pub fn new(writer: W) -> Self { + Self { + writer, + to_clear: 0, + } + } + + /// Returns a mutable reference to the inner writer. + pub fn inner(&mut self) -> &mut W { + &mut self.writer + } + + /// Returns the inner writer. + pub fn into_inner(self) -> W { + self.writer + } +} + +impl Live { + /// Clears the previously written lines and writes new ones using the given closure. + pub fn live(&mut self, f: F) -> io::Result<()> + where + F: FnOnce(&mut Counted<&mut W>) -> io::Result<()>, + { + clear_last_lines(&mut self.writer, self.to_clear)?; + let mut w = Counted::new(&mut self.writer); + f(&mut w)?; + self.to_clear = w.lines(); + + Ok(()) + } +} + +#[allow(dead_code)] +fn assert_traits() { + fn assert_send() {} + fn assert_sync() {} + + assert_send::(); + assert_sync::(); +} + +#[cfg(test)] +mod tests { + use insta::assert_snapshot; + use termcolor::Ansi; + + use super::*; + + #[test] + fn test_counted() { + let mut w = Counted::new(vec![]); + + write!(w, "Hello\n\nWorld\n").unwrap(); + + assert_eq!(w.lines(), 3); + + let w = w.into_inner(); + let str = std::str::from_utf8(&w).unwrap(); + assert_snapshot!(str); + } + + #[test] + fn test_indented() { + let mut w = Indented::new(Ansi::new(vec![]), 2); + + write!(w, "Hello\n\nWorld\n").unwrap(); + + let w = w.into_inner().into_inner(); + let str = std::str::from_utf8(&w).unwrap(); + assert_snapshot!(str); + } + + #[test] + fn test_indented_continued() { + let mut w = Indented::continued(Ansi::new(vec![]), 2); + + write!(w, "Hello\n\nWorld\n").unwrap(); + + let w = w.into_inner().into_inner(); + let str = std::str::from_utf8(&w).unwrap(); + assert_snapshot!(str); + } + + #[test] + fn test_indented_nested() { + let mut w = Indented::new(Indented::new(Ansi::new(vec![]), 2), 2); + + write!(w, "Hello\n\nWorld\n").unwrap(); + + let w = w.into_inner().into_inner().into_inner(); + let str = std::str::from_utf8(&w).unwrap(); + assert_snapshot!(str); + } + + #[test] + fn test_indented_set_color() { + let mut w = Indented::new(Ansi::new(vec![]), 2); + + w.set_color(ColorSpec::new().set_bold(true)).unwrap(); + write!(w, "Hello\n\nWorld\n").unwrap(); + + let w = w.into_inner().into_inner(); + let str = std::str::from_utf8(&w).unwrap(); + assert_snapshot!(str); + } + + #[test] + fn test_heading() { + let mut w = Heading::new(Ansi::new(vec![]), "Heading"); + + write!(w, "Hello\n\nWorld\n").unwrap(); + + let w = w.into_inner().into_inner().into_inner(); + let str = std::str::from_utf8(&w).unwrap(); + assert_snapshot!(str); + } + + #[test] + fn test_heading_set_color() { + let mut w = Heading::new(Ansi::new(vec![]), "Heading"); + + w.set_color(ColorSpec::new().set_bold(true)).unwrap(); + write!(w, "Hello World").unwrap(); + + let w = w.into_inner().into_inner().into_inner(); + let str = std::str::from_utf8(&w).unwrap(); + assert_snapshot!(str); + } + + // TODO: test live +} diff --git a/just/test.just b/just/test.just index 86b58d9..9da018e 100644 --- a/just/test.just +++ b/just/test.just @@ -17,6 +17,10 @@ doc *args: # TODO: this currently deadlocks my pc at 100% CPU # cargo test --workspace --doc {{ args }} +# run cargo insta and review the snapshot changes +review *args='--review': + cargo insta test --test-runner nextest {{ args }} + # test the book book *args: mdbook test {{ book-src }} {{ args }}