1use 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
18pub const SITE_PREFERENCES: &str = "SitePreferences";
21
22#[derive(Debug, Clone, Default)]
23pub struct AttributeDefinition {
24 pub id: String,
25 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 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 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 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" => self.absorb_definitions(node, "custom-attribute-definitions"),
129 "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 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}