From a8d284d7f32d7dcdd1288e01d9bb77ebb586ee03 Mon Sep 17 00:00:00 2001 From: solidiquis Date: Sun, 19 Jan 2025 18:29:06 -0800 Subject: [PATCH] feature: support requiring capture group match in template string --- src/cli.rs | 10 ++++---- src/line.rs | 24 +++++++++++-------- src/template/error.rs | 12 +++++++++- src/template/mod.rs | 9 +++++++- src/template/parse/mod.rs | 17 +++++++++++++- src/template/parse/test.rs | 47 ++++++++++++++++++++++++++++++++++++++ src/template/test.rs | 17 ++++++++++++++ src/template/token.rs | 1 + 8 files changed, 120 insertions(+), 17 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 21d0534..6cd0491 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -158,17 +158,17 @@ pub struct Cli { #[arg(short, long)] pub pattern: Vec, - /// A template string that defines how to transform a line input. See long '--help' + /// A template string that defines how to transform a line input. Can be specified multiple + /// times. See long '--help' #[arg(short, long)] - pub template: String, + pub template: Vec, /// Input files pub files: Vec, - /// Name of capture that must have at least one match for the output to show. Can be specified - /// multiple times + /// Comma-separated capture names that must all have a match for an input line to be processed. #[arg(short, long)] - pub require: Vec, + pub require: String, /// Force output to be line-buffered. By default, output is line buffered when stdout is a /// terminal and block-buffered otherwise diff --git a/src/line.rs b/src/line.rs index fbe1b4e..76824cc 100644 --- a/src/line.rs +++ b/src/line.rs @@ -19,12 +19,16 @@ pub fn process_lines(tty: &mut TtyContext, args: &Cli) -> Result<()> { .. } = args; - let template = OutputTemplate::parse(template)?; + let filters = require.split(",").map(str::trim).collect::>(); + + let mut templates = Vec::with_capacity(template.len()); + for templ in template { + templates.push(OutputTemplate::parse(templ)?); + } let mut regexes = Vec::new(); for pat in pattern { - let re = - Regex::new(pat).with_context(|| format!("encountered invalid regular expression in pattern: {pat}"))?; + let re = Regex::new(pat).with_context(|| format!("encountered invalid regular expression: {pat}"))?; regexes.push(re); } @@ -77,17 +81,19 @@ pub fn process_lines(tty: &mut TtyContext, args: &Cli) -> Result<()> { } } } - for capture_name in require { - if captures_map.get(capture_name.as_str()).is_none_or(|c| c.is_empty()) { + for capture_name in &filters { + if captures_map.get(capture_name).is_none_or(|c| c.is_empty()) { continue 'outer; } } - let output_line = template.transform(&captures_map); + for template in &templates { + let output_line = template.transform(&captures_map); - if output_line.is_empty() { - continue; + if output_line.is_empty() { + continue; + } + writer.writeln(&output_line)?; } - writer.writeln(&output_line)?; } Ok(()) } diff --git a/src/template/error.rs b/src/template/error.rs index bede481..8952f61 100644 --- a/src/template/error.rs +++ b/src/template/error.rs @@ -1,6 +1,6 @@ use super::{ parse::rules::VALID_ANCHOR_CHARSET, - token::{ANCHOR_CLOSE, ATTRIBUTE_CLOSE, ATTRIBUTE_END, ESCAPE}, + token::{ANCHOR_CLOSE, ATTRIBUTE_CLOSE, ATTRIBUTE_END, ESCAPE, REQUIRED}, }; use indoc::{formatdoc, indoc}; use std::fmt::{self, Display}; @@ -119,6 +119,16 @@ impl ParseError { } } + pub fn default_disallowed_with_required(char_index: usize, chars: &[char]) -> Self { + ParseError { + char_index, + partial_template: chars.iter().collect(), + message: format!( + "Default values are disallowed when an anchor is marked as required with a leading '{REQUIRED}'", + ), + } + } + pub fn index_parsing_eol(char_index: usize, chars: &[char]) -> Self { ParseError { char_index, diff --git a/src/template/mod.rs b/src/template/mod.rs index 3de79b8..d90705a 100644 --- a/src/template/mod.rs +++ b/src/template/mod.rs @@ -76,10 +76,10 @@ impl OutputTemplate { for target in &self.targets { match target { - InterpolationTarget::Literal(val) => out.push_str(val), InterpolationTarget::Anchor(anchor) => { let name = anchor.name.as_str(); let index = anchor.index.unwrap_or_default(); + if let Some(val) = interpolation_map.get(name).and_then(|vals| vals.get(index)) { if anchor.attributes.is_empty() { out.push_str(val); @@ -89,6 +89,12 @@ impl OutputTemplate { } continue; } + + // No match, return empty string. + if anchor.required { + return String::new(); + } + for default_val in &anchor.defaults { match default_val { DefaultValue::Literal(val) => { @@ -116,6 +122,7 @@ impl OutputTemplate { } } } + InterpolationTarget::Literal(val) => out.push_str(val), } } out diff --git a/src/template/parse/mod.rs b/src/template/parse/mod.rs index b68fa88..297de78 100644 --- a/src/template/parse/mod.rs +++ b/src/template/parse/mod.rs @@ -2,7 +2,7 @@ use super::{ error::ParseError, token::{ ANCHOR_CLOSE, ANCHOR_OPEN, ATTRIBUTE_CLOSE, ATTRIBUTE_DELIMETER, ATTRIBUTE_END, ATTRIBUTE_OPEN, DEFAULT_PIPE, - ESCAPE, INDEX_CLOSE, INDEX_OPEN, LITERAL_DOUBLE_QUOTE, LITERAL_SINGLE_QUOTE, + ESCAPE, INDEX_CLOSE, INDEX_OPEN, LITERAL_DOUBLE_QUOTE, LITERAL_SINGLE_QUOTE, REQUIRED, }, }; use anyhow::{format_err, Result}; @@ -41,6 +41,7 @@ pub struct Anchor { pub index: Option, pub defaults: Vec, pub attributes: Vec, + pub required: bool, } /// State that is maintained during parsing. The `cursor` is the index of the current token @@ -152,6 +153,16 @@ fn parse_impl(mode: &mut ParseState, anchors: &mut Vec, rules: &Rules) - } continue; } + if mode.tokens.get(mode.cursor).is_some_and(|ch| *ch == REQUIRED) { + let Some(anchor) = mode.bound_anchor.as_mut() else { + log::error!("expected mode.bound_anchor to be `Some` while in `AnchorParseBegin`"); + return Err(format_err!( + "An unexpected error occurred while parsing template string." + )); + }; + anchor.required = true; + mode.cursor += 1; + } if mode.tokens.get(mode.cursor).is_some_and(|ch| *ch == ATTRIBUTE_OPEN) { mode.mode = ParseStateMode::AttributeParse; } else { @@ -266,6 +277,10 @@ fn parse_impl(mode: &mut ParseState, anchors: &mut Vec, rules: &Rules) - } ParseStateMode::AnchorParseDefaultValue => { + if mode.bound_anchor.as_ref().is_some_and(|a| a.required) { + return Err(ParseError::default_disallowed_with_required(mode.cursor - 1, &mode.tokens).into()); + } + mode.cursor += 1; for i in mode.cursor..mode.tokens.len() { mode.cursor = i; diff --git a/src/template/parse/test.rs b/src/template/parse/test.rs index 247ee47..261bf6f 100644 --- a/src/template/parse/test.rs +++ b/src/template/parse/test.rs @@ -8,6 +8,7 @@ fn test_parse_plain() { let anchor = &anchors[0]; assert_eq!(&anchor.name, "log"); + assert!(!anchor.required); assert_eq!("{log}", &template_string[anchor.start..anchor.end]); assert_eq!(anchor.index, None); } @@ -248,3 +249,49 @@ fn test_literal_anchor_with_attributes() { assert!(anchor.attributes.iter().find(|a| a == &&Attribute::Red).is_some()); assert!(anchor.attributes.iter().find(|a| a == &&Attribute::Bold).is_some()); } + +#[test] +fn test_required_anchor() { + let template_string = "output={!log}"; + let anchors = parse(template_string).unwrap(); + assert_eq!(anchors.len(), 1); + + let anchor = &anchors[0]; + assert_eq!(&anchor.name, "log"); + assert_eq!("{!log}", &template_string[anchor.start..anchor.end]); + assert_eq!(anchor.index, None); + assert!(anchor.required); +} + +#[test] +fn test_required_anchor_with_attrs() { + let template_string = "output={!(red|bold):log}"; + let anchors = parse(template_string).unwrap(); + assert_eq!(anchors.len(), 1); + + let anchor = &anchors[0]; + assert_eq!(&anchor.name, "log"); + assert_eq!("{!(red|bold):log}", &template_string[anchor.start..anchor.end]); + assert_eq!(anchor.index, None); + assert!(anchor.required); +} + +#[test] +fn test_required_anchor_no_defaults() { + let template_string = "output={!log || foo}"; + let anchors = parse(template_string); + assert!(anchors.is_err()); + + let template_string = "output={!log||foo}"; + let anchors = parse(template_string); + assert!(anchors.is_err()); + + let template_string = "output={!log||\"foo\"}"; + let anchors = parse(template_string); + assert!(anchors.is_err()); + + // Pointless but will support anyway + let template_string = "output={!'log'}"; + let anchors = parse(template_string); + assert!(anchors.is_ok()); +} diff --git a/src/template/test.rs b/src/template/test.rs index e02ca43..d958a3e 100644 --- a/src/template/test.rs +++ b/src/template/test.rs @@ -84,3 +84,20 @@ fn test_output_template_attributes() { let expected = Attribute::apply("bar_value_2", &[Attribute::Red, Attribute::Bold]); assert_eq!(resultant, format!("{expected}")); } + +#[test] +fn test_output_template_required() { + let template = "log={!foo} out={bar} baz"; + let out = OutputTemplate::parse(template).unwrap(); + assert_eq!(out.targets.len(), 5); + + let mut interpolation_map = HashMap::new(); + interpolation_map.insert("foo", vec![]); + interpolation_map.insert("bar", vec!["bar_value"]); + + let resultant = out.transform(&interpolation_map); + assert_eq!( + resultant, "", + "foo doesn't have any matches so string should come back empty" + ); +} diff --git a/src/template/token.rs b/src/template/token.rs index a79afea..da272fd 100644 --- a/src/template/token.rs +++ b/src/template/token.rs @@ -10,3 +10,4 @@ pub const ATTRIBUTE_OPEN: char = '('; pub const ATTRIBUTE_CLOSE: char = ')'; pub const ATTRIBUTE_DELIMETER: char = '|'; pub const ATTRIBUTE_END: char = ':'; +pub const REQUIRED: char = '!';