Skip to main content

isml_lsp/
metadata.rs

1//! Custom attributes from the metadata XML in the repository, the only local record of what `x.custom.y` may be.
2
3use std::collections::BTreeMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7const MAX_DEPTH: usize = 8;
8const SKIPPED: [&str; 7] = [
9    "node_modules",
10    ".git",
11    "static",
12    "build",
13    "dist",
14    "coverage",
15    "target",
16];
17
18/// The type extension holding the site preferences, which are reached through
19/// `getCustomPreferenceValue` rather than a `.custom.` access.
20pub const SITE_PREFERENCES: &str = "SitePreferences";
21
22#[derive(Debug, Clone, Default)]
23pub struct AttributeDefinition {
24    pub id: String,
25    /// The label Business Manager shows.
26    pub display_name: Option<String>,
27    pub value_type: Option<String>,
28    pub values: Vec<String>,
29    pub mandatory: bool,
30    pub localizable: bool,
31}
32
33impl AttributeDefinition {
34    /// One line for a completion list: what it is, not where it came from.
35    pub fn detail(&self) -> String {
36        let mut detail = self.value_type.clone().unwrap_or_else(|| "?".into());
37        if self.localizable {
38            detail.push_str(", localizable");
39        }
40        if self.mandatory {
41            detail.push_str(", mandatory");
42        }
43        detail
44    }
45
46    pub fn documentation(&self) -> Option<String> {
47        let mut lines = Vec::new();
48        if let Some(name) = &self.display_name {
49            lines.push(name.clone());
50        }
51        if !self.values.is_empty() {
52            lines.push(format!("Values: {}", self.values.join(", ")));
53        }
54        (!lines.is_empty()).then(|| lines.join("\n\n"))
55    }
56}
57
58#[derive(Debug, Default)]
59pub struct Metadata {
60    types: BTreeMap<String, BTreeMap<String, AttributeDefinition>>,
61    sources: usize,
62}
63
64impl Metadata {
65    pub fn scan(roots: &[PathBuf]) -> Metadata {
66        let mut metadata = Metadata::default();
67        for root in roots {
68            metadata.visit(root, 0);
69        }
70        metadata
71    }
72
73    /// False when no metadata was found at all, which has to silence every
74    /// diagnostic: an unknown attribute and an unknown instance look the same.
75    pub fn is_loaded(&self) -> bool {
76        self.sources > 0
77    }
78
79    pub fn attributes_of(&self, type_id: &str) -> Option<&BTreeMap<String, AttributeDefinition>> {
80        self.types.get(type_id)
81    }
82
83    pub fn attribute(&self, type_id: &str, attribute_id: &str) -> Option<&AttributeDefinition> {
84        self.types.get(type_id)?.get(attribute_id)
85    }
86
87    /// Whether the metadata extends this object type at all. A type it never
88    /// mentions says nothing about an attribute, so nothing is reported for it.
89    pub fn knows(&self, type_id: &str) -> bool {
90        self.types.contains_key(type_id)
91    }
92
93    fn visit(&mut self, dir: &Path, depth: usize) {
94        if depth > MAX_DEPTH {
95            return;
96        }
97        let Ok(entries) = fs::read_dir(dir) else {
98            return;
99        };
100        for entry in entries.flatten() {
101            let path = entry.path();
102            let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
103                continue;
104            };
105            if entry.file_type().is_ok_and(|kind| kind.is_dir()) {
106                if !SKIPPED.contains(&name) && !name.starts_with('.') {
107                    self.visit(&path, depth + 1);
108                }
109                continue;
110            }
111            if is_metadata_file(name) {
112                self.absorb(&path);
113            }
114        }
115    }
116
117    fn absorb(&mut self, file: &Path) {
118        let Ok(text) = fs::read_to_string(file) else {
119            return;
120        };
121        let Ok(document) = roxmltree::Document::parse(&text) else {
122            return;
123        };
124        self.sources += 1;
125        for node in document.root_element().children() {
126            match local_name(node) {
127                // <type-extension type-id="Product"> extends a system object.
128                "type-extension" => self.absorb_definitions(node, "custom-attribute-definitions"),
129                // <custom-type type-id="MyObject"> defines a custom object.
130                "custom-type" => self.absorb_definitions(node, "attribute-definitions"),
131                _ => {}
132            }
133        }
134    }
135
136    fn absorb_definitions(&mut self, type_node: roxmltree::Node, container: &str) {
137        let Some(type_id) = type_node.attribute("type-id") else {
138            return;
139        };
140        let entry = self.types.entry(type_id.to_string()).or_default();
141        for group in type_node
142            .children()
143            .filter(|node| local_name(*node) == container)
144        {
145            for node in group
146                .children()
147                .filter(|node| local_name(*node) == "attribute-definition")
148            {
149                if let Some(definition) = read_definition(node) {
150                    entry.insert(definition.id.clone(), definition);
151                }
152            }
153        }
154    }
155}
156
157fn is_metadata_file(name: &str) -> bool {
158    name.ends_with("objecttype-extensions.xml") || name.ends_with("objecttype-definitions.xml")
159}
160
161fn local_name<'a>(node: roxmltree::Node<'a, 'a>) -> &'a str {
162    node.tag_name().name()
163}
164
165fn read_definition(node: roxmltree::Node) -> Option<AttributeDefinition> {
166    let id = node.attribute("attribute-id")?;
167    let mut definition = AttributeDefinition {
168        id: id.to_string(),
169        ..Default::default()
170    };
171
172    for child in node.children().filter(|child| child.is_element()) {
173        let text = child.text().map(str::trim).unwrap_or_default();
174        match local_name(child) {
175            "display-name" if definition.display_name.is_none() => {
176                definition.display_name = Some(text.to_string());
177            }
178            "type" => definition.value_type = Some(text.to_string()),
179            "mandatory-flag" => definition.mandatory = text == "true",
180            "localizable-flag" => definition.localizable = text == "true",
181            "value-definitions" => definition.values = read_values(child),
182            _ => {}
183        }
184    }
185    Some(definition)
186}
187
188fn read_values(node: roxmltree::Node) -> Vec<String> {
189    node.children()
190        .filter(|child| local_name(*child) == "value-definition")
191        .filter_map(|child| {
192            child
193                .children()
194                .find(|leaf| local_name(*leaf) == "value")
195                .and_then(|leaf| leaf.text())
196                .map(|text| text.trim().to_string())
197        })
198        .collect()
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    const SAMPLE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
206<metadata xmlns="http://www.demandware.com/xml/impex/metadata/2006-10-31">
207    <type-extension type-id="Product">
208        <custom-attribute-definitions>
209            <attribute-definition attribute-id="season">
210                <display-name xml:lang="x-default">Drop</display-name>
211                <type>enum-of-string</type>
212                <mandatory-flag>false</mandatory-flag>
213                <value-definitions>
214                    <value-definition><value>SS25</value></value-definition>
215                    <value-definition><value>FW25</value></value-definition>
216                </value-definitions>
217            </attribute-definition>
218        </custom-attribute-definitions>
219        <system-attribute-definitions>
220            <attribute-definition attribute-id="onlineFlag"/>
221        </system-attribute-definitions>
222    </type-extension>
223    <type-extension type-id="SitePreferences">
224        <custom-attribute-definitions>
225            <attribute-definition attribute-id="newsletterEnabled">
226                <type>boolean</type>
227                <mandatory-flag>true</mandatory-flag>
228            </attribute-definition>
229        </custom-attribute-definitions>
230    </type-extension>
231</metadata>
232"#;
233
234    /// Tests run in parallel, so each gets its own directory.
235    fn sample(test: &str) -> Metadata {
236        let directory = std::env::temp_dir().join(format!("isml-lsp-metadata-{test}"));
237        let _ = fs::create_dir_all(&directory);
238        fs::write(directory.join("system-objecttype-extensions.xml"), SAMPLE).unwrap();
239        Metadata::scan(&[directory])
240    }
241
242    #[test]
243    fn reads_custom_attributes_and_leaves_system_ones_out() {
244        let metadata = sample("system-out");
245        assert!(metadata.attribute("Product", "season").is_some());
246        assert!(metadata.attribute("Product", "onlineFlag").is_none());
247    }
248
249    #[test]
250    fn reads_the_enumerated_values_and_the_type() {
251        let metadata = sample("values");
252        let definition = metadata.attribute("Product", "season").unwrap();
253        assert_eq!(definition.values, ["SS25", "FW25"]);
254        assert_eq!(definition.value_type.as_deref(), Some("enum-of-string"));
255        assert_eq!(definition.display_name.as_deref(), Some("Drop"));
256    }
257
258    #[test]
259    fn keeps_site_preferences_as_their_own_type() {
260        let metadata = sample("preferences");
261        assert!(metadata
262            .attribute(SITE_PREFERENCES, "newsletterEnabled")
263            .is_some());
264        assert!(metadata.is_loaded());
265    }
266
267    #[test]
268    fn stays_unloaded_when_there_is_no_metadata() {
269        let empty = Metadata::scan(&[std::env::temp_dir().join("isml-lsp-no-such-directory")]);
270        assert!(!empty.is_loaded());
271    }
272}