Skip to content

Commit

Permalink
feat!: Variables for replacement action
Browse files Browse the repository at this point in the history
See the updated README for more info.

This breaks existing functionality around
"splatting" open capture groups (see removed
README section), which might be added again at a
later date.
  • Loading branch information
alexpovel committed Jun 3, 2024
1 parent 0083789 commit 7f6cfcb
Show file tree
Hide file tree
Showing 20 changed files with 1,099 additions and 270 deletions.
46 changes: 35 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,22 +229,13 @@ $ echo 'ghp_oHn0As3cr3T!!' | srgn 'ghp_[[:alnum:]]+' '*' # A GitHub token
*!!
```

However, in the presence of capture groups, the *individual characters comprising a
capture group match* are treated *individually* for processing, allowing a replacement
to be repeated:

```console
$ echo 'Hide ghp_th15 and ghp_th4t' | srgn '(ghp_[[:alnum:]]+)' '*'
Hide ******** and ********
```

Advanced regex features are
[supported](https://docs.rs/fancy-regex/0.11.0/fancy_regex/index.html#syntax), for
example lookarounds:

```console
$ echo 'ghp_oHn0As3cr3T' | srgn '(?<=ghp_)([[:alnum:]]+)' '*'
ghp_***********
$ echo 'ghp_oHn0As3cr3T' | srgn '(?<=ghp_)[[:alnum:]]+' '*'
ghp_*
```

Take care in using these safely, as advanced patterns come without certain [safety and
Expand All @@ -271,6 +262,39 @@ $ echo 'Mood: 🤮🤒🤧🦠 :(' | srgn '\p{Emoji_Presentation}' '😷'
Mood: 😷😷😷😷 :(
```

##### Variables

Replacements are aware of variables, which are made accessible for use through regex
capture groups. Capture groups can be numbered, or optionally named. The zeroth capture
group corresponds to the entire match.

```console
$ echo 'Swap It' | srgn '(\w+) (\w+)' '$2 $1' # Regular, numbered
It Swap
$ echo 'Swap It' | srgn '(\w+) (\w+)' '$2 $1$1$1' # Use as many times as you'd like
It SwapSwapSwap
$ echo 'Call +1-206-555-0100!' | srgn 'Call (\+?\d\-\d{3}\-\d{3}\-\d{4}).+' 'The phone number in "$0" is: $1.' # Variable `0` is the entire match
The phone number in "Call +1-206-555-0100!" is: +1-206-555-0100.
```
A more advanced use case is, for example, code refactoring using named capture groups
(perhaps you can come up with a more useful one...):
```console
$ echo 'let x = 3;' | srgn 'let (?<var>[a-z]+) = (?<expr>.+);' 'const $var$var = $expr + $expr;'
const xx = 3 + 3;
```

As in bash, use curly braces to disambiguate variables from immediately adjacent
content:

```console
$ echo '12' | srgn '(\d)(\d)' '$2${1}1'
211
$ echo '12' | srgn '(\d)(\d)' '$2$11' # will fail (`11` is unknown)
$ echo '12' | srgn '(\d)(\d)' '$2${11' # will fail (brace was not closed)
```

#### Beyond replacement

Seeing how the replacement is merely a static string, its usefulness is limited. This is
Expand Down
3 changes: 1 addition & 2 deletions src/actions/deletion/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
use log::info;

use super::Action;
use log::info;

/// Deletes everything in the input.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
Expand Down
3 changes: 1 addition & 2 deletions src/actions/lower/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
use log::info;

use super::Action;
use log::info;

/// Renders in lowercase.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
Expand Down
41 changes: 40 additions & 1 deletion src/actions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ mod symbols;
mod titlecase;
mod upper;

use crate::scoping::scope::ScopeContext;
pub use deletion::Deletion;
#[cfg(feature = "german")]
pub use german::German;
pub use lower::Lower;
pub use normalization::Normalization;
pub use replace::{Replacement, ReplacementCreationError};
pub use replace::{Replacement, ReplacementError};
use std::{error::Error, fmt};
#[cfg(feature = "symbols")]
pub use symbols::{inversion::SymbolsInversion, Symbols};
pub use titlecase::Titlecase;
Expand All @@ -31,8 +33,41 @@ pub trait Action: Send + Sync {
/// This is infallible: it cannot fail in the sense of [`Result`]. It can only
/// return incorrect results, which would be bugs (please report).
fn act(&self, input: &str) -> String;

/// Acts taking into account additional context.
///
/// By default, the context is ignored and [`Action::act`] is called. Implementors
/// which need and know how to handle additional context can overwrite this method.
///
/// # Errors
///
/// This is fallible, as the context is dynamically created at runtime and
/// potentially contains bad data. See docs of the [`Err`] variant type.
fn act_with_context(&self, input: &str, context: &ScopeContext) -> Result<String, ActionError> {
let _ = context; // Mark variable as used
Ok(self.act(input))
}
}

