Skip to main content

haz_query/expr/
path.rs

1//! Lifter and intersection check for path-pattern atoms.
2//!
3//! Per `QRY-003`, the `--inputs` and `--outputs` filters accept
4//! atoms drawn from the path-pattern grammar of
5//! `PATH-008..PATH-016`. The lifter
6//! [`parse_path_pattern_atom`] converts a [`RawAtom`] into a
7//! validated [`haz_domain::path::PathPattern`]; the
8//! [`intersects`] function decides whether two path patterns
9//! share at least one matched path, with the glob-vs-glob arm
10//! handled soundly via [`super::glob_intersect`].
11//!
12//! Both patterns passed to [`intersects`] MUST resolve in the
13//! same coordinate system. In practice this means canonicalised
14//! workspace-absolute form; the caller (the query engine) is
15//! responsible for the canonicalisation step before invoking
16//! the intersection check.
17
18use haz_domain::path::PathPattern;
19use haz_query_lang::expr::RawAtom;
20
21use crate::expr::atom::AtomError;
22use crate::expr::glob_intersect::{GlobIntersectError, glob_intersect_non_empty};
23
24/// Parse a raw atom as a [`PathPattern`] per `QRY-003`.
25///
26/// Designed for use with
27/// [`haz_query_lang::expr::Expr::try_map`] to lift an entire
28/// parsed expression to `Expr<PathPattern>` in one step.
29///
30/// # Errors
31///
32/// Returns [`AtomError::InvalidPathPattern`] when the atom text
33/// violates `PATH-001..PATH-016`.
34pub fn parse_path_pattern_atom(atom: RawAtom) -> Result<PathPattern, AtomError> {
35    let RawAtom { text, span } = atom;
36    PathPattern::parse(&text).map_err(|source| AtomError::InvalidPathPattern { span, source })
37}
38
39/// Does the matched set of `lhs` overlap that of `rhs`?
40///
41/// The check is sound (no false positives, no false negatives)
42/// across the three pattern-pair shapes:
43///
44/// - literal/literal: byte-equal comparison of the canonical
45///   forms.
46/// - literal/glob: the literal is tested against the compiled
47///   glob matcher.
48/// - glob/glob: a product-DFA emptiness check via
49///   [`super::glob_intersect`].
50///
51/// Both patterns are assumed to be in the same coordinate
52/// system (typically canonicalised workspace-absolute).
53///
54/// # Errors
55///
56/// Returns [`GlobIntersectError`] only for the glob/glob arm,
57/// when the underlying DFA construction or anchored-start
58/// lookup fails. The literal arms are infallible.
59pub fn intersects(lhs: &PathPattern, rhs: &PathPattern) -> Result<bool, GlobIntersectError> {
60    match (lhs, rhs) {
61        (PathPattern::Literal(a), PathPattern::Literal(b)) => Ok(a == b),
62        (PathPattern::Literal(literal), PathPattern::Glob(g))
63        | (PathPattern::Glob(g), PathPattern::Literal(literal)) => {
64            let compiled = g.compile();
65            let literal_text = literal.to_string();
66            Ok(compiled.compile_matcher().is_match(literal_text.as_str()))
67        }
68        (PathPattern::Glob(g1), PathPattern::Glob(g2)) => {
69            glob_intersect_non_empty(&g1.compile(), &g2.compile())
70        }
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77    use haz_domain::path::PathPatternError;
78    use haz_query_lang::expr::Expr;
79    use haz_query_lang::parser::parse;
80    use haz_query_lang::span::Span;
81
82    fn raw(text: &str, start: usize, end: usize) -> RawAtom {
83        RawAtom {
84            text: text.to_owned(),
85            span: Span { start, end },
86        }
87    }
88
89    // --- Lifter happy paths -----------------------------------
90
91    #[test]
92    fn qry_003_lifts_literal_path_atom() {
93        let pattern = parse_path_pattern_atom(raw("/lib/main.rs", 0, 12)).unwrap();
94        assert!(pattern.is_literal());
95    }
96
97    #[test]
98    fn qry_003_lifts_glob_path_atom() {
99        let pattern = parse_path_pattern_atom(raw("src/**/*.rs", 0, 11)).unwrap();
100        assert!(pattern.is_glob());
101    }
102
103    #[test]
104    fn qry_003_lifts_workspace_absolute_glob() {
105        let pattern = parse_path_pattern_atom(raw("/lib/src/*.rs", 0, 13)).unwrap();
106        assert!(pattern.is_glob());
107    }
108
109    // --- Lifter error paths -----------------------------------
110
111    #[test]
112    fn qry_003_rejects_empty_path_pattern_atom() {
113        let err = parse_path_pattern_atom(raw("", 5, 5)).unwrap_err();
114        match err {
115            AtomError::InvalidPathPattern { span, source } => {
116                assert_eq!(span, Span { start: 5, end: 5 });
117                assert!(matches!(source, PathPatternError::EmptyInput));
118            }
119            other => panic!("expected InvalidPathPattern, got {other:?}"),
120        }
121    }
122
123    #[test]
124    fn qry_003_rejects_double_star_adjacent_to_literal() {
125        // PATH-011: `**` may only be a complete segment.
126        let err = parse_path_pattern_atom(raw("a**b/foo.rs", 2, 13)).unwrap_err();
127        match err {
128            AtomError::InvalidPathPattern {
129                span,
130                source: PathPatternError::InvalidGlobSegment { .. },
131            } => {
132                assert_eq!(span, Span { start: 2, end: 13 });
133            }
134            other => panic!("expected InvalidPathPattern, got {other:?}"),
135        }
136    }
137
138    // --- End-to-end lift via try_map --------------------------
139
140    #[test]
141    fn qry_003_lifts_parsed_expression_to_typed_path_expression() {
142        let expr = parse("src/**/*.rs & !src/tests/**").unwrap();
143        let typed = expr.try_map(parse_path_pattern_atom).unwrap();
144        let src_all = PathPattern::parse("src/**/*.rs").unwrap();
145        let src_tests = PathPattern::parse("src/tests/**").unwrap();
146        let expected = Expr::And(
147            Box::new(Expr::Atom(src_all)),
148            Box::new(Expr::Not(Box::new(Expr::Atom(src_tests)))),
149        );
150        assert_eq!(typed, expected);
151    }
152
153    // --- intersects(): literal/literal arm --------------------
154
155    #[test]
156    fn qry_003_literal_literal_byte_equal_intersect() {
157        let lhs = PathPattern::parse("/lib/main.rs").unwrap();
158        let rhs = PathPattern::parse("/lib/main.rs").unwrap();
159        assert!(intersects(&lhs, &rhs).unwrap());
160    }
161
162    #[test]
163    fn qry_003_literal_literal_byte_inequal_disjoint() {
164        let lhs = PathPattern::parse("/lib/main.rs").unwrap();
165        let rhs = PathPattern::parse("/lib/lib.rs").unwrap();
166        assert!(!intersects(&lhs, &rhs).unwrap());
167    }
168
169    // --- intersects(): literal/glob arm -----------------------
170
171    #[test]
172    fn qry_003_literal_inside_glob_matches() {
173        let lhs = PathPattern::parse("/lib/src/main.rs").unwrap();
174        let rhs = PathPattern::parse("/lib/src/*.rs").unwrap();
175        assert!(intersects(&lhs, &rhs).unwrap());
176    }
177
178    #[test]
179    fn qry_003_literal_outside_glob_does_not_match() {
180        let lhs = PathPattern::parse("/lib/src/main.rs").unwrap();
181        let rhs = PathPattern::parse("/web/src/*.rs").unwrap();
182        assert!(!intersects(&lhs, &rhs).unwrap());
183    }
184
185    #[test]
186    fn qry_003_glob_matches_literal_in_either_direction() {
187        let glob_pat = PathPattern::parse("/lib/**/*.rs").unwrap();
188        let literal = PathPattern::parse("/lib/src/deep/file.rs").unwrap();
189        assert!(intersects(&glob_pat, &literal).unwrap());
190        assert!(intersects(&literal, &glob_pat).unwrap());
191    }
192
193    // --- intersects(): glob/glob arm (Q8.3) -------------------
194
195    #[test]
196    fn qry_003_glob_glob_overlapping_intersect() {
197        let lhs = PathPattern::parse("/lib/src/**/*.rs").unwrap();
198        let rhs = PathPattern::parse("/lib/src/foo/*.rs").unwrap();
199        assert!(intersects(&lhs, &rhs).unwrap());
200    }
201
202    #[test]
203    fn qry_003_glob_glob_disjoint_prefixes_do_not_intersect() {
204        let lhs = PathPattern::parse("/lib/src/**/*.rs").unwrap();
205        let rhs = PathPattern::parse("/web/src/**/*.rs").unwrap();
206        assert!(!intersects(&lhs, &rhs).unwrap());
207    }
208
209    #[test]
210    fn qry_003_glob_glob_disjoint_extensions_do_not_intersect() {
211        let lhs = PathPattern::parse("/lib/**/*.rs").unwrap();
212        let rhs = PathPattern::parse("/lib/**/*.js").unwrap();
213        assert!(!intersects(&lhs, &rhs).unwrap());
214    }
215
216    #[test]
217    fn qry_003_glob_glob_alternation_overlap_intersect() {
218        let lhs = PathPattern::parse("/lib/{a,b}.rs").unwrap();
219        let rhs = PathPattern::parse("/lib/{b,c}.rs").unwrap();
220        assert!(intersects(&lhs, &rhs).unwrap());
221    }
222
223    #[test]
224    fn qry_003_glob_glob_char_class_overlap_intersect() {
225        let lhs = PathPattern::parse("/lib/[abc].rs").unwrap();
226        let rhs = PathPattern::parse("/lib/[bcd].rs").unwrap();
227        assert!(intersects(&lhs, &rhs).unwrap());
228    }
229
230    #[test]
231    fn qry_003_glob_glob_char_class_disjoint_do_not_intersect() {
232        let lhs = PathPattern::parse("/lib/[ab].rs").unwrap();
233        let rhs = PathPattern::parse("/lib/[cd].rs").unwrap();
234        assert!(!intersects(&lhs, &rhs).unwrap());
235    }
236
237    #[test]
238    fn qry_003_glob_glob_double_star_absorbs_single_star_prefix() {
239        let lhs = PathPattern::parse("/lib/**/*.rs").unwrap();
240        let rhs = PathPattern::parse("/lib/*/*.rs").unwrap();
241        assert!(intersects(&lhs, &rhs).unwrap());
242    }
243}