From 9ad3f1499b23ce6b26e7d0ef05cd1cc50716f3af Mon Sep 17 00:00:00 2001 From: backwardspy Date: Mon, 3 Jun 2024 23:02:45 +0100 Subject: [PATCH] feat(whiskers): tidy up output formats, general clean up (#235) --- README.md | 42 +++++------ src/cli.rs | 5 +- src/main.rs | 172 +++++++++++++++++++++++++++++---------------- src/markdown.rs | 175 +++++++++++++++------------------------------- src/templating.rs | 79 ++++++++++++++++++++- tests/cli.rs | 2 +- 6 files changed, 274 insertions(+), 201 deletions(-) diff --git a/README.md b/README.md index c97fb9f..88ce1c6 100644 --- a/README.md +++ b/README.md @@ -167,30 +167,30 @@ These types are designed to closely match the [palette.json](https://github.com/ ### Functions -| Name | Description | Examples | -|------|-------------|----------| -| `if` | Return one value if a condition is true, and another if it's false | `if(cond=true, t=1, f=0)` => `1` | -| `object` | Create an object from the input | `object(a=1, b=2)` => `{a: 1, b: 2}` | -| `css_rgb` | Convert a color to an RGB CSS string | `css_rgb(color=red)` => `rgb(210, 15, 57)` | -| `css_rgba` | Convert a color to an RGBA CSS string | `css_rgba(color=red)` => `rgba(210, 15, 57, 1.00)` | -| `css_hsl` | Convert a color to an HSL CSS string | `css_hsl(color=red)` => `hsl(347, 87%, 44%)` | -| `css_hsla` | Convert a color to an HSLA CSS string | `css_hsla(color=red)` => `hsla(347, 87%, 44%, 1.00)` | -| `read_file` | Read and include the contents of a file, path is relative to the template file | `read_file(path="abc.txt")` => `abc` | +| Name | Description | Examples | +| ----------- | ------------------------------------------------------------------------------ | ----------------------------------------------------- | +| `if` | Return one value if a condition is true, and another if it's false | `if(cond=true, t=1, f=0)` ⇒ `1` | +| `object` | Create an object from the input | `object(a=1, b=2)` ⇒ `{a: 1, b: 2}` | +| `css_rgb` | Convert a color to an RGB CSS string | `css_rgb(color=red)` ⇒ `rgb(210, 15, 57)` | +| `css_rgba` | Convert a color to an RGBA CSS string | `css_rgba(color=red)` ⇒ `rgba(210, 15, 57, 1.00)` | +| `css_hsl` | Convert a color to an HSL CSS string | `css_hsl(color=red)` ⇒ `hsl(347, 87%, 44%)` | +| `css_hsla` | Convert a color to an HSLA CSS string | `css_hsla(color=red)` ⇒ `hsla(347, 87%, 44%, 1.00)` | +| `read_file` | Read and include the contents of a file, path is relative to the template file | `read_file(path="abc.txt")` ⇒ `abc` | ### Filters -| Name | Description | Examples | -|------|-------------|----------| -| `add` | Add a value to a color | `red \| add(hue=30)` => `#ff6666` | -| `sub` | Subtract a value from a color | `red \| sub(hue=30)` => `#d30f9b` | -| `mod` | Modify a color | `red \| mod(lightness=80)` => `#f8a0b3` | -| `mix` | Mix two colors together | `red \| mix(color=base, amount=0.5)` => `#e08097` | -| `urlencode_lzma` | Serialize an object into a URL-safe string with LZMA compression | `red \| urlencode_lzma` => `#ff6666` | -| `trunc` | Truncate a number to a certain number of places | `1.123456 \| trunc(places=3)` => `1.123` | -| `css_rgb` | Convert a color to an RGB CSS string | `red \| css_rgb` => `rgb(210, 15, 57)` | -| `css_rgba` | Convert a color to an RGBA CSS string | `red \| css_rgba` => `rgba(210, 15, 57, 1.00)` | -| `css_hsl` | Convert a color to an HSL CSS string | `red \| css_hsl` => `hsl(347, 87%, 44%)` | -| `css_hsla` | Convert a color to an HSLA CSS string | `red \| css_hsla` => `hsla(347, 87%, 44%, 1.00)` | +| Name | Description | Examples | +| ---------------- | ---------------------------------------------------------------- | -------------------------------------------------- | +| `add` | Add a value to a color | `red \| add(hue=30)` ⇒ `#ff6666` | +| `sub` | Subtract a value from a color | `red \| sub(hue=30)` ⇒ `#d30f9b` | +| `mod` | Modify a color | `red \| mod(lightness=80)` ⇒ `#f8a0b3` | +| `mix` | Mix two colors together | `red \| mix(color=base, amount=0.5)` ⇒ `#e08097` | +| `urlencode_lzma` | Serialize an object into a URL-safe string with LZMA compression | `red \| urlencode_lzma` ⇒ `#ff6666` | +| `trunc` | Truncate a number to a certain number of places | `1.123456 \| trunc(places=3)` ⇒ `1.123` | +| `css_rgb` | Convert a color to an RGB CSS string | `red \| css_rgb` ⇒ `rgb(210, 15, 57)` | +| `css_rgba` | Convert a color to an RGBA CSS string | `red \| css_rgba` ⇒ `rgba(210, 15, 57, 1.00)` | +| `css_hsl` | Convert a color to an HSL CSS string | `red \| css_hsl` ⇒ `hsl(347, 87%, 44%)` | +| `css_hsla` | Convert a color to an HSLA CSS string | `red \| css_hsla` ⇒ `hsla(347, 87%, 44%, 1.00)` | > [!NOTE] > You also have access to all of Tera's own built-in filters and functions. diff --git a/src/cli.rs b/src/cli.rs index 2cb4d94..4a87afc 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -111,8 +111,11 @@ pub enum OutputFormat { Json, Yaml, Markdown, - MarkdownTable, Plain, + + /// Deprecated, now equivalent to `Markdown` + #[clap(hide = true)] + MarkdownTable, } fn json_map(s: &str) -> Result diff --git a/src/main.rs b/src/main.rs index fa0e245..df6ef34 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,7 +3,7 @@ use std::{ env, io::{Read, Write as _}, path::{Path, PathBuf}, - process, + process::{self, exit}, }; use anyhow::{anyhow, Context as _}; @@ -71,21 +71,7 @@ impl TemplateOptions { fn main() -> anyhow::Result<()> { // parse command-line arguments & template frontmatter let args = Args::parse(); - - if args.list_functions { - list_functions(args.output_format); - return Ok(()); - } - - if args.list_flavors { - list_flavors(args.output_format); - return Ok(()); - } - - if args.list_accents { - list_accents(args.output_format); - return Ok(()); - } + handle_list_flags(&args); let template = args .template @@ -201,6 +187,23 @@ fn main() -> anyhow::Result<()> { Ok(()) } +fn handle_list_flags(args: &Args) { + if args.list_functions { + list_functions(args.output_format); + exit(0); + } + + if args.list_flavors { + list_flavors(args.output_format); + exit(0); + } + + if args.list_accents { + list_accents(args.output_format); + exit(0); + } +} + fn override_matrix( matrix: &mut Matrix, value: &tera::Value, @@ -228,15 +231,16 @@ fn override_matrix( Ok(()) } -#[allow(clippy::too_many_lines)] fn list_functions(format: OutputFormat) { + let functions = templating::all_functions(); + let filters = templating::all_filters(); println!( "{}", match format { OutputFormat::Json | OutputFormat::Yaml => { let output = serde_json::json!({ - "functions": templating::all_functions(), - "filters": templating::all_filters() + "functions": functions, + "filters": filters, }); if matches!(format, OutputFormat::Json) { @@ -245,19 +249,20 @@ fn list_functions(format: OutputFormat) { serde_yaml::to_string(&output).expect("output is guaranteed to be valid") } } - OutputFormat::Markdown => { - markdown::display_functions_as_list() - } - OutputFormat::MarkdownTable => { - markdown::display_functions_as_table() + OutputFormat::Markdown | OutputFormat::MarkdownTable => { + format!( + "{}\n\n{}", + markdown::display_as_table(&functions, "Functions"), + markdown::display_as_table(&filters, "Filters") + ) } OutputFormat::Plain => { - let mut list = templating::all_filters() + let mut list = filters .iter() .map(|f| f.name.clone()) .collect::>(); - list.extend(templating::all_functions().iter().map(|f| f.name.clone())); + list.extend(functions.iter().map(|f| f.name.clone())); list.join("\n") } @@ -266,71 +271,120 @@ fn list_functions(format: OutputFormat) { } fn list_flavors(format: OutputFormat) { - let format = match format { - OutputFormat::Markdown | OutputFormat::MarkdownTable => { - eprintln!("warning: Markdown output is not yet supported for listing flavors, reverting to `plain`"); - OutputFormat::Plain + // we want all the flavor info minus the colors + #[derive(serde::Serialize)] + struct FlavorInfo { + identifier: String, + name: String, + emoji: char, + order: u32, + dark: bool, + } + + impl markdown::TableDisplay for FlavorInfo { + fn table_headings() -> Box<[String]> { + vec![ + "Identifier".to_string(), + "Name".to_string(), + "Dark".to_string(), + "Emoji".to_string(), + ] + .into_boxed_slice() } - other => other, - }; - let output = catppuccin::PALETTE + fn table_row(&self) -> Box<[String]> { + vec![ + self.identifier.clone(), + self.name.clone(), + self.dark.to_string(), + self.emoji.to_string(), + ] + .into_boxed_slice() + } + } + + let flavors = catppuccin::PALETTE .all_flavors() - .map(catppuccin::Flavor::identifier); + .into_iter() + .map(|f| FlavorInfo { + identifier: f.identifier().to_string(), + name: f.name.to_string(), + emoji: f.emoji, + order: f.order, + dark: f.dark, + }) + .collect::>(); println!( "{}", match format { + // for structured data, we output the full flavor info objects OutputFormat::Json | OutputFormat::Yaml => { - let output = serde_json::json!(output); - if matches!(format, OutputFormat::Json) { - serde_json::to_string_pretty(&output).expect("output is guaranteed to be valid") + serde_json::to_string_pretty(&flavors) + .expect("flavors are guaranteed to be valid json") } else { - serde_yaml::to_string(&output).expect("output is guaranteed to be valid") + serde_yaml::to_string(&flavors) + .expect("flavors are guaranteed to be valid yaml") } } + // for plain output, we just list the flavor identifiers OutputFormat::Plain => { - output.join("\n") + flavors.iter().map(|f| &f.identifier).join("\n") + } + // and finally for human-readable markdown, we list the flavor names + OutputFormat::Markdown | OutputFormat::MarkdownTable => { + markdown::display_as_table(&flavors, "Flavors") } - _ => todo!(), } ); } fn list_accents(format: OutputFormat) { - let format = match format { - OutputFormat::Markdown | OutputFormat::MarkdownTable => { - eprintln!("warning: Markdown output is not yet supported for listing accents, reverting to `plain`"); - OutputFormat::Plain - } - other => other, - }; - - let output = catppuccin::PALETTE + let accents = catppuccin::PALETTE .latte .colors .all_colors() .into_iter() - .filter_map(|c| if c.accent { Some(c.identifier()) } else { None }) + .filter(|c| c.accent) .collect::>(); println!( "{}", match format { + // for structured data, we can include both name and identifier of each color OutputFormat::Json | OutputFormat::Yaml => { - let output = serde_json::json!(output); - + let accents = accents + .into_iter() + .map(|c| { + serde_json::json!({ + "name": c.name, + "identifier": c.identifier(), + }) + }) + .collect::>(); if matches!(format, OutputFormat::Json) { - serde_json::to_string_pretty(&output).expect("output is guaranteed to be valid") + serde_json::to_string_pretty(&accents) + .expect("accents are guaranteed to be valid json") } else { - serde_yaml::to_string(&output).expect("output is guaranteed to be valid") + serde_yaml::to_string(&accents) + .expect("accents are guaranteed to be valid yaml") } } + // for plain output, we just list the identifiers OutputFormat::Plain => { - output.join("\n") + accents + .into_iter() + .map(catppuccin::Color::identifier) + .join("\n") + } + // and finally for human-readable markdown, we list the names + OutputFormat::Markdown | OutputFormat::MarkdownTable => { + markdown::display_as_list( + &accents.into_iter().map(|c| c.name).collect::>(), + "Accents", + ) } - _ => todo!(), } ); } @@ -380,7 +434,7 @@ fn template_is_compatible(template_opts: &TemplateOptions) -> bool { true } -fn write_template(dry_run: bool, filename: String, result: String) -> Result<(), anyhow::Error> { +fn write_template(dry_run: bool, filename: &str, result: String) -> Result<(), anyhow::Error> { let filename = Path::new(&filename); if dry_run || cfg!(test) { @@ -413,7 +467,7 @@ fn render_single_output( if let Some(path) = check { check_result_with_file(&path, &result).context("Check mode failed")?; } else if let Some(filename) = filename { - write_template(dry_run, filename, result)?; + write_template(dry_run, &filename, result)?; } else { print!("{result}"); } @@ -464,7 +518,7 @@ fn render_multi_output( if args.check.is_some() { check_result_with_file(&filename, &result).context("Check mode failed")?; } else { - write_template(args.dry_run, filename, result)?; + write_template(args.dry_run, &filename, result)?; } } diff --git a/src/markdown.rs b/src/markdown.rs index 71686eb..9b3b596 100644 --- a/src/markdown.rs +++ b/src/markdown.rs @@ -1,132 +1,71 @@ +use std::fmt::Display; + use itertools::Itertools as _; -use crate::templating; +pub trait TableDisplay { + fn table_headings() -> Box<[String]>; + fn table_row(&self) -> Box<[String]>; +} + +pub fn display_as_list(items: &[T], heading: &str) -> String { + let items = items.iter().map(|item| format!("* {item}")).join("\n"); + format!("### {heading}\n\n{items}") +} -#[must_use] -pub fn display_functions_as_list() -> String { +pub fn display_as_table(items: &[T], heading: &str) -> String { let mut result = String::new(); - result.push_str("## Functions\n\n"); - for function in templating::all_functions() { - result.push_str(&format!( - "### `{name}`\n\n{description}\n\n", - name = function.name, - description = function.description - )); - if !function.examples.is_empty() { - result.push_str("#### Examples\n\n"); - for example in &function.examples { - result.push_str(&format!( - "- `{name}({input})` => `{output}`\n", - name = function.name, - input = example - .inputs - .iter() - .map(|(k, v)| format!("{k}={v}")) - .join(", "), - output = example.output - )); - } - result.push('\n'); - } - } + let rows = items.iter().map(T::table_row).collect::>(); - result.push_str("## Filters\n\n"); - for filter in templating::all_filters() { - result.push_str(&format!( - "### `{name}`\n\n{description}\n\n", - name = filter.name, - description = filter.description - )); - if !filter.examples.is_empty() { - result.push_str("#### Examples\n\n"); - for example in &filter.examples { - result.push_str(&format!( - "- `{value} | {name}({input})` => `{output}`\n", - value = example.value, - name = filter.name, - input = example - .inputs - .iter() - .map(|(k, v)| format!("{k}={v}")) - .join(", "), - output = example.output - )); - } - result.push('\n'); - } - } + // calculate a max width for each heading based on the longest row in the column + let headings = T::table_headings(); + let headings = headings + .iter() + .enumerate() + .map(|(i, heading)| { + let max_width = rows + .iter() + .map(|row| row[i].len()) + .chain(std::iter::once(heading.len())) + .max() + .unwrap_or(heading.len()); + (heading, max_width) + }) + .collect::>(); - result -} + // add the section heading + result.push_str(&format!("### {heading}\n\n")); -pub fn display_functions_as_table() -> String { - let mut result = String::new(); - result.push_str("### Functions\n\n"); - result.push_str("| Name | Description | Examples |\n"); - result.push_str("|------|-------------|----------|\n"); - for function in templating::all_functions() { - result.push_str(&format!( - "| `{name}` | {description} | {examples} |\n", - name = function.name, - description = function.description, - examples = if function.examples.is_empty() { - "None".to_string() - } else { - function - .examples - .first() - .map_or_else(String::new, |example| { - format!( - "`{name}({input})` => `{output}`", - name = function.name, - input = example - .inputs - .iter() - .map(|(k, v)| format!("{k}={v}")) - .join(", "), - output = example.output - ) - }) - } - )); - } + // add the table headings + result.push_str(&format!( + "| {} |\n", + headings + .iter() + .map(|(heading, max_width)| format!("{heading: `{output}`", - value = example.value, - name = filter.name, - output = example.output - ) - } else { - format!( - "`{value} \\| {name}({input})` => `{output}`", - value = example.value, - name = filter.name, - input = example - .inputs - .iter() - .map(|(k, v)| format!("{k}={v}")) - .join(", "), - output = example.output - ) - } + "| {} |\n", + row.iter() + .enumerate() + .map(|(i, cell)| { + let max_width = headings[i].1; + format!("{cell: Box<[String]> { + Box::new([ + "Name".to_string(), + "Description".to_string(), + "Examples".to_string(), + ]) + } + + fn table_row(&self) -> Box<[String]> { + Box::new([ + format!("`{}`", self.name), + self.description.clone(), + if self.examples.is_empty() { + "None".to_string() + } else { + self.examples.first().map_or_else(String::new, |example| { + format!( + "`{name}({input})` ⇒ `{output}`", + name = self.name, + input = example + .inputs + .iter() + .map(|(k, v)| format!("{k}={v}")) + .join(", "), + output = example.output + ) + }) + }, + ]) + } +} + +impl markdown::TableDisplay for Filter { + fn table_headings() -> Box<[String]> { + Box::new([ + "Name".to_string(), + "Description".to_string(), + "Examples".to_string(), + ]) + } + + fn table_row(&self) -> Box<[String]> { + Box::new([ + format!("`{}`", self.name), + self.description.clone(), + if self.examples.is_empty() { + "None".to_string() + } else { + self.examples.first().map_or_else(String::new, |example| { + if example.inputs.is_empty() { + format!( + "`{value} \\| {name}` ⇒ `{output}`", + value = example.value, + name = self.name, + output = example.output + ) + } else { + format!( + "`{value} \\| {name}({input})` ⇒ `{output}`", + value = example.value, + name = self.name, + input = example + .inputs + .iter() + .map(|(k, v)| format!("{k}={v}")) + .join(", "), + output = example.output + ) + } + }) + }, + ]) + } +} + #[cfg(test)] mod tests { #[test] diff --git a/tests/cli.rs b/tests/cli.rs index fe340f3..26570b6 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -37,7 +37,7 @@ mod happy_path { )); } - /// Test that the CLI can render a template which uses read_file + /// Test that the CLI can render a template which uses `read_file` #[test] fn test_read_file() { let mut cmd = Command::cargo_bin("whiskers").expect("binary exists");