feat(to-md): add support for centering columns via CellPaths (#14552) (#15861)

Closes #14552 

# Description

Implemented a new flag to the ```to md``` command to center specific
columns in Markdown table output using a list of CellPaths.
This enhances formatting control for users exporting tables to markdown.

## Example

For the table:

```shell
let t = version | select version build_time | transpose k v
```

```
╭───┬────────────┬────────────────────────────╮
│ # │     k      │             v              │
├───┼────────────┼────────────────────────────┤
│ 0 │ version    │ 0.104.1                    │
│ 1 │ build_time │ 2025-05-21 11:15:45 +01:00 │
╰───┴────────────┴────────────────────────────╯
```

Running ```$t | to md``` or ```$t | to md --pretty``` gives us,
respectively:

```
|k|v|
|-|-|
|version|0.104.1|
|build_time|2025-05-21 11:15:45 +01:00|
```

|k|v|
|-|-|
|version|0.104.1|
|build_time|2025-05-21 11:15:45 +01:00|

and

```
| k          | v                          |
| ---------- | -------------------------- |
| version    | 0.104.1                    |
| build_time | 2025-05-21 11:15:45 +01:00 |
```

| k          | v                          |
| ---------- | -------------------------- |
| version    | 0.104.1                    |
| build_time | 2025-05-21 11:15:45 +01:00 |

With the new ```center``` flag, when adding ```--center [v]``` to the
previous commands, we obtain, respectively:

```
|k|v|
|-|:-:|
|version|0.104.1|
|build_time|2025-05-21 11:15:45 +01:00|
```

|k|v|
|-|:-:|
|version|0.104.1|
|build_time|2025-05-21 11:15:45 +01:00|

and

```
| k          |             v              |
| ---------- |:--------------------------:|
| version    |          0.104.1           |
| build_time | 2025-05-21 11:15:45 +01:00 |
```

| k          |             v              |
| ---------- |:--------------------------:|
| version    |          0.104.1           |
| build_time | 2025-05-21 11:15:45 +01:00 |

The new ```center``` option, as demonstrated in the example, not only
formats the Markdown table to center columns but also, when paired with
```pretty```, it also centers the string values within those columns.

The logic works by extracting the column from the CellPath and applying
centering. So, ```--center [1.v]``` is also valid and centers the
```v``` column.
You can also specify multiple columns, for instance, ```--center [v
k]``` will center both columns in the example above.

# User-Facing Changes

The ```to md``` command will support column centering with the new
```center``` flag.

# Tests + Formatting

Added test cases to ensure correct behaviour.
fmt + clippy OK.

# After Submitting

The command documentation needs to be updated with the new ```center```
flag and an example.


Co-authored-by: Marco Cunha <marcomarquesdacunha@tecnico.ulisboa.pt>

Co-authored-by: Marco Cunha <marcomarquesdacunha@tecnico.ulisboa.pt>
This commit is contained in:
André Lazenga 2025-06-09 12:07:09 +01:00 committed by GitHub
parent 61d59f13fa
commit 96a886eb84
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -1,7 +1,8 @@
use indexmap::IndexMap; use indexmap::IndexMap;
use nu_cmd_base::formats::to::delimited::merge_descriptors; use nu_cmd_base::formats::to::delimited::merge_descriptors;
use nu_engine::command_prelude::*; use nu_engine::command_prelude::*;
use nu_protocol::Config; use nu_protocol::{Config, ast::PathMember};
use std::collections::HashSet;
#[derive(Clone)] #[derive(Clone)]
pub struct ToMd; pub struct ToMd;
@ -24,6 +25,12 @@ impl Command for ToMd {
"treat each row as markdown syntax element", "treat each row as markdown syntax element",
Some('e'), Some('e'),
) )
.named(
"center",
SyntaxShape::List(Box::new(SyntaxShape::CellPath)),
"Formats the Markdown table to center given columns",
Some('c'),
)
.category(Category::Formats) .category(Category::Formats)
} }
@ -64,6 +71,13 @@ impl Command for ToMd {
"|foo|bar|\n|-|-|\n|1|2|\n|3|4|\n|foo|\n|-|\n|5|", "|foo|bar|\n|-|-|\n|1|2|\n|3|4|\n|foo|\n|-|\n|5|",
)), )),
}, },
Example {
description: "Center a column of a markdown table",
example: "[ {foo: 1, bar: 2} {foo: 3, bar: 4}] | to md --pretty --center [bar]",
result: Some(Value::test_string(
"| foo | bar |\n| --- |:---:|\n| 1 | 2 |\n| 3 | 4 |",
)),
},
] ]
} }
@ -77,8 +91,9 @@ impl Command for ToMd {
let head = call.head; let head = call.head;
let pretty = call.has_flag(engine_state, stack, "pretty")?; let pretty = call.has_flag(engine_state, stack, "pretty")?;
let per_element = call.has_flag(engine_state, stack, "per-element")?; let per_element = call.has_flag(engine_state, stack, "per-element")?;
let center: Option<Vec<CellPath>> = call.get_flag(engine_state, stack, "center")?;
let config = stack.get_config(engine_state); let config = stack.get_config(engine_state);
to_md(input, pretty, per_element, &config, head) to_md(input, pretty, per_element, &center, &config, head)
} }
} }
@ -86,6 +101,7 @@ fn to_md(
input: PipelineData, input: PipelineData,
pretty: bool, pretty: bool,
per_element: bool, per_element: bool,
center: &Option<Vec<CellPath>>,
config: &Config, config: &Config,
head: Span, head: Span,
) -> Result<PipelineData, ShellError> { ) -> Result<PipelineData, ShellError> {
@ -102,9 +118,12 @@ fn to_md(
.into_iter() .into_iter()
.map(move |val| match val { .map(move |val| match val {
Value::List { .. } => { Value::List { .. } => {
format!("{}\n", table(val.into_pipeline_data(), pretty, config)) format!(
"{}\n",
table(val.into_pipeline_data(), pretty, center, config)
)
} }
other => fragment(other, pretty, config), other => fragment(other, pretty, center, config),
}) })
.collect::<Vec<String>>() .collect::<Vec<String>>()
.join("") .join("")
@ -113,11 +132,13 @@ fn to_md(
) )
.into_pipeline_data_with_metadata(Some(metadata))); .into_pipeline_data_with_metadata(Some(metadata)));
} }
Ok(Value::string(table(grouped_input, pretty, config), head) Ok(
.into_pipeline_data_with_metadata(Some(metadata))) Value::string(table(grouped_input, pretty, center, config), head)
.into_pipeline_data_with_metadata(Some(metadata)),
)
} }
fn fragment(input: Value, pretty: bool, config: &Config) -> String { fn fragment(input: Value, pretty: bool, center: &Option<Vec<CellPath>>, config: &Config) -> String {
let mut out = String::new(); let mut out = String::new();
if let Value::Record { val, .. } = &input { if let Value::Record { val, .. } = &input {
@ -128,13 +149,13 @@ fn fragment(input: Value, pretty: bool, config: &Config) -> String {
"h2" => "## ".to_string(), "h2" => "## ".to_string(),
"h3" => "### ".to_string(), "h3" => "### ".to_string(),
"blockquote" => "> ".to_string(), "blockquote" => "> ".to_string(),
_ => return table(input.into_pipeline_data(), pretty, config), _ => return table(input.into_pipeline_data(), pretty, center, config),
}; };
out.push_str(&markup); out.push_str(&markup);
out.push_str(&data.to_expanded_string("|", config)); out.push_str(&data.to_expanded_string("|", config));
} }
_ => out = table(input.into_pipeline_data(), pretty, config), _ => out = table(input.into_pipeline_data(), pretty, center, config),
} }
} else { } else {
out = input.to_expanded_string("|", config) out = input.to_expanded_string("|", config)
@ -161,7 +182,12 @@ fn collect_headers(headers: &[String]) -> (Vec<String>, Vec<usize>) {
(escaped_headers, column_widths) (escaped_headers, column_widths)
} }
fn table(input: PipelineData, pretty: bool, config: &Config) -> String { fn table(
input: PipelineData,
pretty: bool,
center: &Option<Vec<CellPath>>,
config: &Config,
) -> String {
let vec_of_values = input let vec_of_values = input
.into_iter() .into_iter()
.flat_map(|val| match val { .flat_map(|val| match val {
@ -225,7 +251,13 @@ fn table(input: PipelineData, pretty: bool, config: &Config) -> String {
{ {
String::from("") String::from("")
} else { } else {
get_output_string(&escaped_headers, &escaped_rows, &column_widths, pretty) get_output_string(
&escaped_headers,
&escaped_rows,
&column_widths,
pretty,
center,
)
.trim() .trim()
.to_string() .to_string()
}; };
@ -271,20 +303,42 @@ fn get_output_string(
rows: &[Vec<String>], rows: &[Vec<String>],
column_widths: &[usize], column_widths: &[usize],
pretty: bool, pretty: bool,
center: &Option<Vec<CellPath>>,
) -> String { ) -> String {
let mut output_string = String::new(); let mut output_string = String::new();
let mut to_center: HashSet<String> = HashSet::new();
if let Some(center_vec) = center.as_ref() {
for cell_path in center_vec {
if let Some(PathMember::String { val, .. }) = cell_path
.members
.iter()
.find(|member| matches!(member, PathMember::String { .. }))
{
to_center.insert(val.clone());
}
}
}
if !headers.is_empty() { if !headers.is_empty() {
output_string.push('|'); output_string.push('|');
for i in 0..headers.len() { for i in 0..headers.len() {
if pretty { if pretty {
output_string.push(' '); output_string.push(' ');
if center.is_some() && to_center.contains(&headers[i]) {
output_string.push_str(&get_centered_string(
headers[i].clone(),
column_widths[i],
' ',
));
} else {
output_string.push_str(&get_padded_string( output_string.push_str(&get_padded_string(
headers[i].clone(), headers[i].clone(),
column_widths[i], column_widths[i],
' ', ' ',
)); ));
}
output_string.push(' '); output_string.push(' ');
} else { } else {
output_string.push_str(&headers[i]); output_string.push_str(&headers[i]);
@ -295,11 +349,21 @@ fn get_output_string(
output_string.push_str("\n|"); output_string.push_str("\n|");
for &col_width in column_widths.iter().take(headers.len()) { for i in 0..headers.len() {
let centered_column = center.is_some() && to_center.contains(&headers[i]);
let border_char = if centered_column { ':' } else { ' ' };
if pretty { if pretty {
output_string.push(' '); output_string.push(border_char);
output_string.push_str(&get_padded_string(String::from("-"), col_width, '-')); output_string.push_str(&get_padded_string(
output_string.push(' '); String::from("-"),
column_widths[i],
'-',
));
output_string.push(border_char);
} else if centered_column {
output_string.push(':');
output_string.push('-');
output_string.push(':');
} else { } else {
output_string.push('-'); output_string.push('-');
} }
@ -318,7 +382,19 @@ fn get_output_string(
for i in 0..row.len() { for i in 0..row.len() {
if pretty && column_widths.get(i).is_some() { if pretty && column_widths.get(i).is_some() {
output_string.push(' '); output_string.push(' ');
output_string.push_str(&get_padded_string(row[i].clone(), column_widths[i], ' ')); if center.is_some() && to_center.contains(&headers[i]) {
output_string.push_str(&get_centered_string(
row[i].clone(),
column_widths[i],
' ',
));
} else {
output_string.push_str(&get_padded_string(
row[i].clone(),
column_widths[i],
' ',
));
}
output_string.push(' '); output_string.push(' ');
} else { } else {
output_string.push_str(&row[i]); output_string.push_str(&row[i]);
@ -335,6 +411,24 @@ fn get_output_string(
output_string output_string
} }
fn get_centered_string(text: String, desired_length: usize, padding_character: char) -> String {
let total_padding = if text.len() > desired_length {
0
} else {
desired_length - text.len()
};
let repeat_left = total_padding / 2;
let repeat_right = total_padding - repeat_left;
format!(
"{}{}{}",
padding_character.to_string().repeat(repeat_left),
text,
padding_character.to_string().repeat(repeat_right)
)
}
fn get_padded_string(text: String, desired_length: usize, padding_character: char) -> String { fn get_padded_string(text: String, desired_length: usize, padding_character: char) -> String {
let repeat_length = if text.len() > desired_length { let repeat_length = if text.len() > desired_length {
0 0
@ -355,7 +449,7 @@ mod tests {
use super::*; use super::*;
use nu_cmd_lang::eval_pipeline_without_terminal_expression; use nu_cmd_lang::eval_pipeline_without_terminal_expression;
use nu_protocol::{Config, IntoPipelineData, Value, record}; use nu_protocol::{Config, IntoPipelineData, Value, casing::Casing, record};
fn one(string: &str) -> String { fn one(string: &str) -> String {
string string
@ -381,7 +475,10 @@ mod tests {
"H1" => Value::test_string("Ecuador"), "H1" => Value::test_string("Ecuador"),
}); });
assert_eq!(fragment(value, false, &Config::default()), "# Ecuador\n"); assert_eq!(
fragment(value, false, &None, &Config::default()),
"# Ecuador\n"
);
} }
#[test] #[test]
@ -390,7 +487,10 @@ mod tests {
"H2" => Value::test_string("Ecuador"), "H2" => Value::test_string("Ecuador"),
}); });
assert_eq!(fragment(value, false, &Config::default()), "## Ecuador\n"); assert_eq!(
fragment(value, false, &None, &Config::default()),
"## Ecuador\n"
);
} }
#[test] #[test]
@ -399,7 +499,10 @@ mod tests {
"H3" => Value::test_string("Ecuador"), "H3" => Value::test_string("Ecuador"),
}); });
assert_eq!(fragment(value, false, &Config::default()), "### Ecuador\n"); assert_eq!(
fragment(value, false, &None, &Config::default()),
"### Ecuador\n"
);
} }
#[test] #[test]
@ -408,7 +511,10 @@ mod tests {
"BLOCKQUOTE" => Value::test_string("Ecuador"), "BLOCKQUOTE" => Value::test_string("Ecuador"),
}); });
assert_eq!(fragment(value, false, &Config::default()), "> Ecuador\n"); assert_eq!(
fragment(value, false, &None, &Config::default()),
"> Ecuador\n"
);
} }
#[test] #[test]
@ -429,6 +535,7 @@ mod tests {
table( table(
value.clone().into_pipeline_data(), value.clone().into_pipeline_data(),
false, false,
&None,
&Config::default() &Config::default()
), ),
one(r#" one(r#"
@ -441,7 +548,7 @@ mod tests {
); );
assert_eq!( assert_eq!(
table(value.into_pipeline_data(), true, &Config::default()), table(value.into_pipeline_data(), true, &None, &Config::default()),
one(r#" one(r#"
| country | | country |
| ----------- | | ----------- |
@ -469,6 +576,7 @@ mod tests {
table( table(
value.clone().into_pipeline_data(), value.clone().into_pipeline_data(),
false, false,
&None,
&Config::default() &Config::default()
), ),
one(r#" one(r#"
@ -501,6 +609,7 @@ mod tests {
table( table(
value.clone().into_pipeline_data(), value.clone().into_pipeline_data(),
false, false,
&None,
&Config::default() &Config::default()
), ),
one(r#" one(r#"
@ -513,6 +622,270 @@ mod tests {
); );
} }
#[test]
fn test_center_column() {
let value = Value::test_list(vec![
Value::test_record(record! {
"foo" => Value::test_string("1"),
"bar" => Value::test_string("2"),
}),
Value::test_record(record! {
"foo" => Value::test_string("3"),
"bar" => Value::test_string("4"),
}),
Value::test_record(record! {
"foo" => Value::test_string("5"),
"bar" => Value::test_string("6"),
}),
]);
let center_columns = Value::test_list(vec![Value::test_cell_path(CellPath {
members: vec![PathMember::test_string(
"bar".into(),
false,
Casing::Sensitive,
)],
})]);
let cell_path: Vec<CellPath> = center_columns
.into_list()
.unwrap()
.into_iter()
.map(|v| v.into_cell_path().unwrap())
.collect();
let center: Option<Vec<CellPath>> = Some(cell_path);
// With pretty
assert_eq!(
table(
value.clone().into_pipeline_data(),
true,
&center,
&Config::default()
),
one(r#"
| foo | bar |
| --- |:---:|
| 1 | 2 |
| 3 | 4 |
| 5 | 6 |
"#)
);
// Without pretty
assert_eq!(
table(
value.clone().into_pipeline_data(),
false,
&center,
&Config::default()
),
one(r#"
|foo|bar|
|-|:-:|
|1|2|
|3|4|
|5|6|
"#)
);
}
#[test]
fn test_empty_center_column() {
let value = Value::test_list(vec![
Value::test_record(record! {
"foo" => Value::test_string("1"),
"bar" => Value::test_string("2"),
}),
Value::test_record(record! {
"foo" => Value::test_string("3"),
"bar" => Value::test_string("4"),
}),
Value::test_record(record! {
"foo" => Value::test_string("5"),
"bar" => Value::test_string("6"),
}),
]);
let center: Option<Vec<CellPath>> = Some(vec![]);
assert_eq!(
table(
value.clone().into_pipeline_data(),
true,
&center,
&Config::default()
),
one(r#"
| foo | bar |
| --- | --- |
| 1 | 2 |
| 3 | 4 |
| 5 | 6 |
"#)
);
}
#[test]
fn test_center_multiple_columns() {
let value = Value::test_list(vec![
Value::test_record(record! {
"command" => Value::test_string("ls"),
"input" => Value::test_string("."),
"output" => Value::test_string("file.txt"),
}),
Value::test_record(record! {
"command" => Value::test_string("echo"),
"input" => Value::test_string("'hi'"),
"output" => Value::test_string("hi"),
}),
Value::test_record(record! {
"command" => Value::test_string("cp"),
"input" => Value::test_string("a.txt"),
"output" => Value::test_string("b.txt"),
}),
]);
let center_columns = Value::test_list(vec![
Value::test_cell_path(CellPath {
members: vec![PathMember::test_string(
"command".into(),
false,
Casing::Sensitive,
)],
}),
Value::test_cell_path(CellPath {
members: vec![PathMember::test_string(
"output".into(),
false,
Casing::Sensitive,
)],
}),
]);
let cell_path: Vec<CellPath> = center_columns
.into_list()
.unwrap()
.into_iter()
.map(|v| v.into_cell_path().unwrap())
.collect();
let center: Option<Vec<CellPath>> = Some(cell_path);
assert_eq!(
table(
value.clone().into_pipeline_data(),
true,
&center,
&Config::default()
),
one(r#"
| command | input | output |
|:-------:| ----- |:--------:|
| ls | . | file.txt |
| echo | 'hi' | hi |
| cp | a.txt | b.txt |
"#)
);
}
#[test]
fn test_center_non_existing_column() {
let value = Value::test_list(vec![
Value::test_record(record! {
"name" => Value::test_string("Alice"),
"age" => Value::test_string("30"),
}),
Value::test_record(record! {
"name" => Value::test_string("Bob"),
"age" => Value::test_string("5"),
}),
Value::test_record(record! {
"name" => Value::test_string("Charlie"),
"age" => Value::test_string("20"),
}),
]);
let center_columns = Value::test_list(vec![Value::test_cell_path(CellPath {
members: vec![PathMember::test_string(
"none".into(),
false,
Casing::Sensitive,
)],
})]);
let cell_path: Vec<CellPath> = center_columns
.into_list()
.unwrap()
.into_iter()
.map(|v| v.into_cell_path().unwrap())
.collect();
let center: Option<Vec<CellPath>> = Some(cell_path);
assert_eq!(
table(
value.clone().into_pipeline_data(),
true,
&center,
&Config::default()
),
one(r#"
| name | age |
| ------- | --- |
| Alice | 30 |
| Bob | 5 |
| Charlie | 20 |
"#)
);
}
#[test]
fn test_center_complex_cell_path() {
let value = Value::test_list(vec![
Value::test_record(record! {
"k" => Value::test_string("version"),
"v" => Value::test_string("0.104.1"),
}),
Value::test_record(record! {
"k" => Value::test_string("build_time"),
"v" => Value::test_string("2025-05-28 11:00:45 +01:00"),
}),
]);
let center_columns = Value::test_list(vec![Value::test_cell_path(CellPath {
members: vec![
PathMember::test_int(1, false),
PathMember::test_string("v".into(), false, Casing::Sensitive),
],
})]);
let cell_path: Vec<CellPath> = center_columns
.into_list()
.unwrap()
.into_iter()
.map(|v| v.into_cell_path().unwrap())
.collect();
let center: Option<Vec<CellPath>> = Some(cell_path);
assert_eq!(
table(
value.clone().into_pipeline_data(),
true,
&center,
&Config::default()
),
one(r#"
| k | v |
| ---------- |:--------------------------:|
| version | 0.104.1 |
| build_time | 2025-05-28 11:00:45 +01:00 |
"#)
);
}
#[test] #[test]
fn test_content_type_metadata() { fn test_content_type_metadata() {
let mut engine_state = Box::new(EngineState::new()); let mut engine_state = Box::new(EngineState::new());