Skip to main content

isml_lsp/
resolve.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use crate::hover;
5use crate::reference::Reference;
6use crate::workspace::Workspace;
7
8const MODULE_EXTENSIONS: [&str; 3] = ["js", "json", "ds"];
9/// `default` holds the fallback template, so it is tried first.
10const TEMPLATE_DIRS: [&str; 1] = ["default"];
11
12pub struct Hit {
13    pub path: PathBuf,
14    /// Zero-based.
15    pub line: u32,
16}
17
18impl From<PathBuf> for Hit {
19    fn from(path: PathBuf) -> Self {
20        Hit { path, line: 0 }
21    }
22}
23
24/// Most relevant first; a template or script usually exists in several overriding cartridges.
25pub fn resolve(reference: &Reference, from: &Path, workspace: &Workspace) -> Vec<Hit> {
26    match reference {
27        Reference::Template(path) => templates(path, from, workspace),
28        Reference::Module(path) => modules(path, from, workspace),
29        Reference::Resource { key, bundle } => resources(key, bundle, from, workspace),
30        Reference::Route { .. } => routes(reference, from, workspace),
31    }
32}
33
34/// Every cartridge declaring the route, the one that runs first.
35fn routes(reference: &Reference, from: &Path, workspace: &Workspace) -> Vec<Hit> {
36    let Some(route) = hover::route_of(reference, from) else {
37        return Vec::new();
38    };
39    hover::targets(&hover::chains(&route, workspace))
40        .into_iter()
41        .map(|(path, line)| Hit { path, line })
42        .collect()
43}
44
45fn templates(template: &str, from: &Path, workspace: &Workspace) -> Vec<Hit> {
46    let relative = format!("{}.isml", template.trim_start_matches('/'));
47    let mut hits = Vec::new();
48    for cartridge in workspace.cartridges_from(from) {
49        let templates_dir = cartridge.cartridge_dir().join("templates");
50        for locale in template_dirs(&templates_dir) {
51            let candidate = templates_dir.join(locale).join(&relative);
52            if candidate.is_file() {
53                hits.push(candidate.into());
54            }
55        }
56    }
57    hits
58}
59
60/// `default` first, then any other locale folder that exists.
61fn template_dirs(templates_dir: &Path) -> Vec<String> {
62    let mut dirs: Vec<String> = TEMPLATE_DIRS.iter().map(|dir| dir.to_string()).collect();
63    let Ok(entries) = fs::read_dir(templates_dir) else {
64        return dirs;
65    };
66    let mut others: Vec<String> = entries
67        .flatten()
68        .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir()))
69        .filter_map(|entry| entry.file_name().to_str().map(str::to_string))
70        .filter(|name| !dirs.contains(name) && name != "resources")
71        .collect();
72    others.sort();
73    dirs.extend(others);
74    dirs
75}
76
77fn modules(module: &str, from: &Path, workspace: &Workspace) -> Vec<Hit> {
78    if module.starts_with("dw/") || module.starts_with("dw.") {
79        return Vec::new();
80    }
81
82    if module.starts_with("./") || module.starts_with("../") {
83        let base = from.parent().unwrap_or(from);
84        return existing_module(&base.join(module.replace('/', std::path::MAIN_SEPARATOR_STR)))
85            .into_iter()
86            .map(Hit::from)
87            .collect();
88    }
89
90    let mut hits = Vec::new();
91
92    if let Some(rest) = module.strip_prefix("*/") {
93        for cartridge in workspace.cartridges_from(from) {
94            push_module(&mut hits, &cartridge.root.join(rest));
95        }
96    } else if let Some(rest) = module.strip_prefix("~/") {
97        if let Some(cartridge) = workspace.cartridge_of(from) {
98            push_module(&mut hits, &cartridge.root.join(rest));
99        }
100    } else if let Some((name, rest)) = module.split_once('/') {
101        // `app_brand/cartridge/scripts/x` — an explicit cartridge.
102        for cartridge in workspace.cartridges_from(from) {
103            if cartridge.name == name {
104                push_module(&mut hits, &cartridge.root.join(rest));
105            }
106        }
107    }
108
109    // Bare specifiers (`server`, `int_gtm`, `dwapi/...`) come from a
110    // `cartridges/modules` folder.
111    if hits.is_empty() {
112        for module_root in &workspace.module_roots {
113            push_module(&mut hits, &module_root.join(module));
114        }
115    }
116
117    hits
118}
119
120fn push_module(hits: &mut Vec<Hit>, candidate: &Path) {
121    if let Some(path) = existing_module(candidate) {
122        hits.push(path.into());
123    }
124}
125
126/// `x` resolves to `x`, `x.js`, `x.json`, `x.ds` or `x/index.js`, as SFCC does.
127fn existing_module(candidate: &Path) -> Option<PathBuf> {
128    if candidate.is_file() {
129        return Some(candidate.to_path_buf());
130    }
131    for extension in MODULE_EXTENSIONS {
132        let with_extension = PathBuf::from(format!("{}.{extension}", candidate.display()));
133        if with_extension.is_file() {
134            return Some(with_extension);
135        }
136    }
137    let index = candidate.join("index.js");
138    index.is_file().then_some(index)
139}
140
141fn resources(key: &str, bundle: &str, from: &Path, workspace: &Workspace) -> Vec<Hit> {
142    let file_name = format!("{bundle}.properties");
143    let bundles: Vec<PathBuf> = workspace
144        .cartridges_from(from)
145        .into_iter()
146        .map(|cartridge| {
147            cartridge
148                .cartridge_dir()
149                .join("templates")
150                .join("resources")
151                .join(&file_name)
152        })
153        .filter(|path| path.is_file())
154        .collect();
155
156    let defining: Vec<Hit> = bundles
157        .iter()
158        .filter_map(|path| {
159            line_of_key(path, key).map(|line| Hit {
160                path: path.clone(),
161                line,
162            })
163        })
164        .collect();
165
166    // A key nobody defines is usually a typo; still offer the bundles it would
167    // belong to rather than nothing at all.
168    if defining.is_empty() {
169        return bundles.into_iter().map(Hit::from).collect();
170    }
171    defining
172}
173
174fn line_of_key(path: &Path, key: &str) -> Option<u32> {
175    let contents = fs::read_to_string(path).ok()?;
176    contents
177        .lines()
178        .position(|line| defines_key(line, key))
179        .map(|index| index as u32)
180}
181
182fn defines_key(line: &str, key: &str) -> bool {
183    let trimmed = line.trim_start();
184    trimmed
185        .strip_prefix(key)
186        .is_some_and(|rest| rest.trim_start().starts_with('='))
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn matches_only_a_whole_key() {
195        assert!(defines_key("label.a.b=Value", "label.a.b"));
196        assert!(defines_key("  label.a.b = Value", "label.a.b"));
197        assert!(!defines_key("label.a.bc=Value", "label.a.b"));
198        assert!(!defines_key("#label.a.b=Value", "label.a.b"));
199    }
200}