/// An error during application of an action.
#[derive(Debug, PartialEq, Eq)]
pub enum ActionError {
/// Produced if [`Replacement`] fails.
ReplacementError(ReplacementError),
}

impl fmt::Display for ActionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ReplacementError(re) => {
write!(f, "Action failed in replacement: {re}")
}
}
}
}

impl Error for ActionError {}

/// Any function that can be used as an [`Action`].
impl<T> Action for T
where
Expand All @@ -49,4 +84,8 @@ impl Action for Box<dyn Action> {
fn act(&self, input: &str) -> String {
self.as_ref().act(input)
}

fn act_with_context(&self, input: &str, context: &ScopeContext) -> Result<String, ActionError> {
self.as_ref().act_with_context(input, context)
}
}
20 changes: 0 additions & 20 deletions src/actions/normalization/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,3 @@ impl Action for Normalization {
input.nfd().filter(|c| !c.is_mark()).collect()
}
}

// #[cfg(test)]
// mod tests {
// use rstest::rstest;

// use super::*;

// #[rstest]
// #[case("a dog", "A Dog")]
// #[case("ein überfall", "Ein Überfall")]
// #[case("miXeD caSe", "miXeD caSe")] // Hmmm... behavior of `titlecase` crate
// //
// #[case("a dog's life 🐕", "A Dog's Life 🐕")]
// //
// #[case("a dime a dozen", "A Dime a Dozen")]
// fn test_titlecasing(#[case] input: &str, #[case] expected: &str) {
// let result = Titlecase::default().process(input);
// assert_eq!(result, expected);
// }
// }
68 changes: 54 additions & 14 deletions src/actions/replace/mod.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
use super::Action;
use log::info;
use super::{Action, ActionError};
use crate::scoping::scope::ScopeContext;
use log::{debug, info};
use std::{error::Error, fmt};
use unescape::unescape;
use variables::{inject_variables, VariableExpressionError};

mod variables;

/// Replaces input with a fixed string.
///
Expand Down Expand Up @@ -47,7 +51,7 @@ use unescape::unescape;
pub struct Replacement(String);

impl TryFrom<String> for Replacement {
type Error = ReplacementCreationError;
type Error = ReplacementError;

/// Creates a new replacement from an owned string.
///
Expand All @@ -73,48 +77,84 @@ impl TryFrom<String> for Replacement {
/// Creation fails due to invalid escape sequences.
///
/// ```
/// use srgn::actions::{Replacement, ReplacementCreationError};
/// use srgn::actions::{Replacement, ReplacementError};
///
/// let replacement = Replacement::try_from(r"Invalid \z Escape".to_owned());
/// assert_eq!(
/// replacement,
/// Err(ReplacementCreationError::InvalidEscapeSequences(
/// Err(ReplacementError::InvalidEscapeSequences(
/// "Invalid \\z Escape".to_owned()
/// ))
/// );
/// ```
fn try_from(replacement: String) -> Result<Self, Self::Error> {
match unescape(&replacement) {
Some(res) => Ok(Self(res)),
None => Err(ReplacementCreationError::InvalidEscapeSequences(
replacement,
)),
}
let unescaped =
unescape(&replacement).ok_or(ReplacementError::InvalidEscapeSequences(replacement))?;

Ok(Self(unescaped))
}
}

/// An error that can occur when creating a replacement.
#[derive(Debug, PartialEq, Eq)]
pub enum ReplacementCreationError {
pub enum ReplacementError {
/// The replacement contains invalid escape sequences.
InvalidEscapeSequences(String),
/// The replacement contains an error in its variable expressions.
VariableError(VariableExpressionError),
}

impl fmt::Display for ReplacementCreationError {
impl fmt::Display for ReplacementError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidEscapeSequences(replacement) => {
write!(f, "Contains invalid escape sequences: '{replacement}'")
}
Self::VariableError(err) => {
write!(f, "Error in variable expressions: {err}")
}
}
}
}

impl Error for ReplacementCreationError {}
impl Error for ReplacementError {}

impl From<VariableExpressionError> for ReplacementError {
fn from(value: VariableExpressionError) -> Self {
Self::VariableError(value)
}
}

impl Action for Replacement {
fn act(&self, input: &str) -> String {
info!("Substituting '{}' with '{}'", input, self.0);
info!("This substitution is verbatim and does not take into account variables");
self.0.clone()
}

fn act_with_context(
&self,
_input: &str,
context: &ScopeContext,
) -> Result<String, ActionError> {
match context {
ScopeContext::CaptureGroups(cgs) => {
debug!("Available capture group variables: {cgs:?}");

Ok(inject_variables(self.0.as_str(), cgs)?)
}
}
}
}

impl From<VariableExpressionError> for ActionError {
fn from(value: VariableExpressionError) -> Self {
Self::ReplacementError(value.into())
}
}

impl From<ReplacementError> for ActionError {
fn from(value: ReplacementError) -> Self {
Self::ReplacementError(value)
}
}
Loading

0 comments on commit 7f6cfcb

Please sign in to comment.