Skip to content

Commit

Permalink
feature: support requiring capture group match in template string (#11)
Browse files Browse the repository at this point in the history
* feature: support requiring capture group match in template string

* require and filter
  • Loading branch information
solidiquis authored Jan 20, 2025
1 parent 953994d commit cf42232
Show file tree
Hide file tree
Showing 8 changed files with 178 additions and 32 deletions.
57 changes: 43 additions & 14 deletions src/cli.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use anyhow::{format_err, Result};
use clap::{crate_authors, crate_version, Parser};
use clap::{crate_authors, crate_version, Parser, ValueEnum};
use clap_complete::Shell;
use std::{env, str::FromStr};
use std::{env, fmt, str::FromStr};

#[derive(Parser, Debug)]
#[command(
Expand Down Expand Up @@ -154,32 +154,61 @@ The following attributes are currently available:
"
)]
pub struct Cli {
/// A regular expression with named captures. Can be specified multiple times
/// A regular expression with named captures. Can be specified multiple times.
#[arg(short, long)]
pub pattern: Vec<String>,

/// A template string that defines how to transform a line input. See long '--help'
#[arg(short, long)]
pub template: String,
/// A template string that defines how to transform a line input using
/// times. Can be specified multiple times. See long '--help'.
#[arg(short, long, group = "tmpl")]
pub template: Vec<String>,

/// Separator used to join results of transforming each template if multiple are specified.
#[arg(short, long, default_value_t = String::new(), group="tmpl")]
pub separator: String,

/// Input files
/// Input files.
pub files: Vec<String>,

/// Name of capture that must have at least one match for the output to show. Can be specified
/// multiple times
#[arg(short, long)]
pub require: Vec<String>,
/// Comma-separated capture names that must have a match for a given input line to be
/// processed; otherwise it is ignored.
#[arg(short, long, group = "req")]
pub require: Option<String>,

/// Modify behavior of '-r, --require' to require matching on all specified capture names or
/// any.
#[arg(long, requires = "req", default_value_t = RequireMode::default())]
pub require_mode: RequireMode,

/// Force output to be line-buffered. By default, output is line buffered when stdout is a
/// terminal and block-buffered otherwise
/// terminal and block-buffered otherwise.
#[arg(long)]
pub line_buffered: bool,

/// Produce completions for shell and exit
/// Produce completions for shell and exit.
#[arg(short, long)]
pub completions: Option<clap_complete::Shell>,
}

#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
pub enum RequireMode {
/// Require all capture names specified to be matched for input line to be processed.
#[default]
All,
/// Require at least one capture name among those specified to be matched for input line to be
/// processed.
Any,
}

impl fmt::Display for RequireMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::All => write!(f, "all"),
Self::Any => write!(f, "any"),
}
}
}

impl Cli {
pub fn compute_shell_used_for_completions() -> Result<Option<Shell>> {
let mut raw_args = env::args_os();
Expand All @@ -190,7 +219,7 @@ impl Cli {
.is_some_and(|s| s == "--completions" || s == "-c")
}) {
if let Some(raw_shell) = raw_args.next().and_then(|a| a.as_os_str().to_str().map(String::from)) {
let shell = Shell::from_str(&raw_shell.to_lowercase())
let shell = <Shell as FromStr>::from_str(&raw_shell.to_lowercase())
.map_err(|e| format_err!("failed to determine which Shell to generate autocomplete due to {e}"))?;
return Ok(Some(shell));
}
Expand Down
47 changes: 35 additions & 12 deletions src/line.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
use crate::{
cli::{Cli, RequireMode},
scanner::{MultiFileScanner, StdinScanner},
template::OutputTemplate,
tty::init_output_writer,
Cli, TtyContext,
TtyContext,
};
use anyhow::{format_err, Context, Result};
use regex::Regex;
Expand All @@ -16,15 +17,23 @@ pub fn process_lines(tty: &mut TtyContext, args: &Cli) -> Result<()> {
files,
line_buffered,
require,
require_mode,
separator,
..
} = args;

let template = OutputTemplate::parse(template)?;
let filters = require
.as_ref()
.map_or_else(Vec::new, |r| r.split(",").map(str::trim).collect::<Vec<_>>());

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);
}

Expand Down Expand Up @@ -77,17 +86,31 @@ 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()) {
continue 'outer;
match require_mode {
RequireMode::All => {
if !filters
.iter()
.all(|capname| captures_map.get(capname).is_some_and(|c| !c.is_empty()))
{
continue 'outer;
}
}
RequireMode::Any => {
if !filters
.iter()
.any(|capname| captures_map.get(capname).is_some_and(|c| !c.is_empty()))
{
continue 'outer;
}
}
}
let output_line = template.transform(&captures_map);
let output = templates
.iter()
.map(|t| t.transform(&captures_map))
.collect::<Vec<String>>()
.join(separator);

if output_line.is_empty() {
continue;
}
writer.writeln(&output_line)?;
writer.writeln(&output)?;
}
Ok(())
}
12 changes: 11 additions & 1 deletion src/template/error.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 8 additions & 4 deletions src/template/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,6 @@ impl OutputTemplate {
pub fn parse(template: &str) -> Result<Self> {
let anchors = parse::parse(template)?;

if anchors.is_empty() {
return Ok(Self::default());
}
let mut targets = Vec::new();

let mut left_cursor = 0;
Expand Down Expand Up @@ -76,10 +73,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);
Expand All @@ -89,6 +86,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) => {
Expand Down Expand Up @@ -116,6 +119,7 @@ impl OutputTemplate {
}
}
}
InterpolationTarget::Literal(val) => out.push_str(val),
}
}
out
Expand Down
17 changes: 16 additions & 1 deletion src/template/parse/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -41,6 +41,7 @@ pub struct Anchor {
pub index: Option<usize>,
pub defaults: Vec<DefaultValue>,
pub attributes: Vec<Attribute>,
pub required: bool,
}

/// State that is maintained during parsing. The `cursor` is the index of the current token
Expand Down Expand Up @@ -152,6 +153,16 @@ fn parse_impl(mode: &mut ParseState, anchors: &mut Vec<Anchor>, 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 {
Expand Down Expand Up @@ -266,6 +277,10 @@ fn parse_impl(mode: &mut ParseState, anchors: &mut Vec<Anchor>, 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;
Expand Down
47 changes: 47 additions & 0 deletions src/template/parse/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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());
}
17 changes: 17 additions & 0 deletions src/template/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}
1 change: 1 addition & 0 deletions src/template/token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '!';

0 comments on commit cf42232

Please sign in to comment.