Skip to main content

oxirs_arq/
path_expression.rs

1//! SPARQL 1.1 property path expression parser and evaluator.
2//!
3//! This module provides a representation for SPARQL property path expressions
4//! as defined in the SPARQL 1.1 specification, along with utilities for
5//! analysis and serialisation.
6
7use std::collections::BTreeSet;
8
9/// A SPARQL property path expression.
10///
11/// Represents the full set of SPARQL 1.1 property path operators,
12/// enabling in-memory construction, analysis, and serialisation without
13/// requiring a triple store.
14#[derive(Debug, Clone, PartialEq)]
15pub enum PathExpr {
16    /// A single IRI step.
17    Iri(String),
18    /// Inverse path: `^p`.
19    Inverse(Box<PathExpr>),
20    /// Sequence: `p/q`.
21    Sequence(Box<PathExpr>, Box<PathExpr>),
22    /// Alternative: `p|q`.
23    Alternative(Box<PathExpr>, Box<PathExpr>),
24    /// Zero-or-more: `p*`.
25    ZeroOrMore(Box<PathExpr>),
26    /// One-or-more: `p+`.
27    OneOrMore(Box<PathExpr>),
28    /// Zero-or-one: `p?`.
29    ZeroOrOne(Box<PathExpr>),
30    /// Negated property set: `!(p|q)`.
31    NegatedSet(Vec<String>),
32}
33
34impl PathExpr {
35    /// Create a single IRI step path expression.
36    pub fn iri(iri: &str) -> Self {
37        PathExpr::Iri(iri.to_string())
38    }
39
40    /// Create an inverse path expression (`^p`).
41    pub fn inverse(inner: PathExpr) -> Self {
42        PathExpr::Inverse(Box::new(inner))
43    }
44
45    /// Create a sequence path expression (`p/q`).
46    pub fn sequence(left: PathExpr, right: PathExpr) -> Self {
47        PathExpr::Sequence(Box::new(left), Box::new(right))
48    }
49
50    /// Create an alternative path expression (`p|q`).
51    pub fn alternative(left: PathExpr, right: PathExpr) -> Self {
52        PathExpr::Alternative(Box::new(left), Box::new(right))
53    }
54
55    /// Create a zero-or-more repetition path expression (`p*`).
56    pub fn zero_or_more(inner: PathExpr) -> Self {
57        PathExpr::ZeroOrMore(Box::new(inner))
58    }
59
60    /// Create a one-or-more repetition path expression (`p+`).
61    pub fn one_or_more(inner: PathExpr) -> Self {
62        PathExpr::OneOrMore(Box::new(inner))
63    }
64
65    /// Create a zero-or-one path expression (`p?`).
66    pub fn zero_or_one(inner: PathExpr) -> Self {
67        PathExpr::ZeroOrOne(Box::new(inner))
68    }
69
70    /// Create a negated property set path expression (`!(p|q|...)`).
71    ///
72    /// Accepts a slice of IRI strings.
73    pub fn negated_set(iris: &[&str]) -> Self {
74        PathExpr::NegatedSet(iris.iter().map(|s| s.to_string()).collect())
75    }
76
77    /// Returns the nesting depth / complexity of the path expression.
78    ///
79    /// A single IRI or negated set has depth 1. Each wrapping operator adds 1.
80    pub fn depth(&self) -> usize {
81        match self {
82            PathExpr::Iri(_) => 1,
83            PathExpr::NegatedSet(_) => 1,
84            PathExpr::Inverse(inner) => 1 + inner.depth(),
85            PathExpr::ZeroOrMore(inner) => 1 + inner.depth(),
86            PathExpr::OneOrMore(inner) => 1 + inner.depth(),
87            PathExpr::ZeroOrOne(inner) => 1 + inner.depth(),
88            PathExpr::Sequence(left, right) => 1 + left.depth().max(right.depth()),
89            PathExpr::Alternative(left, right) => 1 + left.depth().max(right.depth()),
90        }
91    }
92
93    /// Returns all unique IRIs referenced in the path expression, in sorted order.
94    pub fn iris(&self) -> Vec<String> {
95        let mut set = BTreeSet::new();
96        self.collect_iris(&mut set);
97        set.into_iter().collect()
98    }
99
100    /// Internal recursive IRI collector.
101    fn collect_iris(&self, set: &mut BTreeSet<String>) {
102        match self {
103            PathExpr::Iri(iri) => {
104                set.insert(iri.clone());
105            }
106            PathExpr::NegatedSet(iris) => {
107                for iri in iris {
108                    set.insert(iri.clone());
109                }
110            }
111            PathExpr::Inverse(inner) => inner.collect_iris(set),
112            PathExpr::ZeroOrMore(inner) => inner.collect_iris(set),
113            PathExpr::OneOrMore(inner) => inner.collect_iris(set),
114            PathExpr::ZeroOrOne(inner) => inner.collect_iris(set),
115            PathExpr::Sequence(left, right) => {
116                left.collect_iris(set);
117                right.collect_iris(set);
118            }
119            PathExpr::Alternative(left, right) => {
120                left.collect_iris(set);
121                right.collect_iris(set);
122            }
123        }
124    }
125
126    /// Returns `true` if this path expression can match zero steps (i.e., is nullable).
127    ///
128    /// - `*` and `?` operators are always nullable.
129    /// - `+` is nullable iff its inner expression is nullable (it cannot be nullable
130    ///   unless inner is, but `p+` itself requires ≥1 match).
131    /// - Sequences are nullable only if both arms are nullable.
132    /// - Alternatives are nullable if either arm is nullable.
133    /// - Plain IRIs and negated sets are not nullable.
134    /// - Inverse is nullable iff its inner is nullable.
135    pub fn can_match_zero(&self) -> bool {
136        match self {
137            PathExpr::Iri(_) => false,
138            PathExpr::NegatedSet(_) => false,
139            PathExpr::ZeroOrMore(_) => true,
140            PathExpr::ZeroOrOne(_) => true,
141            PathExpr::OneOrMore(inner) => inner.can_match_zero(),
142            PathExpr::Inverse(inner) => inner.can_match_zero(),
143            PathExpr::Sequence(left, right) => left.can_match_zero() && right.can_match_zero(),
144            PathExpr::Alternative(left, right) => left.can_match_zero() || right.can_match_zero(),
145        }
146    }
147
148    /// Converts the path expression to its SPARQL 1.1 string representation.
149    ///
150    /// Parentheses are inserted to make operator precedence explicit.
151    pub fn to_sparql(&self) -> String {
152        match self {
153            PathExpr::Iri(iri) => iri.clone(),
154            PathExpr::Inverse(inner) => format!("^({})", inner.to_sparql()),
155            PathExpr::Sequence(left, right) => {
156                format!("({}/{})", left.to_sparql(), right.to_sparql())
157            }
158            PathExpr::Alternative(left, right) => {
159                format!("({}|{})", left.to_sparql(), right.to_sparql())
160            }
161            PathExpr::ZeroOrMore(inner) => format!("({})*", inner.to_sparql()),
162            PathExpr::OneOrMore(inner) => format!("({})+", inner.to_sparql()),
163            PathExpr::ZeroOrOne(inner) => format!("({})?", inner.to_sparql()),
164            PathExpr::NegatedSet(iris) => {
165                if iris.is_empty() {
166                    "!()".to_string()
167                } else if iris.len() == 1 {
168                    format!("!{}", iris[0])
169                } else {
170                    format!("!({})", iris.join("|"))
171                }
172            }
173        }
174    }
175
176    /// Returns `true` if the path is a plain IRI step (no operators).
177    pub fn is_simple_iri(&self) -> bool {
178        matches!(self, PathExpr::Iri(_))
179    }
180
181    /// Returns the IRI string if this is a simple IRI step, otherwise `None`.
182    pub fn as_iri(&self) -> Option<&str> {
183        if let PathExpr::Iri(iri) = self {
184            Some(iri.as_str())
185        } else {
186            None
187        }
188    }
189
190    /// Counts the total number of IRI references (including duplicates) in the path.
191    pub fn iri_count(&self) -> usize {
192        match self {
193            PathExpr::Iri(_) => 1,
194            PathExpr::NegatedSet(iris) => iris.len(),
195            PathExpr::Inverse(inner) => inner.iri_count(),
196            PathExpr::ZeroOrMore(inner) => inner.iri_count(),
197            PathExpr::OneOrMore(inner) => inner.iri_count(),
198            PathExpr::ZeroOrOne(inner) => inner.iri_count(),
199            PathExpr::Sequence(left, right) => left.iri_count() + right.iri_count(),
200            PathExpr::Alternative(left, right) => left.iri_count() + right.iri_count(),
201        }
202    }
203}
204
205impl std::fmt::Display for PathExpr {
206    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
207        write!(f, "{}", self.to_sparql())
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    // --- Constructor tests ---
216
217    #[test]
218    fn test_iri_constructor() {
219        let p = PathExpr::iri("http://example.org/p");
220        assert_eq!(p, PathExpr::Iri("http://example.org/p".to_string()));
221    }
222
223    #[test]
224    fn test_inverse_constructor() {
225        let p = PathExpr::inverse(PathExpr::iri(":p"));
226        assert!(matches!(p, PathExpr::Inverse(_)));
227    }
228
229    #[test]
230    fn test_sequence_constructor() {
231        let s = PathExpr::sequence(PathExpr::iri(":p"), PathExpr::iri(":q"));
232        assert!(matches!(s, PathExpr::Sequence(_, _)));
233    }
234
235    #[test]
236    fn test_alternative_constructor() {
237        let a = PathExpr::alternative(PathExpr::iri(":p"), PathExpr::iri(":q"));
238        assert!(matches!(a, PathExpr::Alternative(_, _)));
239    }
240
241    #[test]
242    fn test_zero_or_more_constructor() {
243        let z = PathExpr::zero_or_more(PathExpr::iri(":p"));
244        assert!(matches!(z, PathExpr::ZeroOrMore(_)));
245    }
246
247    #[test]
248    fn test_one_or_more_constructor() {
249        let o = PathExpr::one_or_more(PathExpr::iri(":p"));
250        assert!(matches!(o, PathExpr::OneOrMore(_)));
251    }
252
253    #[test]
254    fn test_zero_or_one_constructor() {
255        let z = PathExpr::zero_or_one(PathExpr::iri(":p"));
256        assert!(matches!(z, PathExpr::ZeroOrOne(_)));
257    }
258
259    #[test]
260    fn test_negated_set_constructor() {
261        let n = PathExpr::negated_set(&[":p", ":q"]);
262        assert!(matches!(n, PathExpr::NegatedSet(_)));
263        if let PathExpr::NegatedSet(iris) = &n {
264            assert_eq!(iris.len(), 2);
265        }
266    }
267
268    #[test]
269    fn test_negated_set_empty() {
270        let n = PathExpr::negated_set(&[]);
271        if let PathExpr::NegatedSet(iris) = &n {
272            assert!(iris.is_empty());
273        }
274    }
275
276    #[test]
277    fn test_negated_set_single() {
278        let n = PathExpr::negated_set(&[":p"]);
279        if let PathExpr::NegatedSet(iris) = &n {
280            assert_eq!(iris.len(), 1);
281            assert_eq!(iris[0], ":p");
282        }
283    }
284
285    // --- depth() tests ---
286
287    #[test]
288    fn test_depth_iri() {
289        assert_eq!(PathExpr::iri(":p").depth(), 1);
290    }
291
292    #[test]
293    fn test_depth_negated_set() {
294        assert_eq!(PathExpr::negated_set(&[":p"]).depth(), 1);
295    }
296
297    #[test]
298    fn test_depth_inverse() {
299        let p = PathExpr::inverse(PathExpr::iri(":p"));
300        assert_eq!(p.depth(), 2);
301    }
302
303    #[test]
304    fn test_depth_zero_or_more() {
305        let p = PathExpr::zero_or_more(PathExpr::iri(":p"));
306        assert_eq!(p.depth(), 2);
307    }
308
309    #[test]
310    fn test_depth_one_or_more() {
311        let p = PathExpr::one_or_more(PathExpr::iri(":p"));
312        assert_eq!(p.depth(), 2);
313    }
314
315    #[test]
316    fn test_depth_zero_or_one() {
317        let p = PathExpr::zero_or_one(PathExpr::iri(":p"));
318        assert_eq!(p.depth(), 2);
319    }
320
321    #[test]
322    fn test_depth_sequence() {
323        // depth = 1 + max(1,1) = 2
324        let p = PathExpr::sequence(PathExpr::iri(":p"), PathExpr::iri(":q"));
325        assert_eq!(p.depth(), 2);
326    }
327
328    #[test]
329    fn test_depth_alternative() {
330        let p = PathExpr::alternative(PathExpr::iri(":p"), PathExpr::iri(":q"));
331        assert_eq!(p.depth(), 2);
332    }
333
334    #[test]
335    fn test_depth_nested_three_levels() {
336        // inverse(sequence(iri, iri)) → depth 3
337        let seq = PathExpr::sequence(PathExpr::iri(":p"), PathExpr::iri(":q"));
338        let p = PathExpr::inverse(seq);
339        assert_eq!(p.depth(), 3);
340    }
341
342    #[test]
343    fn test_depth_deeply_nested() {
344        // zero_or_more(one_or_more(inverse(iri))) → 4
345        let inner = PathExpr::inverse(PathExpr::iri(":x"));
346        let mid = PathExpr::one_or_more(inner);
347        let outer = PathExpr::zero_or_more(mid);
348        assert_eq!(outer.depth(), 4);
349    }
350
351    #[test]
352    fn test_depth_asymmetric_sequence() {
353        // sequence(iri, zero_or_more(iri)) → 1 + max(1,2) = 3
354        let p = PathExpr::sequence(
355            PathExpr::iri(":p"),
356            PathExpr::zero_or_more(PathExpr::iri(":q")),
357        );
358        assert_eq!(p.depth(), 3);
359    }
360
361    // --- iris() tests ---
362
363    #[test]
364    fn test_iris_single_iri() {
365        let p = PathExpr::iri(":a");
366        assert_eq!(p.iris(), vec![":a".to_string()]);
367    }
368
369    #[test]
370    fn test_iris_sequence_two() {
371        let p = PathExpr::sequence(PathExpr::iri(":a"), PathExpr::iri(":b"));
372        let mut iris = p.iris();
373        iris.sort();
374        assert!(iris.contains(&":a".to_string()));
375        assert!(iris.contains(&":b".to_string()));
376    }
377
378    #[test]
379    fn test_iris_deduplicates() {
380        // alternative(:a|:a) should return only one :a
381        let p = PathExpr::alternative(PathExpr::iri(":a"), PathExpr::iri(":a"));
382        assert_eq!(p.iris(), vec![":a".to_string()]);
383    }
384
385    #[test]
386    fn test_iris_negated_set() {
387        let p = PathExpr::negated_set(&[":x", ":y"]);
388        let mut iris = p.iris();
389        iris.sort();
390        assert_eq!(iris, vec![":x".to_string(), ":y".to_string()]);
391    }
392
393    #[test]
394    fn test_iris_sorted_order() {
395        let p = PathExpr::sequence(PathExpr::iri("z:c"), PathExpr::iri("a:b"));
396        let iris = p.iris();
397        // BTreeSet gives sorted output
398        assert_eq!(iris, vec!["a:b".to_string(), "z:c".to_string()]);
399    }
400
401    #[test]
402    fn test_iris_negated_set_empty() {
403        let p = PathExpr::negated_set(&[]);
404        assert!(p.iris().is_empty());
405    }
406
407    // --- can_match_zero() tests ---
408
409    #[test]
410    fn test_can_match_zero_iri_false() {
411        assert!(!PathExpr::iri(":p").can_match_zero());
412    }
413
414    #[test]
415    fn test_can_match_zero_negated_set_false() {
416        assert!(!PathExpr::negated_set(&[":p"]).can_match_zero());
417    }
418
419    #[test]
420    fn test_can_match_zero_star_true() {
421        assert!(PathExpr::zero_or_more(PathExpr::iri(":p")).can_match_zero());
422    }
423
424    #[test]
425    fn test_can_match_zero_question_mark_true() {
426        assert!(PathExpr::zero_or_one(PathExpr::iri(":p")).can_match_zero());
427    }
428
429    #[test]
430    fn test_can_match_zero_plus_non_nullable_inner() {
431        // p+ where p is IRI: cannot match zero
432        assert!(!PathExpr::one_or_more(PathExpr::iri(":p")).can_match_zero());
433    }
434
435    #[test]
436    fn test_can_match_zero_plus_nullable_inner() {
437        // (p*)+ → inner is nullable so result is nullable
438        let star = PathExpr::zero_or_more(PathExpr::iri(":p"));
439        assert!(PathExpr::one_or_more(star).can_match_zero());
440    }
441
442    #[test]
443    fn test_can_match_zero_sequence_both_nullable() {
444        let s = PathExpr::sequence(
445            PathExpr::zero_or_more(PathExpr::iri(":p")),
446            PathExpr::zero_or_one(PathExpr::iri(":q")),
447        );
448        assert!(s.can_match_zero());
449    }
450
451    #[test]
452    fn test_can_match_zero_sequence_one_not_nullable() {
453        let s = PathExpr::sequence(
454            PathExpr::zero_or_more(PathExpr::iri(":p")),
455            PathExpr::iri(":q"),
456        );
457        assert!(!s.can_match_zero());
458    }
459
460    #[test]
461    fn test_can_match_zero_alternative_one_nullable() {
462        let a = PathExpr::alternative(
463            PathExpr::iri(":p"),
464            PathExpr::zero_or_more(PathExpr::iri(":q")),
465        );
466        assert!(a.can_match_zero());
467    }
468
469    #[test]
470    fn test_can_match_zero_alternative_none_nullable() {
471        let a = PathExpr::alternative(PathExpr::iri(":p"), PathExpr::iri(":q"));
472        assert!(!a.can_match_zero());
473    }
474
475    #[test]
476    fn test_can_match_zero_inverse_non_nullable() {
477        assert!(!PathExpr::inverse(PathExpr::iri(":p")).can_match_zero());
478    }
479
480    #[test]
481    fn test_can_match_zero_inverse_of_star() {
482        let star = PathExpr::zero_or_more(PathExpr::iri(":p"));
483        assert!(PathExpr::inverse(star).can_match_zero());
484    }
485
486    // --- to_sparql() tests ---
487
488    #[test]
489    fn test_to_sparql_iri() {
490        assert_eq!(PathExpr::iri(":p").to_sparql(), ":p");
491    }
492
493    #[test]
494    fn test_to_sparql_inverse() {
495        let p = PathExpr::inverse(PathExpr::iri(":p"));
496        assert_eq!(p.to_sparql(), "^(:p)");
497    }
498
499    #[test]
500    fn test_to_sparql_sequence() {
501        let p = PathExpr::sequence(PathExpr::iri(":p"), PathExpr::iri(":q"));
502        assert_eq!(p.to_sparql(), "(:p/:q)");
503    }
504
505    #[test]
506    fn test_to_sparql_alternative() {
507        let p = PathExpr::alternative(PathExpr::iri(":p"), PathExpr::iri(":q"));
508        assert_eq!(p.to_sparql(), "(:p|:q)");
509    }
510
511    #[test]
512    fn test_to_sparql_zero_or_more() {
513        let p = PathExpr::zero_or_more(PathExpr::iri(":p"));
514        assert_eq!(p.to_sparql(), "(:p)*");
515    }
516
517    #[test]
518    fn test_to_sparql_one_or_more() {
519        let p = PathExpr::one_or_more(PathExpr::iri(":p"));
520        assert_eq!(p.to_sparql(), "(:p)+");
521    }
522
523    #[test]
524    fn test_to_sparql_zero_or_one() {
525        let p = PathExpr::zero_or_one(PathExpr::iri(":p"));
526        assert_eq!(p.to_sparql(), "(:p)?");
527    }
528
529    #[test]
530    fn test_to_sparql_negated_set_empty() {
531        let p = PathExpr::negated_set(&[]);
532        assert_eq!(p.to_sparql(), "!()");
533    }
534
535    #[test]
536    fn test_to_sparql_negated_set_single() {
537        let p = PathExpr::negated_set(&[":p"]);
538        assert_eq!(p.to_sparql(), "!:p");
539    }
540
541    #[test]
542    fn test_to_sparql_negated_set_multiple() {
543        let p = PathExpr::negated_set(&[":p", ":q"]);
544        assert_eq!(p.to_sparql(), "!(:p|:q)");
545    }
546
547    #[test]
548    fn test_to_sparql_nested_sequence_of_alternatives() {
549        // (:a|:b)/(:c|:d)
550        let alt1 = PathExpr::alternative(PathExpr::iri(":a"), PathExpr::iri(":b"));
551        let alt2 = PathExpr::alternative(PathExpr::iri(":c"), PathExpr::iri(":d"));
552        let seq = PathExpr::sequence(alt1, alt2);
553        assert_eq!(seq.to_sparql(), "((:a|:b)/(:c|:d))");
554    }
555
556    #[test]
557    fn test_to_sparql_round_trip_display() {
558        let p = PathExpr::one_or_more(PathExpr::inverse(PathExpr::iri(":knows")));
559        let s = p.to_sparql();
560        assert!(s.contains("knows"));
561        assert!(s.contains('+'));
562        assert!(s.contains('^'));
563    }
564
565    // --- Nested / complex path tests ---
566
567    #[test]
568    fn test_nested_sequence_depth() {
569        // ((a/b)/c) → depth 3
570        let ab = PathExpr::sequence(PathExpr::iri(":a"), PathExpr::iri(":b"));
571        let abc = PathExpr::sequence(ab, PathExpr::iri(":c"));
572        assert_eq!(abc.depth(), 3);
573    }
574
575    #[test]
576    fn test_nested_alternative_iris() {
577        let a = PathExpr::alternative(
578            PathExpr::iri(":p"),
579            PathExpr::alternative(PathExpr::iri(":q"), PathExpr::iri(":r")),
580        );
581        let mut iris = a.iris();
582        iris.sort();
583        assert_eq!(
584            iris,
585            vec![":p".to_string(), ":q".to_string(), ":r".to_string()]
586        );
587    }
588
589    #[test]
590    fn test_clone_equality() {
591        let p =
592            PathExpr::zero_or_more(PathExpr::sequence(PathExpr::iri(":a"), PathExpr::iri(":b")));
593        assert_eq!(p.clone(), p);
594    }
595
596    #[test]
597    fn test_is_simple_iri_true() {
598        assert!(PathExpr::iri(":p").is_simple_iri());
599    }
600
601    #[test]
602    fn test_is_simple_iri_false() {
603        assert!(!PathExpr::inverse(PathExpr::iri(":p")).is_simple_iri());
604    }
605
606    #[test]
607    fn test_as_iri_some() {
608        let p = PathExpr::iri(":x");
609        assert_eq!(p.as_iri(), Some(":x"));
610    }
611
612    #[test]
613    fn test_as_iri_none_for_inverse() {
614        let p = PathExpr::inverse(PathExpr::iri(":x"));
615        assert_eq!(p.as_iri(), None);
616    }
617
618    #[test]
619    fn test_iri_count_sequence() {
620        let p = PathExpr::sequence(PathExpr::iri(":a"), PathExpr::iri(":a"));
621        assert_eq!(p.iri_count(), 2);
622    }
623
624    #[test]
625    fn test_iri_count_negated_set() {
626        let p = PathExpr::negated_set(&[":a", ":b", ":c"]);
627        assert_eq!(p.iri_count(), 3);
628    }
629
630    #[test]
631    fn test_display_trait() {
632        let p = PathExpr::iri(":hello");
633        assert_eq!(format!("{}", p), ":hello");
634    }
635
636    #[test]
637    fn test_deep_nesting_can_match_zero() {
638        // sequence(star(iri), star(iri)) → nullable
639        let s = PathExpr::sequence(
640            PathExpr::zero_or_more(PathExpr::iri(":x")),
641            PathExpr::zero_or_more(PathExpr::iri(":y")),
642        );
643        assert!(s.can_match_zero());
644    }
645
646    #[test]
647    fn test_alternative_of_sequences_iris() {
648        // (a/b) | (c/d)
649        let s1 = PathExpr::sequence(PathExpr::iri(":a"), PathExpr::iri(":b"));
650        let s2 = PathExpr::sequence(PathExpr::iri(":c"), PathExpr::iri(":d"));
651        let alt = PathExpr::alternative(s1, s2);
652        let mut iris = alt.iris();
653        iris.sort();
654        assert_eq!(
655            iris,
656            vec![
657                ":a".to_string(),
658                ":b".to_string(),
659                ":c".to_string(),
660                ":d".to_string()
661            ]
662        );
663    }
664
665    #[test]
666    fn test_to_sparql_complex_path() {
667        // ^(:a/:b)*
668        let seq = PathExpr::sequence(PathExpr::iri(":a"), PathExpr::iri(":b"));
669        let star = PathExpr::zero_or_more(seq);
670        let inv = PathExpr::inverse(star);
671        let sparql = inv.to_sparql();
672        assert!(sparql.contains(":a"));
673        assert!(sparql.contains(":b"));
674        assert!(sparql.contains('*'));
675        assert!(sparql.contains('^'));
676    }
677
678    #[test]
679    fn test_negated_set_three_iris() {
680        let n = PathExpr::negated_set(&[":a", ":b", ":c"]);
681        if let PathExpr::NegatedSet(iris) = &n {
682            assert_eq!(iris.len(), 3);
683        } else {
684            panic!("Expected NegatedSet");
685        }
686    }
687
688    #[test]
689    fn test_depth_alternative_asymmetric() {
690        // alternative(iri, zero_or_more(inverse(iri))) → 1 + max(1,3) = 4
691        let deep = PathExpr::zero_or_more(PathExpr::inverse(PathExpr::iri(":x")));
692        let alt = PathExpr::alternative(PathExpr::iri(":y"), deep);
693        assert_eq!(alt.depth(), 4);
694    }
695}