Skip to main content

morphir_core/naming/
fqname.rs

1use crate::ir::{Diagnostic, DiagnosticCode, DiagnosticError};
2use crate::naming::{name::Name, path::Path};
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6/// FQName represents a Fully Qualified Name (PackagePath + ModulePath + LocalName).
7///
8/// The wire form is the canonical string `package/path:module/path#local-name`; the reader also
9/// accepts the legacy three-element array `[package, module, local]`, where the two paths are
10/// legacy arrays of legacy names. `schemars` is told the schema is a string because that is what
11/// the writer emits and what every schema consumer expects.
12#[derive(Debug, Clone, PartialEq, Eq, Hash, JsonSchema)]
13#[schemars(with = "String")]
14pub struct FQName {
15    pub package_path: Path,
16    pub module_path: Path,
17    pub local_name: Name,
18}
19
20impl FQName {
21    pub fn new(package_path: Path, module_path: Path, local_name: Name) -> Self {
22        Self {
23            package_path,
24            module_path,
25            local_name,
26        }
27    }
28
29    /// Parse FQName from classic format: `pkg:mod:local`
30    pub fn parse(s: &str) -> Option<Self> {
31        let parts: Vec<&str> = s.split(':').collect();
32        if parts.len() != 3 {
33            return None;
34        }
35        let pkg_params = parts[0];
36        let mod_params = parts[1];
37        let local_name = parts[2];
38        // The empty string does not name anything, so `a:b:` is not a fully qualified name.
39        if local_name.is_empty() {
40            return None;
41        }
42
43        Some(Self::new(
44            Path::new(pkg_params),
45            Path::new(mod_params),
46            Name::from(local_name),
47        ))
48    }
49
50    /// Convert to V4 canonical string format: `package/path:module/path#local-name`
51    pub fn to_canonical_string(&self) -> String {
52        format!(
53            "{}:{}#{}",
54            self.package_path, self.module_path, self.local_name
55        )
56    }
57
58    /// Parse from V4 canonical string format: `package/path:module/path#local-name`
59    pub fn from_canonical_string(s: &str) -> Result<Self, String> {
60        // Split on ':' first, then '#' for the local name
61        let colon_pos = s
62            .find(':')
63            .ok_or_else(|| format!("missing ':' in FQName: {}", s))?;
64        let package_str = &s[..colon_pos];
65        let rest = &s[colon_pos + 1..];
66
67        let hash_pos = rest
68            .find('#')
69            .ok_or_else(|| format!("missing '#' in FQName: {}", s))?;
70        let module_str = &rest[..hash_pos];
71        let local_str = &rest[hash_pos + 1..];
72
73        Ok(Self::new(
74            Path::from_canonical_string(package_str)?,
75            Path::from_canonical_string(module_str)?,
76            Name::from_canonical_string(local_str)?,
77        ))
78    }
79}
80
81impl std::fmt::Display for FQName {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        write!(f, "{}", self.to_canonical_string())
84    }
85}
86
87impl From<FQName> for String {
88    fn from(fqname: FQName) -> String {
89        fqname.to_canonical_string()
90    }
91}
92
93impl TryFrom<String> for FQName {
94    type Error = String;
95    fn try_from(s: String) -> Result<Self, Self::Error> {
96        FQName::from_canonical_string(&s)
97    }
98}
99
100impl Serialize for FQName {
101    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
102    where
103        S: serde::Serializer,
104    {
105        serializer.serialize_str(&self.to_canonical_string())
106    }
107}
108
109/// Reads a fully qualified name from the canonical string or the legacy three-element array.
110///
111/// This is written out rather than derived through `#[serde(try_from = "String")]` for two
112/// reasons. A derived `try_from` only ever sees a string, so the legacy array
113/// `[[["morphir"], ["s","d","k"]], [["list"]], ["map"]]` — the spelling every classic document
114/// uses — could not be read at all. And its error is a bare `String`, so the code and the cursor
115/// are lost by the time serde hands it back; the refusals below carry a [`Diagnostic`] through
116/// [`DiagnosticError`] instead.
117impl<'de> Deserialize<'de> for FQName {
118    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
119    where
120        D: serde::Deserializer<'de>,
121    {
122        use serde::de;
123
124        fn carry<E: de::Error>(code: DiagnosticCode, message: impl Into<String>) -> E {
125            E::custom(DiagnosticError(Diagnostic::normalization(
126                code, "/", message,
127            )))
128        }
129
130        let value = serde_json::Value::deserialize(deserializer)?;
131        match value {
132            serde_json::Value::String(text) => FQName::from_canonical_string(&text)
133                .map_err(|error| carry(DiagnosticCode::InvalidFqname, error)),
134            serde_json::Value::Array(items) => {
135                let [package, module, local]: [serde_json::Value; 3] =
136                    items.try_into().map_err(|items: Vec<_>| {
137                        carry::<D::Error>(
138                            DiagnosticCode::InvalidFqname,
139                            format!(
140                                "a legacy fully qualified name is a package, a module and a local \
141                                 name, not {} elements",
142                                items.len()
143                            ),
144                        )
145                    })?;
146                Ok(FQName::new(
147                    serde_json::from_value(package).map_err(de::Error::custom)?,
148                    serde_json::from_value(module).map_err(de::Error::custom)?,
149                    serde_json::from_value(local).map_err(de::Error::custom)?,
150                ))
151            }
152            _ => Err(carry(
153                DiagnosticCode::InvalidType,
154                "a fully qualified name is a canonical string or a legacy three-element array",
155            )),
156        }
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn test_fqname_parsing() {
166        let fq = FQName::parse("org/pkg:mod/sub:Func").unwrap();
167        assert_eq!(fq.package_path.to_string(), "org/pkg");
168        assert_eq!(fq.module_path.to_string(), "mod/sub");
169        assert_eq!(fq.local_name.to_kebab_case(), "func");
170    }
171
172    #[test]
173    fn test_fqname_roundtrip() {
174        let fq = FQName::parse("my/pkg:my/mod:myFunc").unwrap();
175        let s = fq.to_string();
176        assert_eq!(s, "my/pkg:my/mod#my-func");
177    }
178
179    /// The legacy nested array is the spelling every classic document uses for a fully qualified
180    /// name, so the reader has to understand it. A derived `try_from = "String"` only ever sees a
181    /// string and refuses this outright.
182    #[test]
183    fn a_legacy_nested_array_reads_as_a_fully_qualified_name() {
184        let fq: FQName = serde_json::from_str(
185            r#"[[["acme"], ["b", "i"]], [["widget", "kit"]], ["make", "one"]]"#,
186        )
187        .expect("a legacy fully qualified name");
188        assert_eq!(fq.to_canonical_string(), "acme/BI:widget-kit#make-one");
189    }
190
191    #[test]
192    fn the_canonical_string_round_trips_through_serde() {
193        let text = "\"acme/BI:widget-kit#make-one\"";
194        let fq: FQName = serde_json::from_str(text).expect("a canonical fully qualified name");
195        assert_eq!(serde_json::to_string(&fq).unwrap(), text);
196    }
197
198    /// A refusal has to reach the caller as a code and a cursor, not as prose: reporting through
199    /// `serde::de::Error::custom(String)` leaves the caller nothing to answer with but
200    /// `invalid_type`.
201    #[test]
202    fn a_string_that_is_not_a_fully_qualified_name_carries_a_diagnostic() {
203        let error = serde_json::from_str::<FQName>("\"acme/BI\"").unwrap_err();
204        let diagnostic = Diagnostic::from_serde_error(&error).expect("a carried diagnostic");
205        assert_eq!(diagnostic.code, DiagnosticCode::InvalidFqname);
206        assert_eq!(diagnostic.cursor, "/");
207    }
208
209    #[test]
210    fn an_array_of_the_wrong_length_carries_a_diagnostic() {
211        let error = serde_json::from_str::<FQName>(r#"[[["acme"]], [["widget"]]]"#).unwrap_err();
212        let diagnostic = Diagnostic::from_serde_error(&error).expect("a carried diagnostic");
213        assert_eq!(diagnostic.code, DiagnosticCode::InvalidFqname);
214    }
215}