Skip to main content

wdl_modules/
symbolic_path.rs

1//! Symbolic-module-path parsing.
2
3use std::fmt;
4use std::path::Path;
5use std::path::PathBuf;
6use std::str::FromStr;
7
8use thiserror::Error;
9use wdl_grammar::lexer::v1::is_ident;
10
11use crate::DependencyName;
12
13/// An error parsing a [`SymbolicPath`].
14#[derive(Debug, Error)]
15#[error(
16    "symbolic module path `{0}` does not match `<dep-name>[/<sub-path>]` with non-empty, \
17     non-`.`/`..` segments"
18)]
19pub struct SymbolicPathError(String);
20
21/// A symbolic module path.
22///
23/// The string form is `<dep-name>[/<sub-path>]`. The `<dep-name>` is the
24/// key declared under `dependencies` in the consumer's `module.json`; the
25/// optional `<sub-path>` addresses a specific document within a module.
26///
27/// Path components are case-sensitive.
28#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
29pub struct SymbolicPath {
30    /// The dependency name component.
31    dep_name: DependencyName,
32    /// The optional sub-path within the dependency source.
33    sub_path: Option<PathBuf>,
34}
35
36impl SymbolicPath {
37    /// Returns the dependency name component.
38    pub fn dep_name(&self) -> &DependencyName {
39        &self.dep_name
40    }
41
42    /// Returns the sub-path component, if present.
43    pub fn sub_path(&self) -> Option<&Path> {
44        self.sub_path.as_deref()
45    }
46}
47
48/// Validates that a string matches `<dep-name>[/<sub-path>]` where every
49/// component (the dep name and each sub-path segment) is a WDL identifier.
50fn validate(s: String) -> Result<SymbolicPath, SymbolicPathError> {
51    let mut iter = s.split('/');
52    // SAFETY: `str::split` always yields at least one item, even on the
53    // empty string.
54    let head = iter.next().unwrap();
55
56    let dep_name =
57        DependencyName::try_from(head.to_string()).map_err(|_| SymbolicPathError(s.clone()))?;
58
59    let mut sub_path = PathBuf::new();
60    let mut has_tail = false;
61    for segment in iter {
62        if !is_ident(segment) {
63            return Err(SymbolicPathError(s));
64        }
65        sub_path.push(segment);
66        has_tail = true;
67    }
68
69    Ok(SymbolicPath {
70        dep_name,
71        sub_path: if has_tail { Some(sub_path) } else { None },
72    })
73}
74
75impl TryFrom<String> for SymbolicPath {
76    type Error = SymbolicPathError;
77
78    fn try_from(s: String) -> Result<Self, Self::Error> {
79        validate(s)
80    }
81}
82
83impl FromStr for SymbolicPath {
84    type Err = SymbolicPathError;
85
86    fn from_str(s: &str) -> Result<Self, Self::Err> {
87        Self::try_from(s.to_string())
88    }
89}
90
91impl fmt::Display for SymbolicPath {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        f.write_str(self.dep_name.identifier())?;
94        if let Some(sub) = &self.sub_path {
95            for component in sub.iter() {
96                f.write_str("/")?;
97                f.write_str(&component.to_string_lossy())?;
98            }
99        }
100        Ok(())
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn parses_dep_only() {
110        let p: SymbolicPath = "spellbook".parse().unwrap();
111        assert_eq!(p.dep_name().identifier(), "spellbook");
112        assert!(p.sub_path().is_none());
113    }
114
115    #[test]
116    fn parses_with_sub_path() {
117        let p: SymbolicPath = "spellbook/cauldron".parse().unwrap();
118        assert_eq!(p.dep_name().identifier(), "spellbook");
119        assert_eq!(p.sub_path().unwrap(), Path::new("cauldron"));
120    }
121
122    #[test]
123    fn parses_multi_segment_sub_path() {
124        let p: SymbolicPath = "spellbook/cauldron/runes".parse().unwrap();
125        assert_eq!(p.sub_path().unwrap(), Path::new("cauldron/runes"));
126    }
127
128    #[test]
129    fn rejects_invalid_format() {
130        for bad in [
131            "spellbook/",
132            "spellbook//cauldron",
133            "spellbook/cauldron/",
134            "spellbook/..",
135            "spellbook/.",
136            "1spellbook/cauldron",
137            "spellbook/has-dash",       // non-identifier sub-path segment
138            "spellbook/has space",      // whitespace
139            "spellbook/cauldron.runes", // non-identifier
140        ] {
141            assert!(bad.parse::<SymbolicPath>().is_err(), "accepted `{bad}`");
142        }
143    }
144
145    #[test]
146    fn case_sensitive() {
147        let lower: SymbolicPath = "spellbook/cauldron".parse().unwrap();
148        let mixed: SymbolicPath = "spellbook/Cauldron".parse().unwrap();
149        assert_ne!(lower.sub_path(), mixed.sub_path());
150    }
151
152    #[test]
153    fn round_trips_via_display() {
154        for s in [
155            "spellbook",
156            "spellbook/cauldron",
157            "spellbook/cauldron/runes",
158        ] {
159            let p: SymbolicPath = s.parse().unwrap();
160            assert_eq!(p.to_string(), s);
161        }
162    }
163}