Skip to main content

sim_lib_pattern/
glob_dialect.rs

1//! Shell-glob text-pattern compiler for the shared VM.
2
3use sim_kernel::{Error, Result};
4
5use crate::lua_dialect::parse_set_body;
6use crate::{
7    Anchor, EnginePolicy, IrNode, PatternDialect, PatternIr, RepeatBounds, ScalarDomain, TextClass,
8    TextOp,
9};
10use std::collections::BTreeMap;
11
12/// Glob-only operations admitted by the shared text automaton.
13#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
14pub enum GlobExtension {
15    /// Match one character from a glob character class.
16    Class(TextClass),
17}
18
19/// Compiler for small shell-style glob patterns.
20#[derive(Clone, Copy, Debug, Default)]
21pub struct GlobPatternDialect;
22
23impl PatternDialect for GlobPatternDialect {
24    fn compile(&self, pattern: &str) -> Result<Vec<TextOp>> {
25        let ir = self.compile_ir(pattern)?;
26        Ok(project_compatibility_program(ir.root()))
27    }
28}
29
30impl GlobPatternDialect {
31    /// Lowers glob syntax directly into validated shared pattern IR.
32    pub fn compile_ir(self, pattern: &str) -> Result<PatternIr<ScalarDomain, GlobExtension>> {
33        let chars = pattern.chars().collect::<Vec<_>>();
34        let mut index = 0;
35        let mut nodes = vec![IrNode::Anchor(Anchor::SubjectStart)];
36        while let Some(ch) = chars.get(index).copied() {
37            index += 1;
38            match ch {
39                '*' => {
40                    nodes.push(IrNode::Repeat {
41                        node: Box::new(IrNode::Any),
42                        bounds: RepeatBounds::new(0, None)
43                            .expect("glob star has valid static bounds"),
44                        greedy: true,
45                    });
46                }
47                '?' => nodes.push(IrNode::Any),
48                '[' => {
49                    let negated = matches!(chars.get(index), Some('!') | Some('^'));
50                    if negated {
51                        index += 1;
52                    }
53                    nodes.push(IrNode::Extension(GlobExtension::Class(parse_set_body(
54                        &chars,
55                        &mut index,
56                        negated,
57                        "unterminated glob character set",
58                    )?)));
59                }
60                '\\' => {
61                    let literal = chars
62                        .get(index)
63                        .copied()
64                        .ok_or_else(|| malformed("dangling escape"))?;
65                    index += 1;
66                    nodes.push(IrNode::Symbol(literal));
67                }
68                literal => nodes.push(IrNode::Symbol(literal)),
69            }
70        }
71        nodes.push(IrNode::Anchor(Anchor::SubjectEnd));
72        let root = IrNode::Concat(nodes);
73        let extensions = collect_extensions(&root);
74        PatternIr::new(root, BTreeMap::new(), &EnginePolicy::new(extensions))
75            .map_err(|error| malformed(&error.to_string()))
76    }
77}
78
79/// Compiles a shell-style glob into shared VM operations.
80///
81/// # Errors
82///
83/// Returns an error when the glob pattern is malformed.
84pub fn compile_glob_pattern(pattern: &str) -> Result<Vec<TextOp>> {
85    GlobPatternDialect.compile(pattern)
86}
87
88fn malformed(message: &str) -> Error {
89    Error::Eval(format!("malformed glob pattern: {message}"))
90}
91
92fn collect_extensions(node: &IrNode<char, GlobExtension>) -> Vec<GlobExtension> {
93    let mut extensions = Vec::new();
94    visit(node, &mut |extension| extensions.push(extension.clone()));
95    extensions
96}
97
98fn project_compatibility_program(node: &IrNode<char, GlobExtension>) -> Vec<TextOp> {
99    let mut ops = Vec::new();
100    project(node, &mut ops);
101    ops
102}
103
104fn project(node: &IrNode<char, GlobExtension>, ops: &mut Vec<TextOp>) {
105    match node {
106        IrNode::Symbol(ch) => ops.push(TextOp::Literal(*ch)),
107        IrNode::Any => ops.push(TextOp::Any),
108        IrNode::Concat(nodes) | IrNode::Alternation(nodes) => {
109            for node in nodes {
110                project(node, ops);
111            }
112        }
113        IrNode::Repeat {
114            node,
115            bounds,
116            greedy,
117        } => {
118            project(node, ops);
119            ops.push(TextOp::Repeat {
120                min: bounds.min(),
121                max: bounds.max(),
122                greedy: *greedy,
123            });
124        }
125        IrNode::Group(node) | IrNode::Capture { node, .. } => project(node, ops),
126        IrNode::Anchor(Anchor::SubjectStart) => ops.push(TextOp::AnchorStart),
127        IrNode::Anchor(Anchor::SubjectEnd) => ops.push(TextOp::AnchorEnd),
128        IrNode::Extension(GlobExtension::Class(class)) => ops.push(TextOp::Class(class.clone())),
129        IrNode::Assertion(_) => unreachable!("glob lowering does not create assertions"),
130    }
131}
132
133fn visit(node: &IrNode<char, GlobExtension>, f: &mut impl FnMut(&GlobExtension)) {
134    match node {
135        IrNode::Concat(nodes) | IrNode::Alternation(nodes) => {
136            for node in nodes {
137                visit(node, f);
138            }
139        }
140        IrNode::Repeat { node, .. } | IrNode::Group(node) | IrNode::Capture { node, .. } => {
141            visit(node, f);
142        }
143        IrNode::Extension(extension) => f(extension),
144        IrNode::Symbol(_) | IrNode::Any | IrNode::Anchor(_) | IrNode::Assertion(_) => {}
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn frozen_globs_project_from_validated_ir() {
154        for pattern in ["*.rs", "src/?ain.rs", "file[!0-9].txt", r"literal\*"] {
155            let ir = GlobPatternDialect.compile_ir(pattern).unwrap();
156            assert_eq!(
157                project_compatibility_program(ir.root()),
158                compile_glob_pattern(pattern).unwrap(),
159                "{pattern:?}"
160            );
161        }
162    }
163}