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    pub fn then(mut self, name: impl Into<String>, transform: impl Transform<T> + 'static) -> Self {
140        self.push(name, Box::new(transform));
141        self
142    }
143
144    /// Add a boxed stage at the end.
145    pub fn push(&mut self, name: impl Into<String>, transform: Box<dyn Transform<T>>) {
146        self.stages.push((name.into(), transform));
147    }
148
149    /// The stages' names, in order.
150    pub fn names(&self) -> Vec<&str> {
151        self.stages.iter().map(|(name, _)| name.as_str()).collect()
152    }
153
154    pub fn len(&self) -> usize {
155        self.stages.len()
156    }
157
158    pub fn is_empty(&self) -> bool {
159        self.stages.is_empty()
160    }
161
162    /// Run every stage in order.
163    pub fn apply(&self, input: T) -> Result<T, PipelineError> {
164        self.stages
165            .iter()
166            .try_fold(input, |value, (stage, transform)| {
167                transform.apply(value).map_err(|error| PipelineError {
168                    stage: stage.clone(),
169                    error,
170                })
171            })
172    }
173}
174
175/// A pipeline is itself a transform, so pipelines nest.
176impl<T> Transform<T> for Pipeline<T> {
177    fn apply(&self, input: T) -> Result<T, TransformError> {
178        Pipeline::apply(self, input).map_err(|error| TransformError::new(error.to_string()))
179    }
180}
181
182/// Keeps the lines of a text that match a regular expression (or, inverted,
183/// those that do not). Styles are kept, and the result ends with a newline
184/// only if the input did.
185#[derive(Clone, Debug)]
186pub struct KeepLines {
187    regex: fancy_regex::Regex,
188    invert: bool,
189}
190
191impl KeepLines {
192    /// Keep lines matching `pattern` anywhere.
193    pub fn new(pattern: &str) -> Result<Self, TransformError> {
194        let regex = fancy_regex::Regex::new(pattern)
195            .map_err(|e| TransformError::new(format!("invalid pattern {pattern:?}: {e}")))?;
196        Ok(KeepLines {
197            regex,
198            invert: false,
199        })
200    }
201
202    /// Keep the lines that do *not* match instead.
203    pub fn invert(mut self, invert: bool) -> Self {
204        self.invert = invert;
205        self
206    }
207}
208
209impl TextTransform for KeepLines {
210    fn transform(&self, text: Text) -> Result<Text, PluginError> {
211        if text.plain().is_empty() {
212            return Ok(text);
213        }
214        let mut kept = text.blank_copy();
215        for line in text.split("\n", true, false) {
216            let content = line.plain().strip_suffix('\n').unwrap_or(line.plain());
217            let matched = self
218                .regex
219                .is_match(content)
220                .map_err(|e| PluginError::Other(format!("filter failed: {e}")))?;
221            if matched != self.invert {
222                kept = kept.append_text(&line);
223            }
224        }
225        // End the way the input did: a kept middle line brings its `\n`.
226        if !text.plain().ends_with('\n') && kept.plain().ends_with('\n') {
227            kept.right_crop(1);
228        }
229        Ok(kept)
230    }
231}
232
233/// Styles every match of a regular expression.
234///
235/// Only whole matches are styled: unlike [`Text::highlight_regex`], a named
236/// group is just a group here, never a style name. A match the regex engine
237/// cannot finish (its backtrack limit) is an error, as it is for [`KeepLines`].
238#[derive(Clone, Debug)]
239pub struct HighlightMatches {
240    regex: fancy_regex::Regex,
241    style: Style,
242}
243
244impl HighlightMatches {
245    pub fn new(pattern: &str, style: Style) -> Result<Self, TransformError> {
246        let regex = fancy_regex::Regex::new(pattern)
247            .map_err(|e| TransformError::new(format!("invalid pattern {pattern:?}: {e}")))?;
248        Ok(HighlightMatches { regex, style })
249    }
250}
251
252impl TextTransform for HighlightMatches {
253    fn transform(&self, mut text: Text) -> Result<Text, PluginError> {
254        let mut ranges = Vec::new();
255        for found in self.regex.find_iter(text.plain()) {
256            let found = found.map_err(|e| PluginError::Other(format!("highlight failed: {e}")))?;
257            ranges.push(found.range());
258        }
259        // `Text` spans are byte offsets, the same units the regex reports.
260        for range in ranges {
261            text.stylize(self.style.clone(), range.start, range.end);
262        }
263        Ok(text)
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn keep_lines_keeps_endings_and_styles() {
273        let mut text = Text::new("one\ntwo\nthree");
274        text.stylize(Style::parse("bold").unwrap(), 4, 7);
275        let kept = KeepLines::new("t").unwrap().transform(text).unwrap();
276        assert_eq!(kept.plain(), "two\nthree");
277        assert_eq!(kept.spans().len(), 1);
278        assert_eq!((kept.spans()[0].start, kept.spans()[0].end), (0, 3));
279
280        let dropped = KeepLines::new("t")
281            .unwrap()
282            .invert(true)
283            .transform(Text::new("one\ntwo\n"))
284            .unwrap();
285        assert_eq!(dropped.plain(), "one\n");
286        let first = KeepLines::new("a")
287            .unwrap()
288            .transform(Text::new("a\nb"))
289            .unwrap();
290        assert_eq!(first.plain(), "a");
291        assert_eq!(
292            KeepLines::new("x")
293                .unwrap()
294                .transform(Text::new("a\nb"))
295                .unwrap()
296                .plain(),
297            ""
298        );
299        assert!(KeepLines::new("(").is_err());
300    }
301
302    #[test]
303    fn highlight_matches_styles_each_match() {
304        let text = HighlightMatches::new("o+", Style::parse("red").unwrap())
305            .unwrap()
306            .transform(Text::new("foo boo"))
307            .unwrap();
308        let ranges: Vec<_> = text.spans().iter().map(|s| (s.start, s.end)).collect();
309        assert_eq!(ranges, [(1, 3), (5, 7)]);
310    }
311
312    #[test]
313    fn highlight_matches_styles_whole_matches_only() {
314        // A named group is not a style name here, unlike `highlight_regex`.
315        let text = HighlightMatches::new("(?P<blink>ERR)OR", Style::parse("reverse").unwrap())
316            .unwrap()
317            .transform(Text::new("an ERROR"))
318            .unwrap();
319        assert_eq!(text.spans().len(), 1);
320        assert_eq!((text.spans()[0].start, text.spans()[0].end), (3, 8));
321    }
322
323    #[test]
324    fn highlight_matches_reports_the_backtrack_limit_like_keep_lines() {
325        let input = || Text::new("a".repeat(30));
326        let pattern = r"^(a|a)*\1b";
327        assert!(KeepLines::new(pattern).unwrap().transform(input()).is_err());
328        assert!(
329            HighlightMatches::new(pattern, Style::parse("reverse").unwrap())
330                .unwrap()
331                .transform(input())
332                .is_err()
333        );
334    }
335
336    #[test]
337    fn a_pipeline_runs_in_order_and_names_the_failing_stage() {
338        let pipeline: Pipeline<Vec<u8>> = Pipeline::new()
339            .then(
340                "push",
341                from_fn(|mut v: Vec<u8>| {
342                    v.push(1);
343                    Ok(v)
344                }),
345            )
346            .then(
347                "double",
348                from_fn(|v: Vec<u8>| Ok(v.into_iter().map(|x| x * 2).collect())),
349            );
350        assert_eq!(pipeline.apply(vec![3]).unwrap(), [6, 2]);
351
352        let failing = pipeline.then("fail", from_fn(|_: Vec<u8>| Err(TransformError::new("no"))));
353        let error = failing.apply(Vec::new()).unwrap_err();
354        assert_eq!(error.stage, "fail");
355        assert_eq!(error.to_string(), "fail: no");
356    }
357}