mirror of
https://github.com/nushell/nushell.git
synced 2026-10-28 08:54:52 +08:00
Shorter table escape sequences, and a new ansi opt command (#19340)
## Description With the default colors, nushell's tables repeat escape sequences that change nothing on screen. Every border character is written as `\e[39m│\e[0m`, and a cell colored with the default foreground is wrapped in `\e[39m…\e[0m`, which leaves two resets after every file name in `ls`. This PR does two things: 1. **Plain borders.** With the default separator style (`separator: default`), nu-table keeps the color only on the borders that start a line: every horizontal line, and the first vertical border of each row. It writes the other borders without color, and the cells colored only with the default foreground too. Every line looks the same, with fewer bytes: 19% fewer for `ls`, 28% for a nested table, 43% for a 300-row stream. 2. **`ansi opt`.** A new command shortens the escape sequences in a string, binary or byte stream with the [ansiopt](https://github.com/fdncred/ansiopt) optimizer, without changing what a terminal shows. It can also report what it changed. Nushell doesn't rewrite anything on its own at print time. Nothing but table rendering changes, and only in a way the screen can't show, for a whole table and for a line taken out of one. ### Before and After ``` main \e[39m╭───┬──────╮\e[0m \e[39m│\e[0m \e[1;32m#\e[0m \e[39m│\e[0m \e[1;32mname\e[0m \e[39m│\e[0m \e[39m├───┼──────┤\e[0m \e[39m│\e[0m \e[1;32m0\e[0m \e[39m│\e[0m \e[39m\e[38;5;81msrc\e[0m\e[0m \e[39m│\e[0m \e[39m╰───┴──────╯\e[0m branch \e[39m╭───┬──────╮\e[0m \e[39m│\e[0m \e[1;32m#\e[0m │ \e[1;32mname\e[0m │ \e[39m├───┼──────┤\e[0m \e[39m│\e[0m \e[1;32m0\e[0m │ \e[38;5;81msrc\e[0m │ \e[39m╰───┴──────╯\e[0m ``` `ansi opt` on a string: ```nushell > $'(ansi reset)(ansi reset)(ansi red)hi(ansi reset)' | ansi opt --show \e[;31mhi\e[m > "\e[0m\e[31m──── \e[0m \e[31m────\e[0m" | ansi opt --visual --rep --show \e[;31m─\e[3b ─\e[3b\e[m > $'(ansi reset)(ansi reset)(ansi red)hi(ansi reset)' | ansi opt --explain ╭───┬──────────┬─────────┬───────────┬─────────┬───────────────────╮ │ # │ sequence │ meaning │ outcome │ output │ why │ ├───┼──────────┼─────────┼───────────┼─────────┼───────────────────┤ │ 0 │ \e[0m │ reset │ folded │ \e[;31m │ │ │ 1 │ \e[0m │ reset │ no effect │ │ already in effect │ │ 2 │ \e[31m │ fg red │ folded │ \e[;31m │ │ │ 3 │ \e[0m │ reset │ folded │ \e[m │ │ ╰───┴──────────┴─────────┴───────────┴─────────┴───────────────────╯ ``` ## User-facing changes (Release notes) - Tables with the default colors are written with fewer escape sequences: about a fifth fewer bytes for `ls` and records, and up to two fifths for longer tables. They look exactly the same, also when a line is taken out of a table and printed on its own. - New `ansi opt` command, which shortens the ANSI escape sequences in a string, binary or byte stream without changing what a terminal shows. `--stats`, `--diff`, `--explain`, `--hotspot`, `--show` and `--html` return what it changed instead, as records, tables or an HTML report. ## Additional notes
This commit is contained in:
1 parent
f3cc93329c
commit
706259d6f8
12 files changed
+1132
-29
No files matched your search
Generated
+37
-4
@@ -127,6 +127,17 @@ dependencies = [
|
||||
"ansitok",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ansiopt"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5035af3f4e13856c6373940c4c58b4d362ca80359963dfc1daec3b6e9cf0f588"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"simdutf8",
|
||||
"vt100",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ansitok"
|
||||
version = "0.3.0"
|
||||
@@ -134,7 +145,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0a8acea8c2f1c60f0a92a8cd26bf96ca97db56f10bbcab238bbe0cceba659ee"
|
||||
dependencies = [
|
||||
"nom 7.1.3",
|
||||
"vte",
|
||||
"vte 0.14.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3722,9 +3733,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.17"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jiff"
|
||||
@@ -4898,6 +4909,7 @@ dependencies = [
|
||||
"aegis-password-generator",
|
||||
"alphanumeric-sort",
|
||||
"ansi-str",
|
||||
"ansiopt",
|
||||
"arboard",
|
||||
"base64 0.23.1",
|
||||
"bracoxide",
|
||||
@@ -8984,7 +8996,7 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025"
|
||||
dependencies = [
|
||||
"vte",
|
||||
"vte 0.14.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10186,6 +10198,17 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
|
||||
|
||||
[[package]]
|
||||
name = "vt100"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb05cdd19ae8707bf6a0a269972c07e6358103a7079faec749dec318a5b3dc23"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"unicode-width 0.2.2",
|
||||
"vte 0.15.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vte"
|
||||
version = "0.14.1"
|
||||
@@ -10196,6 +10219,16 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vte"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wait-timeout"
|
||||
version = "0.2.1"
|
||||
|
||||
@@ -139,6 +139,7 @@ nuon = { path = "crates/nuon", version = "0.116.2", default-features = false }
|
||||
aegis-password-generator = "0.1.1"
|
||||
alphanumeric-sort = "1.5.8"
|
||||
ansi-str = "0.9"
|
||||
ansiopt = { version = "0.4.0", default-features = false }
|
||||
anyhow = "1.0.104"
|
||||
base64 = "0.23.1"
|
||||
better_default = "1.0.5"
|
||||
|
||||
@@ -25,6 +25,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
ansi-str = { workspace = true }
|
||||
ansiopt = { workspace = true, features = ["render"] }
|
||||
nu-ansi-term.workspace = true
|
||||
nu-cmd-base.workspace = true
|
||||
nu-color-config.workspace = true
|
||||
|
||||
@@ -201,6 +201,7 @@ pub fn add_shell_command_context(mut engine_state: EngineState) -> EngineState {
|
||||
bind_command! {
|
||||
Ansi,
|
||||
AnsiLink,
|
||||
AnsiOpt,
|
||||
AnsiStrip,
|
||||
Char,
|
||||
Decode,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
mod ansi_;
|
||||
mod link;
|
||||
mod opt;
|
||||
mod strip;
|
||||
|
||||
pub use ansi_::Ansi;
|
||||
pub use link::AnsiLink;
|
||||
pub use opt::AnsiOpt;
|
||||
pub use strip::AnsiStrip;
|
||||
@@ -0,0 +1,672 @@
|
||||
use ansiopt::{
|
||||
Minifier, Options,
|
||||
align::token_at,
|
||||
describe::{self, describe, visible},
|
||||
report::{
|
||||
Report, Tally, heat_lines, percent_saved, rewrites,
|
||||
term::{Paint, shade},
|
||||
},
|
||||
trace::Fate,
|
||||
};
|
||||
use nu_engine::command_prelude::*;
|
||||
use nu_protocol::{
|
||||
DataSource, PipelineMetadata,
|
||||
shell_error::{bridge::ShellErrorBridge, generic::GenericError, io::IoError},
|
||||
};
|
||||
use std::{
|
||||
io::{ErrorKind, Read},
|
||||
ops::Range,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AnsiOpt;
|
||||
|
||||
/// What `ansi opt` returns in place of the shortened input. Each has a flag of the same name.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum View {
|
||||
Show,
|
||||
Report(ReportView),
|
||||
}
|
||||
|
||||
/// The views built from a [`Report`] of how the input and the output line up.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum ReportView {
|
||||
Stats,
|
||||
Diff,
|
||||
Explain,
|
||||
Hotspot,
|
||||
Html,
|
||||
}
|
||||
|
||||
impl View {
|
||||
const ALL: [(&str, View); 6] = [
|
||||
("stats", View::Report(ReportView::Stats)),
|
||||
("show", View::Show),
|
||||
("diff", View::Report(ReportView::Diff)),
|
||||
("explain", View::Report(ReportView::Explain)),
|
||||
("hotspot", View::Report(ReportView::Hotspot)),
|
||||
("html", View::Report(ReportView::Html)),
|
||||
];
|
||||
}
|
||||
|
||||
impl Command for AnsiOpt {
|
||||
fn name(&self) -> &str {
|
||||
"ansi opt"
|
||||
}
|
||||
|
||||
fn signature(&self) -> Signature {
|
||||
Signature::build("ansi opt")
|
||||
.input_output_types(vec![
|
||||
(Type::String, Type::String),
|
||||
(Type::Binary, Type::Binary),
|
||||
(Type::String, Type::record()),
|
||||
(Type::Binary, Type::record()),
|
||||
(Type::String, Type::table()),
|
||||
(Type::Binary, Type::table()),
|
||||
(Type::Binary, Type::String),
|
||||
])
|
||||
.switch(
|
||||
"visual",
|
||||
"Only keep what is visible: spaces, line feeds and erased areas don't get colors they can't show.",
|
||||
None,
|
||||
)
|
||||
.switch(
|
||||
"rep",
|
||||
"Write runs of one character with REP (CSI n b).",
|
||||
None,
|
||||
)
|
||||
.switch(
|
||||
"compat",
|
||||
"Avoid shorthand that old terminals may reject: empty parameters, BEL ending strings.",
|
||||
Some('c'),
|
||||
)
|
||||
.switch(
|
||||
"stats",
|
||||
"Return the sizes and what kinds of bytes were saved, as a record.",
|
||||
Some('s'),
|
||||
)
|
||||
.switch(
|
||||
"show",
|
||||
"Return the result with its escape sequences made visible.",
|
||||
None,
|
||||
)
|
||||
.switch(
|
||||
"diff",
|
||||
"Return each place between two pieces of text where the sequences changed, and the bytes saved there, as a table.",
|
||||
None,
|
||||
)
|
||||
.switch(
|
||||
"explain",
|
||||
"Return what happened to every input sequence, and why, as a table, including sequences kept as they were.",
|
||||
None,
|
||||
)
|
||||
.switch(
|
||||
"hotspot",
|
||||
"Return each line with the escape bytes spent on it, its text shaded by cost, as a table.",
|
||||
None,
|
||||
)
|
||||
.switch(
|
||||
"html",
|
||||
"Return an HTML report combining all the views.",
|
||||
None,
|
||||
)
|
||||
.allow_variants_without_examples(true)
|
||||
.category(Category::Platform)
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Shorten the ANSI escape sequences in a string without changing what a terminal shows."
|
||||
}
|
||||
|
||||
fn extra_description(&self) -> &str {
|
||||
"Repeated resets, colors that are set again before anything uses them, and cursor movements that cancel out are removed, and what is left is written in its shortest form. Anything ansi opt doesn't understand is kept byte for byte. It assumes nothing about the terminal's state, so the result also looks the same wherever it is printed.
|
||||
|
||||
--visual --rep together give the smallest output, on terminals that support REP (what the ansiopt tool calls --aggressive). Neither is safe everywhere: --visual can drop colors from blank cells that some tools still copy or record, and a terminal without REP drops the repeated characters.
|
||||
|
||||
The result is meant to be written to a terminal as a whole. It can rely on colors carried over from one line to the next, so splitting it into lines (for example with `lines`) can lose colors.
|
||||
|
||||
A byte stream is shortened as it arrives. In that case the result is not guaranteed to be shorter than the input, which a whole string or binary is.
|
||||
|
||||
The flags --stats, --show, --diff, --explain, --hotspot and --html each return a view of what changed in place of the result, and only one can be used at a time."
|
||||
}
|
||||
|
||||
fn search_terms(&self) -> Vec<&str> {
|
||||
vec![
|
||||
"minify", "optimize", "shrink", "compress", "escape", "sgr", "color",
|
||||
]
|
||||
}
|
||||
|
||||
fn run(
|
||||
&self,
|
||||
engine_state: &EngineState,
|
||||
stack: &mut Stack,
|
||||
call: &Call,
|
||||
input: PipelineData,
|
||||
) -> Result<PipelineData, ShellError> {
|
||||
let head = call.head;
|
||||
let options = options(engine_state, stack, call)?;
|
||||
let view = view(engine_state, stack, call)?;
|
||||
|
||||
let metadata = input.metadata_ref().cloned();
|
||||
let (bytes, is_string) = match input {
|
||||
PipelineData::ByteStream(stream, metadata) => match view {
|
||||
// Without a view the stream can be shortened as it arrives.
|
||||
None => {
|
||||
return Ok(PipelineData::byte_stream(
|
||||
minify_stream(stream, options),
|
||||
metadata,
|
||||
));
|
||||
}
|
||||
// A view looks the same for a string or binary input.
|
||||
Some(_) => (stream.into_bytes()?, false),
|
||||
},
|
||||
PipelineData::Value(Value::String { val, .. }, ..) => (val.into_bytes(), true),
|
||||
PipelineData::Value(Value::Binary { val, .. }, ..) => (val.into_owned(), false),
|
||||
PipelineData::Value(Value::Error { error, .. }, ..) => return Err(*error),
|
||||
input => return Err(input.unsupported_input_error("string or binary", head)),
|
||||
};
|
||||
|
||||
let wants_trace = matches!(
|
||||
view,
|
||||
Some(View::Report(ReportView::Explain | ReportView::Html))
|
||||
);
|
||||
let minified = ansiopt::minify_detailed(&bytes, &options, wants_trace);
|
||||
let view = match view {
|
||||
None => {
|
||||
let value = if is_string {
|
||||
// The optimizer never splits a character, so this only fails on a bug.
|
||||
let output = String::from_utf8(minified.output).map_err(|_| {
|
||||
ShellError::NonUtf8Custom {
|
||||
msg: "shortening ANSI escape sequences produced invalid UTF-8".into(),
|
||||
span: head,
|
||||
}
|
||||
})?;
|
||||
Value::string(output, head)
|
||||
} else {
|
||||
Value::binary(minified.output, head)
|
||||
};
|
||||
return Ok(value.into_pipeline_data_with_metadata(metadata));
|
||||
}
|
||||
Some(View::Show) => {
|
||||
return Ok(Value::string(show(&minified.output), head).into_pipeline_data());
|
||||
}
|
||||
Some(View::Report(view)) => view,
|
||||
};
|
||||
|
||||
let report = Report::new(&bytes, &minified.output, minified.fell_back, minified.trace)
|
||||
.map_err(|mismatch| {
|
||||
ShellError::Generic(
|
||||
GenericError::new(
|
||||
"ansi opt could not line up its input with its output",
|
||||
format!(
|
||||
"they disagree at input byte {} and output byte {}",
|
||||
mismatch.input_offset, mismatch.output_offset
|
||||
),
|
||||
head,
|
||||
)
|
||||
.with_help("this is a bug in ansiopt; the result without a view is unaffected"),
|
||||
)
|
||||
})?;
|
||||
let value = match view {
|
||||
ReportView::Stats => stats(&report, head),
|
||||
ReportView::Diff => diff(&report, head),
|
||||
ReportView::Explain => explain(&report, head),
|
||||
ReportView::Hotspot => {
|
||||
let color = stack
|
||||
.get_config(engine_state)
|
||||
.use_ansi_coloring
|
||||
.get(engine_state);
|
||||
hotspot(&report, color, head)
|
||||
}
|
||||
ReportView::Html => {
|
||||
let settings = ansiopt::report::html::Settings {
|
||||
title: title(metadata.as_ref()),
|
||||
options,
|
||||
width: None,
|
||||
height: None,
|
||||
};
|
||||
Value::string(ansiopt::report::html::html(&report, &settings), head)
|
||||
}
|
||||
};
|
||||
Ok(value.into_pipeline_data())
|
||||
}
|
||||
|
||||
fn examples(&self) -> Vec<Example<'_>> {
|
||||
let folded = |sequence, meaning, output| {
|
||||
Value::test_record(record! {
|
||||
"sequence" => Value::test_string(sequence),
|
||||
"meaning" => Value::test_string(meaning),
|
||||
"outcome" => Value::test_string("folded"),
|
||||
"output" => Value::test_string(output),
|
||||
"why" => Value::test_nothing(),
|
||||
})
|
||||
};
|
||||
vec![
|
||||
Example {
|
||||
description: "Shorten a string: the second reset does nothing, and the first and the red can be one sequence",
|
||||
example: "$'(ansi reset)(ansi reset)(ansi red)hi(ansi reset)' | ansi opt",
|
||||
result: Some(Value::test_string("\u{1b}[;31mhi\u{1b}[m")),
|
||||
},
|
||||
Example {
|
||||
description: "Shorten an external command's colored output as it streams",
|
||||
example: "^ls --color=always | ansi opt",
|
||||
result: None,
|
||||
},
|
||||
Example {
|
||||
description: "Show the result with its escape sequences made visible",
|
||||
example: "$'(ansi reset)(ansi reset)(ansi red)hi(ansi reset)' | ansi opt --show",
|
||||
result: Some(Value::test_string(r"\e[;31mhi\e[m")),
|
||||
},
|
||||
Example {
|
||||
description: "Only keep what is visible: spaces show no foreground color, so the words can stay red across them",
|
||||
example: r#""\e[0m\e[31mfoo \e[0m \e[31mbar\e[0m" | ansi opt --visual --show"#,
|
||||
result: Some(Value::test_string(r"\e[;31mfoo bar\e[m")),
|
||||
},
|
||||
Example {
|
||||
description: "Write a run of one character with REP, for a terminal that supports it",
|
||||
example: "'────────' | ansi opt --rep --show",
|
||||
result: Some(Value::test_string(r"─\e[7b")),
|
||||
},
|
||||
Example {
|
||||
description: "Shorten as far as possible for a terminal that supports REP: spaces don't need their color, and repeated characters become REP",
|
||||
example: r#""\e[0m\e[31m──── \e[0m \e[31m────\e[0m" | ansi opt --visual --rep --show"#,
|
||||
result: Some(Value::test_string(r"\e[;31m─\e[3b ─\e[3b\e[m")),
|
||||
},
|
||||
Example {
|
||||
description: "Avoid shorthand that old terminals may reject: the reset is written as 0 instead of left empty",
|
||||
example: "$'(ansi reset)(ansi reset)(ansi red)hi(ansi reset)' | ansi opt --compat --show",
|
||||
result: Some(Value::test_string(r"\e[0;31mhi\e[m")),
|
||||
},
|
||||
Example {
|
||||
description: "See how many bytes were saved",
|
||||
example: "$'(ansi reset)(ansi reset)(ansi red)hi(ansi reset)' | ansi opt --stats | get input output saved",
|
||||
result: Some(Value::test_list(vec![
|
||||
Value::test_filesize(19),
|
||||
Value::test_filesize(11),
|
||||
Value::test_filesize(8),
|
||||
])),
|
||||
},
|
||||
Example {
|
||||
description: "List each place where the sequences changed, and the bytes saved there",
|
||||
example: "$'(ansi reset)(ansi reset)(ansi red)hi(ansi reset)' | ansi opt --diff",
|
||||
result: Some(Value::test_list(vec![
|
||||
Value::test_record(record! {
|
||||
"from" => Value::test_string(r"\e[0m \e[0m \e[31m"),
|
||||
"to" => Value::test_string(r"\e[;31m"),
|
||||
"saved" => Value::test_filesize(7),
|
||||
}),
|
||||
Value::test_record(record! {
|
||||
"from" => Value::test_string(r"\e[0m"),
|
||||
"to" => Value::test_string(r"\e[m"),
|
||||
"saved" => Value::test_filesize(1),
|
||||
}),
|
||||
])),
|
||||
},
|
||||
Example {
|
||||
description: "List what happened to every escape sequence, and why any was left out",
|
||||
example: "$'(ansi reset)(ansi reset)(ansi red)hi(ansi reset)' | ansi opt --explain",
|
||||
result: Some(Value::test_list(vec![
|
||||
folded(r"\e[0m", "reset", r"\e[;31m"),
|
||||
Value::test_record(record! {
|
||||
"sequence" => Value::test_string(r"\e[0m"),
|
||||
"meaning" => Value::test_string("reset"),
|
||||
"outcome" => Value::test_string("no effect"),
|
||||
"output" => Value::test_nothing(),
|
||||
"why" => Value::test_string("already in effect"),
|
||||
}),
|
||||
folded(r"\e[31m", "fg red", r"\e[;31m"),
|
||||
folded(r"\e[0m", "reset", r"\e[m"),
|
||||
])),
|
||||
},
|
||||
Example {
|
||||
// No result: `text` is shaded only when coloring is on, which by default depends
|
||||
// on whether stdout is a terminal.
|
||||
description: "Find the lines of an external command's output that spend the most bytes on escape sequences",
|
||||
example: "^ls --color=always | ansi opt --hotspot | sort-by escapes_in --reverse | first 5",
|
||||
result: None,
|
||||
},
|
||||
Example {
|
||||
description: "Write an HTML report of what changed in a captured file",
|
||||
example: "open --raw capture.ansi | ansi opt --html | save report.html",
|
||||
result: None,
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// The optimizer options set by the flags.
|
||||
fn options(
|
||||
engine_state: &EngineState,
|
||||
stack: &mut Stack,
|
||||
call: &Call,
|
||||
) -> Result<Options, ShellError> {
|
||||
Ok(Options {
|
||||
visual: call.has_flag(engine_state, stack, "visual")?,
|
||||
rep: call.has_flag(engine_state, stack, "rep")?,
|
||||
compat: call.has_flag(engine_state, stack, "compat")?,
|
||||
..Options::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// The view flag given, if any. More than one is an error, naming the first two as typed.
|
||||
fn view(
|
||||
engine_state: &EngineState,
|
||||
stack: &mut Stack,
|
||||
call: &Call,
|
||||
) -> Result<Option<View>, ShellError> {
|
||||
let mut chosen = Vec::new();
|
||||
for (flag, view) in View::ALL {
|
||||
if call.has_flag(engine_state, stack, flag)? {
|
||||
let span = call.get_flag_span(stack, flag).unwrap_or(call.head);
|
||||
chosen.push((span, flag, view));
|
||||
}
|
||||
}
|
||||
chosen.sort_by_key(|(span, ..)| span.start);
|
||||
match chosen[..] {
|
||||
[] => Ok(None),
|
||||
[(_, _, view)] => Ok(Some(view)),
|
||||
[(left_span, first, _), (right_span, second, _), ..] => {
|
||||
Err(ShellError::IncompatibleParameters {
|
||||
left_message: format!("--{first} returns one view"),
|
||||
left_span,
|
||||
right_message: format!("--{second} returns another"),
|
||||
right_span,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shortens a stream as it arrives, bringing the output up to date after every chunk, since the
|
||||
/// producer may pause after any of them.
|
||||
fn minify_stream(stream: ByteStream, options: Options) -> ByteStream {
|
||||
let span = stream.span();
|
||||
let signals = stream.signals().clone();
|
||||
let type_ = stream.type_();
|
||||
let Some(mut reader) = stream.reader() else {
|
||||
return ByteStream::read(std::io::empty(), span, signals, type_);
|
||||
};
|
||||
// `None` once the stream has ended.
|
||||
let mut minifier = Some(Minifier::new(options));
|
||||
let mut buf = vec![0; 64 * 1024];
|
||||
ByteStream::from_fn(span, signals, type_, move |out| {
|
||||
let Some(active) = minifier.as_mut() else {
|
||||
return Ok(false);
|
||||
};
|
||||
// A read cut short by a signal is tried again, as `ByteStream::into_bytes` does. An
|
||||
// error from upstream, or Ctrl-C, comes through the reader as an I/O error.
|
||||
let read = loop {
|
||||
match reader.read(&mut buf) {
|
||||
Err(err) if err.kind() == ErrorKind::Interrupted => continue,
|
||||
read => break read,
|
||||
}
|
||||
}
|
||||
.map_err(|err| match ShellErrorBridge::try_from(err) {
|
||||
Ok(ShellErrorBridge(err)) => err,
|
||||
Err(err) => IoError::new(err, span, None).into(),
|
||||
})?;
|
||||
if read > 0 {
|
||||
out.extend(active.feed(&buf[..read]));
|
||||
out.extend(active.flush());
|
||||
return Ok(true);
|
||||
}
|
||||
// Writes out whatever the end of the stream left unfinished.
|
||||
if let Some(finished) = minifier.take() {
|
||||
out.extend(finished.finish());
|
||||
}
|
||||
Ok(false)
|
||||
})
|
||||
}
|
||||
|
||||
/// The output with its escapes made visible, one line per line it prints, each line feed shown
|
||||
/// as `\n` at the end of its line. Unlike `ansiopt --show`, a line feed at the end of the output
|
||||
/// doesn't start an empty line.
|
||||
fn show(output: &[u8]) -> String {
|
||||
output
|
||||
.split_inclusive(|&b| b == b'\n')
|
||||
.map(visible)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// The report title: the name of the file the input came from, if known.
|
||||
fn title(metadata: Option<&PipelineMetadata>) -> String {
|
||||
metadata
|
||||
.and_then(|metadata| match &metadata.data_source {
|
||||
DataSource::FilePath(path) => path.file_name(),
|
||||
_ => None,
|
||||
})
|
||||
.map_or_else(
|
||||
|| "input".into(),
|
||||
|name| name.to_string_lossy().into_owned(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The sizes of the input and the output, the bytes and sequences of each kind in both, and
|
||||
/// each kind of change with the bytes it saved, as a record.
|
||||
fn stats(report: &Report, span: Span) -> Value {
|
||||
let (input, output) = (Tally::of(report.input), Tally::of(report.output));
|
||||
let breakdown = describe::Category::ALL
|
||||
.into_iter()
|
||||
.filter(|&category| input.bytes(category) + output.bytes(category) > 0)
|
||||
.map(|category| {
|
||||
Value::record(
|
||||
record! {
|
||||
"category" => Value::string(category.label(), span),
|
||||
"input" => Value::filesize(input.bytes(category) as i64, span),
|
||||
"output" => Value::filesize(output.bytes(category) as i64, span),
|
||||
"sequences_in" => Value::int(input.count(category) as i64, span),
|
||||
"sequences_out" => Value::int(output.count(category) as i64, span),
|
||||
},
|
||||
span,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
// The numbers come first: `from` can be a long run of sequences, and a narrow table cuts
|
||||
// off the columns after it.
|
||||
let changes = rewrites(report, true)
|
||||
.into_iter()
|
||||
.map(|rewrite| {
|
||||
Value::record(
|
||||
record! {
|
||||
"saved" => Value::filesize(rewrite.saved as i64, span),
|
||||
"count" => Value::int(rewrite.count as i64, span),
|
||||
"from" => Value::string(rewrite.from, span),
|
||||
"to" => Value::string(rewrite.to, span),
|
||||
},
|
||||
span,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let (bytes_in, bytes_out) = (report.input.len(), report.output.len());
|
||||
Value::record(
|
||||
record! {
|
||||
"input" => Value::filesize(bytes_in as i64, span),
|
||||
"output" => Value::filesize(bytes_out as i64, span),
|
||||
"saved" => Value::filesize(bytes_in as i64 - bytes_out as i64, span),
|
||||
"percent" => Value::float(percent_saved(bytes_in, bytes_out), span),
|
||||
"sequences_in" => Value::int(input.sequences() as i64, span),
|
||||
"sequences_out" => Value::int(output.sequences() as i64, span),
|
||||
"fell_back" => Value::bool(report.fell_back, span),
|
||||
"breakdown" => Value::list(breakdown, span),
|
||||
"changes" => Value::list(changes, span),
|
||||
},
|
||||
span,
|
||||
)
|
||||
}
|
||||
|
||||
/// The sequences at `ranges` of `bytes`, made visible and joined by spaces.
|
||||
fn sequences(bytes: &[u8], ranges: &[Range<usize>]) -> String {
|
||||
ranges
|
||||
.iter()
|
||||
.map(|range| visible(&bytes[range.clone()]))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// One row for each place between two pieces of text where the sequences changed: what the input
|
||||
/// had there, what the output has, and the bytes saved. `--explain` says what each sequence
|
||||
/// means.
|
||||
fn diff(report: &Report, span: Span) -> Value {
|
||||
let rows = report
|
||||
.changed_gaps()
|
||||
.map(|(_, gap)| {
|
||||
Value::record(
|
||||
record! {
|
||||
"from" => Value::string(sequences(report.input, &gap.input), span),
|
||||
"to" => Value::string(sequences(report.output, &gap.output), span),
|
||||
"saved" => Value::filesize(
|
||||
gap.input_bytes() as i64 - gap.output_bytes() as i64,
|
||||
span,
|
||||
),
|
||||
},
|
||||
span,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
Value::list(rows, span)
|
||||
}
|
||||
|
||||
/// One row for each sequence of the input: what it means, what happened to it, what the output
|
||||
/// has in its place and, where ansiopt gives one, why.
|
||||
fn explain(report: &Report, span: Span) -> Value {
|
||||
let Some(trace) = &report.trace else {
|
||||
return Value::list(vec![], span);
|
||||
};
|
||||
let rows = trace
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|entry| entry.fate != Fate::Text)
|
||||
.map(|entry| {
|
||||
let (outputs, why): (&[Range<usize>], Option<&str>) = match &entry.fate {
|
||||
Fate::Rewritten { output, why } => (std::slice::from_ref(output), Some(why)),
|
||||
Fate::Folded { outputs } => (outputs, None),
|
||||
Fate::Merged { output } => (std::slice::from_ref(output), None),
|
||||
Fate::NoEffect { why } | Fate::Superseded { why } | Fate::Barrier { why } => {
|
||||
(&[], Some(why))
|
||||
}
|
||||
Fate::Pending | Fate::Text | Fate::Kept => (&[], None),
|
||||
};
|
||||
let sequence = visible(&report.input[entry.input.clone()]);
|
||||
let meaning = describe(&token_at(report.input, entry.input.clone()));
|
||||
let output = if outputs.is_empty() {
|
||||
Value::nothing(span)
|
||||
} else {
|
||||
Value::string(sequences(report.output, outputs), span)
|
||||
};
|
||||
// `why` is last: it is empty on most rows and a long sentence on the rest, so a
|
||||
// narrow table cuts it off rather than wrap every column around it.
|
||||
Value::record(
|
||||
record! {
|
||||
"sequence" => Value::string(sequence, span),
|
||||
"meaning" => Value::string(meaning, span),
|
||||
"outcome" => Value::string(entry.fate.kind(), span),
|
||||
"output" => output,
|
||||
"why" => why.map_or(Value::nothing(span), |why| Value::string(why, span)),
|
||||
},
|
||||
span,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
Value::list(rows, span)
|
||||
}
|
||||
|
||||
/// The totals come before the text, like the gutter of `ansiopt --hotspot`, so a line too long
|
||||
/// for the table wraps or is cut off instead of the numbers.
|
||||
fn hotspot(report: &Report, color: bool, span: Span) -> Value {
|
||||
let rows = heat_lines(report)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, mut line)| {
|
||||
let (escapes_in, escapes_out) = (line.input(), line.output());
|
||||
// `shade` marks escape bytes after the last character with `⏎`, which reads as a
|
||||
// line feed; the totals still count them.
|
||||
line.end_input = 0;
|
||||
Value::record(
|
||||
record! {
|
||||
"line" => Value::int((i + 1) as i64, span),
|
||||
"escapes_in" => Value::filesize(escapes_in as i64, span),
|
||||
"escapes_out" => Value::filesize(escapes_out as i64, span),
|
||||
"text" => Value::string(shade(&line, Paint { color }), span),
|
||||
},
|
||||
span,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
Value::list(rows, span)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::{self, ErrorKind, Read};
|
||||
|
||||
use ansiopt::Options;
|
||||
use nu_protocol::{ByteStream, ByteStreamType, Signals, Span};
|
||||
|
||||
use super::{AnsiOpt, minify_stream};
|
||||
|
||||
#[test]
|
||||
fn examples_work_as_expected() -> nu_test_support::Result {
|
||||
nu_test_support::test().examples(AnsiOpt)
|
||||
}
|
||||
|
||||
/// Shortens a stream of these chunks, one read each.
|
||||
fn minify_chunks(chunks: &[&'static str]) -> ByteStream {
|
||||
let stream = ByteStream::from_iter(
|
||||
chunks.to_vec(),
|
||||
Span::test_data(),
|
||||
Signals::empty(),
|
||||
ByteStreamType::String,
|
||||
);
|
||||
minify_stream(stream, Options::default())
|
||||
}
|
||||
|
||||
/// The first chunk's reset is written before the second chunk is read, and the second
|
||||
/// reset, split between the chunks, is put back together and dropped. The whole string at
|
||||
/// once would be `\e[;31mhi`.
|
||||
#[test]
|
||||
fn stream_writes_each_chunk_as_it_arrives() {
|
||||
let mut reader = minify_chunks(&["\x1b[0m\x1b[", "0m\x1b[31mhi"])
|
||||
.reader()
|
||||
.expect("a reader");
|
||||
let mut buf = vec![0; 64 * 1024];
|
||||
let reads: Vec<String> = std::iter::from_fn(|| match reader.read(&mut buf) {
|
||||
Ok(0) => None,
|
||||
Ok(read) => Some(String::from_utf8_lossy(&buf[..read]).into_owned()),
|
||||
Err(err) => panic!("{err}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(reads, ["\x1b[m", "\x1b[31mhi"]);
|
||||
}
|
||||
|
||||
/// Reads `bytes`, after one read cut short by a signal.
|
||||
struct InterruptedOnce {
|
||||
interrupted: bool,
|
||||
bytes: &'static [u8],
|
||||
}
|
||||
|
||||
impl Read for InterruptedOnce {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
if !self.interrupted {
|
||||
self.interrupted = true;
|
||||
return Err(ErrorKind::Interrupted.into());
|
||||
}
|
||||
self.bytes.read(buf)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_retries_a_read_cut_short_by_a_signal() {
|
||||
let reader = InterruptedOnce {
|
||||
interrupted: false,
|
||||
bytes: b"\x1b[0m\x1b[0mhi",
|
||||
};
|
||||
let stream = ByteStream::read(
|
||||
reader,
|
||||
Span::test_data(),
|
||||
Signals::empty(),
|
||||
ByteStreamType::String,
|
||||
);
|
||||
let output = minify_stream(stream, Options::default()).into_bytes();
|
||||
assert_eq!(output.expect("no error"), b"\x1b[mhi");
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ mod parse;
|
||||
mod split;
|
||||
mod str_;
|
||||
|
||||
pub use ansi::{Ansi, AnsiLink, AnsiStrip};
|
||||
pub use ansi::{Ansi, AnsiLink, AnsiOpt, AnsiStrip};
|
||||
pub use base::{
|
||||
DecodeBase32, DecodeBase32Hex, DecodeBase64, DecodeHex, EncodeBase32, EncodeBase32Hex,
|
||||
EncodeBase64, EncodeHex,
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
use nu_test_support::prelude::*;
|
||||
use pretty_assertions::assert_matches;
|
||||
use rstest::rstest;
|
||||
|
||||
/// Two resets, red, `hi` and a reset: the second reset does nothing, and the first and the red
|
||||
/// can be one sequence.
|
||||
const REDUNDANT: &str = "$'(ansi reset)(ansi reset)(ansi red)hi(ansi reset)'";
|
||||
|
||||
#[rstest]
|
||||
#[case::default(REDUNDANT, "", "\x1b[;31mhi\x1b[m")]
|
||||
#[case::compat(REDUNDANT, "--compat", "\x1b[0;31mhi\x1b[m")]
|
||||
#[case::rep("'────────'", "--rep", "─\x1b[7b")]
|
||||
// Starting with a reset makes the state known, so the spaces need no reset of their own.
|
||||
#[case::visual(
|
||||
r#""\e[0m\e[31mfoo \e[0m \e[31mbar\e[0m""#,
|
||||
"--visual",
|
||||
"\x1b[;31mfoo bar\x1b[m"
|
||||
)]
|
||||
#[case::visual_rep(
|
||||
r#""\e[0m\e[31m──── \e[0m \e[31m────\e[0m""#,
|
||||
"--visual --rep",
|
||||
"\x1b[;31m─\x1b[3b ─\x1b[3b\x1b[m"
|
||||
)]
|
||||
fn shortens_strings(#[case] input: &str, #[case] flags: &str, #[case] expected: &str) -> Result {
|
||||
test()
|
||||
.run(format!("{input} | ansi opt {flags}"))
|
||||
.expect_value_eq(expected)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortens_binary() -> Result {
|
||||
test()
|
||||
.run("0x[1b 5b 30 6d 1b 5b 30 6d 41] | ansi opt")
|
||||
.expect_value_eq(Value::test_binary(b"\x1b[mA".to_vec()))
|
||||
}
|
||||
|
||||
/// A stream with a sequence split between two chunks comes out shortened. `str join` streams
|
||||
/// each list item as its own chunk, but the chunks can also arrive together, so either result
|
||||
/// is right; the unit tests in `opt.rs` pin the chunk by chunk output.
|
||||
#[test]
|
||||
fn shortens_a_stream_with_a_sequence_split_between_chunks() -> Result {
|
||||
let output: String = test().run(r#"["\e[0m\e[" "0m\e[31mhi"] | str join | ansi opt"#)?;
|
||||
let (chunked, whole) = ("\x1b[m\x1b[31mhi", "\x1b[;31mhi");
|
||||
assert!([chunked, whole].contains(&output.as_str()), "{output:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An error from upstream comes out of the stream as itself, not as an I/O error.
|
||||
#[test]
|
||||
fn stream_passes_upstream_errors_through() -> Result {
|
||||
let code = r#"
|
||||
try {
|
||||
1..3
|
||||
| each {|i| if $i == 2 { error make {msg: boom} } else { $"($i)" } }
|
||||
| str join
|
||||
| ansi opt
|
||||
} catch {|err| $err.msg }
|
||||
"#;
|
||||
test().run(code).expect_value_eq("boom")
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::one_line(REDUNDANT, r"\e[;31mhi\e[m")]
|
||||
// Each line feed shows at the end of its line, and the last one starts no empty line.
|
||||
#[case::lines(r#""\e[0m\e[0ma\nb\n""#, "\\e[ma\\n\nb\\n")]
|
||||
fn show_makes_escapes_visible(#[case] input: &str, #[case] expected: &str) -> Result {
|
||||
test()
|
||||
.run(format!("{input} | ansi opt --show"))
|
||||
.expect_value_eq(expected)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_reports_sizes() -> Result {
|
||||
test()
|
||||
.run(format!(
|
||||
"{REDUNDANT} | ansi opt --stats | reject percent breakdown changes"
|
||||
))
|
||||
.expect_value_eq(test_record! {
|
||||
"input" => Value::test_filesize(19),
|
||||
"output" => Value::test_filesize(11),
|
||||
"saved" => Value::test_filesize(8),
|
||||
"sequences_in" => 4,
|
||||
"sequences_out" => 2,
|
||||
"fell_back" => false,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_groups_changes() -> Result {
|
||||
test()
|
||||
.run(format!("{REDUNDANT} | ansi opt --stats | get changes"))
|
||||
.expect_value_eq(test_table![
|
||||
["saved", "count", "from", "to"];
|
||||
[Value::test_filesize(7), 1, r"\e[Nm\e[Nm\e[Nm", r"\e[;Nm"],
|
||||
[Value::test_filesize(1), 1, r"\e[Nm", r"\e[m"],
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explain_lists_every_sequence() -> Result {
|
||||
// The title is kept as it was, and still listed.
|
||||
test()
|
||||
.run(r#""\e]0;title\a\e[31ma\e[0m" | ansi opt --explain"#)
|
||||
.expect_value_eq(test_table![
|
||||
["sequence", "meaning", "outcome", "output", "why"];
|
||||
[r"\e]0;title\x07", r#"title and icon: "title""#, "kept", (), ()],
|
||||
[r"\e[31m", "fg red", "folded", r"\e[31m", ()],
|
||||
[r"\e[0m", "reset", "folded", r"\e[m", ()],
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_lists_each_change() -> Result {
|
||||
test()
|
||||
.run(format!("{REDUNDANT} | ansi opt --diff"))
|
||||
.expect_value_eq(test_table![
|
||||
["from", "to", "saved"];
|
||||
[r"\e[0m \e[0m \e[31m", r"\e[;31m", Value::test_filesize(7)],
|
||||
[r"\e[0m", r"\e[m", Value::test_filesize(1)],
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hotspot_totals_each_line() -> Result {
|
||||
let code = r#"
|
||||
$env.config.use_ansi_coloring = false
|
||||
$"(ansi red)a(ansi reset)\n(ansi red)b(ansi reset)" | ansi opt --hotspot
|
||||
"#;
|
||||
test().run(code).expect_value_eq(test_table![
|
||||
["line", "escapes_in", "escapes_out", "text"];
|
||||
[1, Value::test_filesize(9), Value::test_filesize(8), "a"],
|
||||
[2, Value::test_filesize(9), Value::test_filesize(8), "b"],
|
||||
])
|
||||
}
|
||||
|
||||
/// A table shows the columns in this order and cuts off the last ones when it is too narrow.
|
||||
#[rstest]
|
||||
#[case::stats(
|
||||
"--stats",
|
||||
&[
|
||||
"input", "output", "saved", "percent", "sequences_in", "sequences_out", "fell_back",
|
||||
"breakdown", "changes",
|
||||
]
|
||||
)]
|
||||
#[case::stats_breakdown(
|
||||
"--stats | get breakdown",
|
||||
&["category", "input", "output", "sequences_in", "sequences_out"]
|
||||
)]
|
||||
#[case::stats_changes("--stats | get changes", &["saved", "count", "from", "to"])]
|
||||
#[case::diff("--diff", &["from", "to", "saved"])]
|
||||
#[case::explain(
|
||||
"--explain",
|
||||
&["sequence", "meaning", "outcome", "output", "why"]
|
||||
)]
|
||||
#[case::hotspot("--hotspot", &["line", "escapes_in", "escapes_out", "text"])]
|
||||
fn view_columns_are_in_reading_order(#[case] view: &str, #[case] columns: &[&str]) -> Result {
|
||||
test()
|
||||
.run(format!("{REDUNDANT} | ansi opt {view} | columns"))
|
||||
.expect_value_eq(columns.to_vec())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_returns_a_report() -> Result {
|
||||
let html: String = test().run(format!("{REDUNDANT} | ansi opt --html"))?;
|
||||
assert_contains("<title>ansiopt report: input</title>", html);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The error names the flags in the order they were typed.
|
||||
#[test]
|
||||
fn views_are_exclusive() -> Result {
|
||||
let err = test()
|
||||
.run(format!("{REDUNDANT} | ansi opt --diff --stats"))
|
||||
.expect_shell_error()?;
|
||||
assert_matches!(
|
||||
err,
|
||||
ShellError::IncompatibleParameters { left_message, right_message, .. }
|
||||
if left_message == "--diff returns one view" && right_message == "--stats returns another"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
mod ansi_;
|
||||
mod ansi_opt;
|
||||
mod char_;
|
||||
mod kill;
|
||||
@@ -712,8 +712,8 @@ fn table_colors() -> Result {
|
||||
let mut tester = test();
|
||||
let colored = indoc! {"
|
||||
\u{1b}[39m╭───┬───╮\u{1b}[0m
|
||||
\u{1b}[39m│\u{1b}[0m \u{1b}[1;32ma\u{1b}[0m \u{1b}[39m│\u{1b}[0m \u{1b}[39m1\u{1b}[0m \u{1b}[39m│\u{1b}[0m
|
||||
\u{1b}[39m│\u{1b}[0m \u{1b}[1;32mb\u{1b}[0m \u{1b}[39m│\u{1b}[0m \u{1b}[39m2\u{1b}[0m \u{1b}[39m│\u{1b}[0m
|
||||
\u{1b}[39m│\u{1b}[0m \u{1b}[1;32ma\u{1b}[0m │ \u{1b}[39m1\u{1b}[0m │
|
||||
\u{1b}[39m│\u{1b}[0m \u{1b}[1;32mb\u{1b}[0m │ \u{1b}[39m2\u{1b}[0m │
|
||||
\u{1b}[39m╰───┴───╯\u{1b}[0m"};
|
||||
tester
|
||||
.run_with_data(
|
||||
@@ -755,8 +755,8 @@ fn table_semver_list_colors() -> Result {
|
||||
// cyan_bold = bold cyan (1;36)
|
||||
let colored = indoc! {"
|
||||
\u{1b}[39m╭───┬───────╮\u{1b}[0m
|
||||
\u{1b}[39m│\u{1b}[0m \u{1b}[1;32m0\u{1b}[0m \u{1b}[39m│\u{1b}[0m \u{1b}[1;36m1.0.0\u{1b}[0m \u{1b}[39m│\u{1b}[0m
|
||||
\u{1b}[39m│\u{1b}[0m \u{1b}[1;32m1\u{1b}[0m \u{1b}[39m│\u{1b}[0m \u{1b}[1;36m2.0.0\u{1b}[0m \u{1b}[39m│\u{1b}[0m
|
||||
\u{1b}[39m│\u{1b}[0m \u{1b}[1;32m0\u{1b}[0m │ \u{1b}[1;36m1.0.0\u{1b}[0m │
|
||||
\u{1b}[39m│\u{1b}[0m \u{1b}[1;32m1\u{1b}[0m │ \u{1b}[1;36m2.0.0\u{1b}[0m │
|
||||
\u{1b}[39m╰───┴───────╯\u{1b}[0m
|
||||
"};
|
||||
tester
|
||||
@@ -786,12 +786,12 @@ fn table_empty_colors() -> Result {
|
||||
let mut tester = test();
|
||||
let empty_list_colored = indoc! {"
|
||||
\u{1b}[39m╭────────────╮\u{1b}[0m
|
||||
\u{1b}[39m│\u{1b}[0m \u{1b}[2mempty list\u{1b}[0m \u{1b}[39m│\u{1b}[0m
|
||||
\u{1b}[39m│\u{1b}[0m \u{1b}[2mempty list\u{1b}[0m │
|
||||
\u{1b}[39m╰────────────╯\u{1b}[0m
|
||||
"};
|
||||
let empty_record_colored = indoc! {"
|
||||
\u{1b}[39m╭──────────────╮\u{1b}[0m
|
||||
\u{1b}[39m│\u{1b}[0m \u{1b}[2mempty record\u{1b}[0m \u{1b}[39m│\u{1b}[0m
|
||||
\u{1b}[39m│\u{1b}[0m \u{1b}[2mempty record\u{1b}[0m │
|
||||
\u{1b}[39m╰──────────────╯\u{1b}[0m"};
|
||||
tester
|
||||
.run("$env.config.use_ansi_coloring = true; [] | table")
|
||||
|
||||
@@ -90,9 +90,9 @@ fn configure_stream_size() -> Result {
|
||||
fn metadata_path_columns_single() -> Result {
|
||||
let expected = indoc! {"
|
||||
\u{1b}[39m╭───┬──────╮\u{1b}[0m
|
||||
\u{1b}[39m│\u{1b}[0m \u{1b}[1;32m#\u{1b}[0m \u{1b}[39m│\u{1b}[0m \u{1b}[1;32mname\u{1b}[0m \u{1b}[39m│\u{1b}[0m
|
||||
\u{1b}[39m│\u{1b}[0m \u{1b}[1;32m#\u{1b}[0m │ \u{1b}[1;32mname\u{1b}[0m │
|
||||
\u{1b}[39m├───┼──────┤\u{1b}[0m
|
||||
\u{1b}[39m│\u{1b}[0m \u{1b}[1;32m0\u{1b}[0m \u{1b}[39m│\u{1b}[0m \u{1b}[39m\u{1b}[38;5;81msrc\u{1b}[0m\u{1b}[0m \u{1b}[39m│\u{1b}[0m
|
||||
\u{1b}[39m│\u{1b}[0m \u{1b}[1;32m0\u{1b}[0m │ \u{1b}[38;5;81msrc\u{1b}[0m │
|
||||
\u{1b}[39m╰───┴──────╯\u{1b}[0m
|
||||
"};
|
||||
test()
|
||||
@@ -114,9 +114,9 @@ fn metadata_path_columns_single() -> Result {
|
||||
fn metadata_path_columns_multiple() -> Result {
|
||||
let expected = indoc! {"
|
||||
\u{1b}[39m╭───┬─────┬─────────╮\u{1b}[0m
|
||||
\u{1b}[39m│\u{1b}[0m \u{1b}[1;32m#\u{1b}[0m \u{1b}[39m│\u{1b}[0m \u{1b}[1;32mdir\u{1b}[0m \u{1b}[39m│\u{1b}[0m \u{1b}[1;32mfile\u{1b}[0m \u{1b}[39m│\u{1b}[0m
|
||||
\u{1b}[39m│\u{1b}[0m \u{1b}[1;32m#\u{1b}[0m │ \u{1b}[1;32mdir\u{1b}[0m │ \u{1b}[1;32mfile\u{1b}[0m │
|
||||
\u{1b}[39m├───┼─────┼─────────┤\u{1b}[0m
|
||||
\u{1b}[39m│\u{1b}[0m \u{1b}[1;32m0\u{1b}[0m \u{1b}[39m│\u{1b}[0m \u{1b}[39m\u{1b}[38;5;81msrc\u{1b}[0m\u{1b}[0m \u{1b}[39m│\u{1b}[0m \u{1b}[39m\u{1b}[38;5;48mmain.rs\u{1b}[0m\u{1b}[0m \u{1b}[39m│\u{1b}[0m
|
||||
\u{1b}[39m│\u{1b}[0m \u{1b}[1;32m0\u{1b}[0m │ \u{1b}[38;5;81msrc\u{1b}[0m │ \u{1b}[38;5;48mmain.rs\u{1b}[0m │
|
||||
\u{1b}[39m╰───┴─────┴─────────╯\u{1b}[0m
|
||||
"};
|
||||
test()
|
||||
@@ -138,9 +138,9 @@ fn metadata_path_columns_multiple() -> Result {
|
||||
fn metadata_path_columns_multiple_with_icons() -> Result {
|
||||
let expected = indoc! {"
|
||||
\u{1b}[39m╭───┬────────┬────────────╮\u{1b}[0m
|
||||
\u{1b}[39m│\u{1b}[0m \u{1b}[1;32m#\u{1b}[0m \u{1b}[39m│\u{1b}[0m \u{1b}[1;32mdir\u{1b}[0m \u{1b}[39m│\u{1b}[0m \u{1b}[1;32mfile\u{1b}[0m \u{1b}[39m│\u{1b}[0m
|
||||
\u{1b}[39m│\u{1b}[0m \u{1b}[1;32m#\u{1b}[0m │ \u{1b}[1;32mdir\u{1b}[0m │ \u{1b}[1;32mfile\u{1b}[0m │
|
||||
\u{1b}[39m├───┼────────┼────────────┤\u{1b}[0m
|
||||
\u{1b}[39m│\u{1b}[0m \u{1b}[1;32m0\u{1b}[0m \u{1b}[39m│\u{1b}[0m \u{1b}[39m\u{1b}[38;2;126;142;168m\u{f115}\u{1b}[0m \u{1b}[38;5;81msrc\u{1b}[0m\u{1b}[0m \u{1b}[39m│\u{1b}[0m \u{1b}[39m\u{1b}[38;2;222;165;132m\u{e68b}\u{1b}[0m \u{1b}[38;5;48mmain.rs\u{1b}[0m\u{1b}[0m \u{1b}[39m│\u{1b}[0m
|
||||
\u{1b}[39m│\u{1b}[0m \u{1b}[1;32m0\u{1b}[0m │ \u{1b}[38;2;126;142;168m\u{f115}\u{1b}[0m \u{1b}[38;5;81msrc\u{1b}[0m │ \u{1b}[38;2;222;165;132m\u{e68b}\u{1b}[0m \u{1b}[38;5;48mmain.rs\u{1b}[0m │
|
||||
\u{1b}[39m╰───┴────────┴────────────╯\u{1b}[0m
|
||||
"};
|
||||
test()
|
||||
|
||||
+223
-12
@@ -9,8 +9,10 @@ use tabled::{
|
||||
builder::Builder,
|
||||
grid::{
|
||||
ansi::ANSIBuf,
|
||||
colors::Colors,
|
||||
config::{
|
||||
AlignmentHorizontal, ColoredConfig, Entity, Indent, Position, Sides, SpannedConfig,
|
||||
AlignmentHorizontal, Border, Borders, ColoredConfig, Entity, Indent, Position, Sides,
|
||||
SpannedConfig,
|
||||
},
|
||||
dimension::{CompleteDimension, PeekableGridDimension},
|
||||
records::{
|
||||
@@ -653,10 +655,13 @@ fn draw_table(
|
||||
|
||||
set_styles(&mut table, t.styles, &structure);
|
||||
set_indent(&mut table, t.config.indent);
|
||||
load_theme(&mut table, &t.config.theme, &structure, sep_color);
|
||||
load_theme(&mut table, &t.config.theme, &structure);
|
||||
let ctrl = DimensionCtrl::new(width, current, t.config.trim.clone(), &t.config, t.heights);
|
||||
table.with(ctrl);
|
||||
table_set_border_header(&mut table, head, &t.config);
|
||||
if let Some(style) = sep_color {
|
||||
color_borders(&mut table, style);
|
||||
}
|
||||
|
||||
let string = table.to_string();
|
||||
Some(string)
|
||||
@@ -901,12 +906,7 @@ fn colorize_table(table: &mut Table, styles: CellConfiguration<Color>, structure
|
||||
}
|
||||
}
|
||||
|
||||
fn load_theme(
|
||||
table: &mut Table,
|
||||
theme: &TableTheme,
|
||||
structure: &TableStructure,
|
||||
sep_color: Option<Style>,
|
||||
) {
|
||||
fn load_theme(table: &mut Table, theme: &TableTheme, structure: &TableStructure) {
|
||||
let with_header = table.count_rows() > 1 && structure.with_header;
|
||||
let with_footer = with_header && structure.with_footer;
|
||||
let mut theme = theme.as_base().clone();
|
||||
@@ -920,12 +920,117 @@ fn load_theme(
|
||||
}
|
||||
|
||||
table.with(theme);
|
||||
}
|
||||
|
||||
if let Some(style) = sep_color {
|
||||
let color = convert_style(style);
|
||||
let color = ANSIBuf::from(color);
|
||||
table.get_config_mut().set_border_color_default(color);
|
||||
/// Colors the borders with the separator style, once the table's text is final.
|
||||
///
|
||||
/// A colored border ends with a reset, which leaves the terminal at its default colors. With
|
||||
/// the default separator style (the default foreground), only the borders that start a line
|
||||
/// keep their color: every horizontal line, and the first vertical border of each cell line,
|
||||
/// which is the left border or, in themes without one, the first inner border. Each line then
|
||||
/// starts as it would with every border colored, whatever was printed before it, even when it
|
||||
/// is printed on its own. From that border on the colors are the defaults, and a border or a
|
||||
/// cell colored with the default foreground looks the same uncolored, as long as every cell
|
||||
/// line ends with the defaults (see [`cells_to_uncolor`]). The table looks the same, line by
|
||||
/// line, with fewer escape sequences.
|
||||
fn color_borders(table: &mut Table, style: Style) {
|
||||
let color = ANSIBuf::from(convert_style(style));
|
||||
let count_rows = table.count_rows();
|
||||
let count_columns = table.count_columns();
|
||||
let first_vertical =
|
||||
(0..=count_columns).find(|&col| table.get_config().has_vertical(col, count_columns));
|
||||
let uncolored = first_vertical.and_then(|col| cells_to_uncolor(table, col));
|
||||
let cfg = table.get_config_mut();
|
||||
let (Some(first_vertical), Some(uncolored)) = (first_vertical, uncolored) else {
|
||||
cfg.set_border_color_default(color);
|
||||
return;
|
||||
};
|
||||
|
||||
for pos in uncolored {
|
||||
cfg.set_color(pos.into(), ANSIBuf::default());
|
||||
}
|
||||
|
||||
if style != nu_ansi_term::Color::Default.normal() {
|
||||
cfg.set_border_color_default(color);
|
||||
return;
|
||||
}
|
||||
|
||||
let line = || Some(color.clone());
|
||||
cfg.set_borders_color(Borders {
|
||||
top: line(),
|
||||
top_left: line(),
|
||||
top_right: line(),
|
||||
top_intersection: line(),
|
||||
horizontal: line(),
|
||||
left_intersection: line(),
|
||||
right_intersection: line(),
|
||||
intersection: line(),
|
||||
bottom: line(),
|
||||
bottom_left: line(),
|
||||
bottom_right: line(),
|
||||
bottom_intersection: line(),
|
||||
// The left border is colored once for every row, an inner one row by row.
|
||||
left: (first_vertical == 0).then(|| color.clone()),
|
||||
..Borders::default()
|
||||
});
|
||||
if first_vertical > 0 {
|
||||
for row in 0..count_rows {
|
||||
let border = Border {
|
||||
left: line(),
|
||||
..Border::default()
|
||||
};
|
||||
cfg.set_border_color(Position::new(row, first_vertical), border);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The cells right of the first vertical border that are colored only with the default
|
||||
/// foreground, if the terminal's colors are its defaults after every cell line as printed;
|
||||
/// `None` if a cell line can end with other colors.
|
||||
///
|
||||
/// Cells left of the first vertical border keep their color, since nothing before them on
|
||||
/// their line resets colors left on by whatever was printed before it. A colored cell line is
|
||||
/// printed between the color's prefix and suffix, so it ends with the defaults when the suffix
|
||||
/// is a reset. An uncolored one has to end with them by itself (see
|
||||
/// [`ends_with_default_colors`]).
|
||||
fn cells_to_uncolor(table: &Table, first_vertical: usize) -> Option<Vec<Position>> {
|
||||
let default_fg = ANSIBuf::from(convert_style(nu_ansi_term::Color::Default.normal()));
|
||||
let colors = table.get_config().get_colors();
|
||||
let records = table.get_records();
|
||||
let mut uncolored = Vec::new();
|
||||
for row in 0..table.count_rows() {
|
||||
for col in 0..table.count_columns() {
|
||||
let pos = Position::new(row, col);
|
||||
let ends_clean = || {
|
||||
records
|
||||
.get_text(pos)
|
||||
.split('\n')
|
||||
.all(ends_with_default_colors)
|
||||
};
|
||||
match colors.get_color(pos).filter(|color| !color.is_empty()) {
|
||||
None if !ends_clean() => return None,
|
||||
Some(color) if color.get_suffix() != RESET => return None,
|
||||
Some(color) if col >= first_vertical && *color == default_fg && ends_clean() => {
|
||||
uncolored.push(pos)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(uncolored)
|
||||
}
|
||||
|
||||
const RESET: &str = "\x1b[0m";
|
||||
|
||||
/// Whether a line that starts with the terminal's default colors ends with them: it has no
|
||||
/// escape sequence, or its last one is a reset. A C1 control sequence introducer (U+009B),
|
||||
/// which some terminals read like `ESC [`, never counts as a reset.
|
||||
fn ends_with_default_colors(line: &str) -> bool {
|
||||
line.rfind(['\x1b', '\u{9b}']).is_none_or(|i| {
|
||||
let last = &line[i..];
|
||||
last.starts_with(RESET) || last.starts_with("\x1b[m")
|
||||
})
|
||||
}
|
||||
|
||||
/// Plans how the columns of a table fit `termwidth`.
|
||||
@@ -1429,3 +1534,109 @@ pub fn get_color_if_exists(c: &Color) -> Option<Color> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A table with the default separator style, the given theme and these rows of cell texts.
|
||||
fn colored_table(theme: TableTheme, rows: &[&[&str]]) -> NuTable {
|
||||
let mut table = NuTable::new(rows.len(), rows[0].len());
|
||||
table.set_theme(theme);
|
||||
table.set_structure(false, false, false);
|
||||
table.set_border_color(nu_ansi_term::Color::Default.normal());
|
||||
for (row, cells) in rows.iter().enumerate() {
|
||||
for (col, text) in cells.iter().enumerate() {
|
||||
table.insert((row, col), String::from(*text));
|
||||
}
|
||||
}
|
||||
table
|
||||
}
|
||||
|
||||
/// Colors these cells with the default foreground.
|
||||
fn color_default_foreground(table: &mut NuTable, cells: &[(usize, usize)]) {
|
||||
let default_fg = TextStyle::new().style(nu_ansi_term::Color::Default.normal());
|
||||
for &pos in cells {
|
||||
table.insert_style(pos, default_fg);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_separator_colors_horizontal_lines_and_first_borders() {
|
||||
let table = colored_table(
|
||||
TableTheme::rounded(),
|
||||
&[&["a", "b"], &["\x1b[31mc\x1b[0m", "d\ne"]],
|
||||
);
|
||||
assert_eq!(
|
||||
table.draw(80).expect("fits"),
|
||||
"\x1b[39m╭───┬───╮\x1b[0m\n\
|
||||
\x1b[39m│\x1b[0m a │ b │\n\
|
||||
\x1b[39m│\x1b[0m \x1b[31mc\x1b[0m │ d │\n\
|
||||
\x1b[39m│\x1b[0m │ e │\n\
|
||||
\x1b[39m╰───┴───╯\x1b[0m"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_leaving_colors_on_colors_every_border() {
|
||||
let table = colored_table(TableTheme::rounded(), &[&["a", "b"], &["\x1b[41mc", "d"]]);
|
||||
assert_eq!(
|
||||
table.draw(80).expect("fits"),
|
||||
"\x1b[39m╭───┬───╮\x1b[0m\n\
|
||||
\x1b[39m│\x1b[0m a \x1b[39m│\x1b[0m b \x1b[39m│\x1b[0m\n\
|
||||
\x1b[39m│\x1b[0m \x1b[41mc \x1b[39m│\x1b[0m d \x1b[39m│\x1b[0m\n\
|
||||
\x1b[39m╰───┴───╯\x1b[0m"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_without_left_border_colors_its_first_inner_border() {
|
||||
let mut table = colored_table(TableTheme::psql(), &[&["a", "b", "c"], &["d", "e", "f"]]);
|
||||
color_default_foreground(&mut table, &[(0, 0), (0, 1)]);
|
||||
assert_eq!(
|
||||
table.draw(80).expect("fits"),
|
||||
" \x1b[39ma\x1b[0m \x1b[39m|\x1b[0m b | c \n d \x1b[39m|\x1b[0m e | f "
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_foreground_cells_are_uncolored_unless_they_leave_colors_on() {
|
||||
let mut table = colored_table(TableTheme::rounded(), &[&["a", "b"], &["\x1b[41mc", "d"]]);
|
||||
color_default_foreground(&mut table, &[(0, 0), (1, 0)]);
|
||||
assert_eq!(
|
||||
table.draw(80).expect("fits"),
|
||||
"\x1b[39m╭───┬───╮\x1b[0m\n\
|
||||
\x1b[39m│\x1b[0m a │ b │\n\
|
||||
\x1b[39m│\x1b[0m \x1b[39m\x1b[41mc\x1b[0m │ d │\n\
|
||||
\x1b[39m╰───┴───╯\x1b[0m"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_separator_colors_every_border_and_uncolors_default_foreground_cells() {
|
||||
let mut table = colored_table(TableTheme::rounded(), &[&["a", "b"], &["c", "d"]]);
|
||||
table.set_border_color(nu_ansi_term::Color::Red.normal());
|
||||
color_default_foreground(&mut table, &[(0, 0), (1, 0)]);
|
||||
assert_eq!(
|
||||
table.draw(80).expect("fits"),
|
||||
"\x1b[31m╭───┬───╮\x1b[0m\n\
|
||||
\x1b[31m│\x1b[0m a \x1b[31m│\x1b[0m b \x1b[31m│\x1b[0m\n\
|
||||
\x1b[31m│\x1b[0m c \x1b[31m│\x1b[0m d \x1b[31m│\x1b[0m\n\
|
||||
\x1b[31m╰───┴───╯\x1b[0m"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_ending_with_default_colors() {
|
||||
let clean = ["", "plain", "\x1b[31mx\x1b[0m", "\x1b[31mx\x1b[m tail"];
|
||||
let dirty = [
|
||||
"\x1b[31mx",
|
||||
"\x1b[0m\x1b]8;;\x1b\\",
|
||||
"\x1b[1mx\x1b[22m",
|
||||
"x\x1b[0m\u{9b}41m",
|
||||
"\x1b[31mx\u{9b}0m",
|
||||
];
|
||||
assert!(clean.into_iter().all(ends_with_default_colors));
|
||||
assert!(!dirty.into_iter().any(ends_with_default_colors));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user