Skip to main content

rich_ext/
transform.rs

1//! Composable transforms: rewrite what is about to be rendered, one stage at a
2//! time.
3//!
4//! A [`Transform<T>`] takes a value and returns a changed one: a [`Text`] with
5//! some lines dropped, a data `Document` (`data::transform`, with the
6//! `jsonpath` feature) narrowed to a JSONPath, a [`TableData`](crate::table::TableData) sorted, a
7//! git [`Patch`](crate::diff::git::Patch) limited to some files. A
8//! [`Pipeline`] runs named stages in the order they were added, and says which
9//! stage failed.
10//!
11//! Text transforms are the plugin-facing kind: they implement
12//! [`TextTransform`] from the plugin contract, so a plugin can register one
13//! (see [`ExtensionRegistry::text_pipeline`](crate::ExtensionRegistry::text_pipeline)).
14//! Every [`TextTransform`] is also a `Transform<Text>`.
15//!
16//! ```
17//! use rich::{Style, Text};
18//! use rich_ext::transform::{HighlightMatches, KeepLines, Pipeline};
19//!
20//! let pipeline = Pipeline::new()
21//!     .then("filter", KeepLines::new("ERROR|WARN").unwrap())
22//!     .then("highlight", HighlightMatches::new("ERROR", Style::parse("bold red").unwrap()).unwrap());
23//! assert_eq!(pipeline.names(), ["filter", "highlight"]);
24//!
25//! let log = Text::new("INFO start\nWARN slow\nERROR failed\n");
26//! let text = pipeline.apply(log).unwrap();
27//! assert_eq!(text.plain(), "WARN slow\nERROR failed\n");
28//! ```
29
30use std::fmt;
31
32use rich::{Style, Text};
33use rich_plugin_api::PluginError;
34pub use rich_plugin_api::TextTransform;
35
36/// Why a transform failed.
37#[derive(Clone, Debug, PartialEq, Eq)]
38pub struct TransformError {
39    message: String,
40}
41
42impl TransformError {
43    pub fn new(message: impl Into<String>) -> Self {
44        TransformError {
45            message: message.into(),
46        }
47    }
48
49    pub fn message(&self) -> &str {
50        &self.message
51    }
52}
53
54impl fmt::Display for TransformError {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        f.write_str(&self.message)
57    }
58}
59
60impl std::error::Error for TransformError {}
61
62impl From<PluginError> for TransformError {
63    fn from(error: PluginError) -> Self {
64        TransformError::new(error.to_string())
65    }
66}
67
68/// Rewrites a `T`.
69pub trait Transform<T>: Send + Sync {
70    fn apply(&self, input: T) -> Result<T, TransformError>;
71}
72
73impl<X: TextTransform + ?Sized> Transform<Text> for X {
74    fn apply(&self, input: Text) -> Result<Text, TransformError> {
75        Ok(self.transform(input)?)
76    }
77}
78
79/// A transform from a function.
80pub fn from_fn<T, F>(f: F) -> impl Transform<T>
81where
82    F: Fn(T) -> Result<T, TransformError> + Send + Sync,
83{
84    FromFn(f)
85}
86
87struct FromFn<F>(F);
88
89impl<T, F> Transform<T> for FromFn<F>
90where
91    F: Fn(T) -> Result<T, TransformError> + Send + Sync,
92{
93    fn apply(&self, input: T) -> Result<T, TransformError> {
94        (self.0)(input)
95    }
96}
97
98/// Which stage of a [`Pipeline`] failed, and why.
99#[derive(Clone, Debug, PartialEq, Eq)]
100pub struct PipelineError {
101    /// The stage's name, as given to [`Pipeline::then`].
102    pub stage: String,
103    pub error: TransformError,
104}
105
106impl fmt::Display for PipelineError {
107    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108        write!(f, "{}: {}", self.stage, self.error)
109    }
110}
111
112impl std::error::Error for PipelineError {}
113
114/// Named transforms run in order: each stage gets the previous one's output.
115pub struct Pipeline<T> {
116    stages: Vec<(String, Box<dyn Transform<T>>)>,
117}
118
119impl<T> Default for Pipeline<T> {
120    fn default() -> Self {
121        Pipeline { stages: Vec::new() }
122    }
123}
124
125impl<T> fmt::Debug for Pipeline<T> {
126    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127        f.debug_struct("Pipeline")
128            .field("stages", &self.names())
129            .finish()
130    }
131}
132
133impl<T> Pipeline<T> {
134    pub fn new() -> Self {
135        Self::default()
136    }
137
138    /// Add a stage at the end.
139    #[must_use]
140    pub fn then(mut self, name: impl Into<String>, transform: impl Transform<T> + 'static) -> Self {
141        self.push(name, Box::new(transform));
142        self
143    }
144
145    /// Add a boxed stage at the end.
146    pub fn push(&mut self, name: impl Into<String>, transform: Box<dyn Transform<T>>) {
147        self.stages.push((name.into(), transform));
148    }
149
150    /// The stages' names, in order.
151    pub fn names(&self) -> Vec<&str> {
152        self.stages.iter().map(|(name, _)| name.as_str()).collect()
153    }
154
155    pub fn len(&self) -> usize {
156        self.stages.len()
157    }
158
159    pub fn is_empty(&self) -> bool {
160        self.stages.is_empty()
161    }
162
163    /// Run every stage in order.
164    pub fn apply(&self, input: T) -> Result<T, PipelineError> {
165        self.stages
166            .iter()
167            .try_fold(input, |value, (stage, transform)| {
168                transform.apply(value).map_err(|error| PipelineError {
169                    stage: stage.clone(),
170                    error,
171                })
172            })
173    }
174}
175
176/// A pipeline is itself a transform, so pipelines nest.
177impl<T> Transform<T> for Pipeline<T> {
178    fn apply(&self, input: T) -> Result<T, TransformError> {
179        Pipeline::apply(self, input).map_err(|error| TransformError::new(error.to_string()))
180    }
181}
182
183/// A regular expression compiled once and run on plain `&str`: what a
184/// filter tests each row or cell with, where building a [`Text`] for
185/// [`KeepLines`] would cost more than the match. The same engine and
186/// syntax as [`KeepLines`] and [`HighlightMatches`] (fancy-regex:
187/// look-around and back-references).
188///
189/// ```
190/// use rich_ext::transform::Pattern;
191///
192/// let pattern = Pattern::new("(?i)name9+$").unwrap();
193/// assert!(pattern.is_match("Name99"));
194/// assert!(!pattern.is_match("name98"));
195/// assert_eq!(pattern.as_str(), "(?i)name9+$");
196/// assert!(Pattern::new("(").is_err());
197/// ```
198#[derive(Clone, Debug)]
199pub struct Pattern {
200    regex: fancy_regex::Regex,
201}
202
203impl Pattern {
204    /// Compile `pattern`, or say why it is not one.
205    pub fn new(pattern: &str) -> Result<Self, TransformError> {
206        let regex = fancy_regex::Regex::new(pattern)
207            .map_err(|e| TransformError::new(format!("invalid pattern {pattern:?}: {e}")))?;
208        Ok(Pattern { regex })
209    }
210
211    /// Whether it matches anywhere in `text`; `false` too where the engine
212    /// gives up (its backtrack limit), which [`try_is_match`](Self::try_is_match)
213    /// reports instead.
214    pub fn is_match(&self, text: &str) -> bool {
215        self.regex.is_match(text).unwrap_or(false)
216    }
217
218    /// Whether it matches anywhere in `text`, or the engine's error.
219    pub fn try_is_match(&self, text: &str) -> Result<bool, TransformError> {
220        self.regex
221            .is_match(text)
222            .map_err(|e| TransformError::new(format!("match failed: {e}")))
223    }
224
225    /// The pattern as it was written.
226    pub fn as_str(&self) -> &str {
227        self.regex.as_str()
228    }
229}
230
231/// Keeps the lines of a text that match a regular expression (or, inverted,
232/// those that do not). Styles are kept, and the result ends with a newline
233/// only if the input did.
234#[derive(Clone, Debug)]
235pub struct KeepLines {
236    regex: fancy_regex::Regex,
237    invert: bool,
238}
239
240impl KeepLines {
241    /// Keep lines matching `pattern` anywhere.
242    pub fn new(pattern: &str) -> Result<Self, TransformError> {
243        let regex = fancy_regex::Regex::new(pattern)
244            .map_err(|e| TransformError::new(format!("invalid pattern {pattern:?}: {e}")))?;
245        Ok(KeepLines {
246            regex,
247            invert: false,
248        })
249    }
250
251    /// Keep the lines that do *not* match instead.
252    #[must_use]
253    pub fn invert(mut self, invert: bool) -> Self {
254        self.invert = invert;
255        self
256    }
257}
258
259impl TextTransform for KeepLines {
260    fn transform(&self, text: Text) -> Result<Text, PluginError> {
261        if text.plain().is_empty() {
262            return Ok(text);
263        }
264        let mut kept = text.blank_copy();
265        for line in text.split("\n", true, false) {
266            let content = line.plain().strip_suffix('\n').unwrap_or(line.plain());
267            let matched = self
268                .regex
269                .is_match(content)
270                .map_err(|e| PluginError::Other(format!("filter failed: {e}")))?;
271            if matched != self.invert {
272                kept = kept.append_text(&line);
273            }
274        }
275        // End the way the input did: a kept middle line brings its `\n`.
276        if !text.plain().ends_with('\n') && kept.plain().ends_with('\n') {
277            kept.right_crop(1);
278        }
279        Ok(kept)
280    }
281}
282
283/// Styles every match of a regular expression.
284///
285/// Only whole matches are styled: unlike [`Text::highlight_regex`], a named
286/// group is just a group here, never a style name. A match the regex engine
287/// cannot finish (its backtrack limit) is an error, as it is for [`KeepLines`].
288#[derive(Clone, Debug)]
289pub struct HighlightMatches {
290    regex: fancy_regex::Regex,
291    style: Style,
292}
293
294impl HighlightMatches {
295    pub fn new(pattern: &str, style: Style) -> Result<Self, TransformError> {
296        let regex = fancy_regex::Regex::new(pattern)
297            .map_err(|e| TransformError::new(format!("invalid pattern {pattern:?}: {e}")))?;
298        Ok(HighlightMatches { regex, style })
299    }
300}
301
302impl TextTransform for HighlightMatches {
303    fn transform(&self, mut text: Text) -> Result<Text, PluginError> {
304        let mut ranges = Vec::new();
305        for found in self.regex.find_iter(text.plain()) {
306            let found = found.map_err(|e| PluginError::Other(format!("highlight failed: {e}")))?;
307            ranges.push(found.range());
308        }
309        // `Text` spans are byte offsets, the same units the regex reports.
310        for range in ranges {
311            text.stylize(self.style.clone(), range.start, range.end);
312        }
313        Ok(text)
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    #[test]
322    fn keep_lines_keeps_endings_and_styles() {
323        let mut text = Text::new("one\ntwo\nthree");
324        text.stylize(Style::parse("bold").unwrap(), 4, 7);
325        let kept = KeepLines::new("t").unwrap().transform(text).unwrap();
326        assert_eq!(kept.plain(), "two\nthree");
327        assert_eq!(kept.spans().len(), 1);
328        assert_eq!((kept.spans()[0].start, kept.spans()[0].end), (0, 3));
329
330        let dropped = KeepLines::new("t")
331            .unwrap()
332            .invert(true)
333            .transform(Text::new("one\ntwo\n"))
334            .unwrap();
335        assert_eq!(dropped.plain(), "one\n");
336        let first = KeepLines::new("a")
337            .unwrap()
338            .transform(Text::new("a\nb"))
339            .unwrap();
340        assert_eq!(first.plain(), "a");
341        assert_eq!(
342            KeepLines::new("x")
343                .unwrap()
344                .transform(Text::new("a\nb"))
345                .unwrap()
346                .plain(),
347            ""
348        );
349        assert!(KeepLines::new("(").is_err());
350    }
351
352    #[test]
353    fn a_pattern_matches_plain_text_as_keep_lines_does() {
354        let pattern = Pattern::new(r"(?<=a)b|^c").unwrap();
355        assert!(pattern.is_match("ab"));
356        assert!(pattern.is_match("cat"));
357        assert!(!pattern.is_match("b c"));
358        assert_eq!(pattern.try_is_match("xab"), Ok(true));
359        let err = Pattern::new("[").unwrap_err();
360        assert!(err.message().starts_with("invalid pattern"), "{err}");
361    }
362
363    #[test]
364    fn highlight_matches_styles_each_match() {
365        let text = HighlightMatches::new("o+", Style::parse("red").unwrap())
366            .unwrap()
367            .transform(Text::new("foo boo"))
368            .unwrap();
369        let ranges: Vec<_> = text.spans().iter().map(|s| (s.start, s.end)).collect();
370        assert_eq!(ranges, [(1, 3), (5, 7)]);
371    }
372
373    #[test]
374    fn highlight_matches_styles_whole_matches_only() {
375        // A named group is not a style name here, unlike `highlight_regex`.
376        let text = HighlightMatches::new("(?P<blink>ERR)OR", Style::parse("reverse").unwrap())
377            .unwrap()
378            .transform(Text::new("an ERROR"))
379            .unwrap();
380        assert_eq!(text.spans().len(), 1);
381        assert_eq!((text.spans()[0].start, text.spans()[0].end), (3, 8));
382    }
383
384    #[test]
385    fn highlight_matches_reports_the_backtrack_limit_like_keep_lines() {
386        let input = || Text::new("a".repeat(30));
387        let pattern = r"^(a|a)*\1b";
388        assert!(KeepLines::new(pattern).unwrap().transform(input()).is_err());
389        assert!(
390            HighlightMatches::new(pattern, Style::parse("reverse").unwrap())
391                .unwrap()
392                .transform(input())
393                .is_err()
394        );
395    }
396
397    #[test]
398    fn a_pipeline_runs_in_order_and_names_the_failing_stage() {
399        let pipeline: Pipeline<Vec<u8>> = Pipeline::new()
400            .then(
401                "push",
402                from_fn(|mut v: Vec<u8>| {
403                    v.push(1);
404                    Ok(v)
405                }),
406            )
407            .then(
408                "double",
409                from_fn(|v: Vec<u8>| Ok(v.into_iter().map(|x| x * 2).collect())),
410            );
411        assert_eq!(pipeline.apply(vec![3]).unwrap(), [6, 2]);
412
413        let failing = pipeline.then("fail", from_fn(|_: Vec<u8>| Err(TransformError::new("no"))));
414        let error = failing.apply(Vec::new()).unwrap_err();
415        assert_eq!(error.stage, "fail");
416        assert_eq!(error.to_string(), "fail: no");
417    }
418}