Skip to main content

isml_lsp/
workspace.rs

1use std::collections::HashSet;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::sync::OnceLock;
5
6use crate::cartridgepath::{self, CartridgePath};
7use crate::routes::Controllers;
8
9const MAX_DEPTH: usize = 6;
10const TEMPLATE_DEPTH: usize = 10;
11const SKIPPED: [&str; 8] = [
12    "node_modules",
13    ".git",
14    "static",
15    "build",
16    "dist",
17    "coverage",
18    ".zed",
19    ".vscode",
20];
21
22#[derive(Debug, Clone)]
23pub struct Cartridge {
24    /// The directory name, which is how the cartridge path refers to it.
25    pub name: String,
26    /// The directory holding `cartridge/`, e.g. `.../cartridges/app_brand`.
27    pub root: PathBuf,
28}
29
30impl Cartridge {
31    pub fn cartridge_dir(&self) -> PathBuf {
32        self.root.join("cartridge")
33    }
34}
35
36#[derive(Debug, Default)]
37pub struct Workspace {
38    pub cartridges: Vec<Cartridge>,
39    /// `cartridges/modules` folders, which hold plain CommonJS modules such as
40    /// `server` that are required without a `cartridge/` segment.
41    pub module_roots: Vec<PathBuf>,
42    pub paths: Vec<CartridgePath>,
43    /// Built on the first `template="..."` completion, not at startup: most
44    /// sessions never ask, and walking every cartridge costs a second.
45    templates: OnceLock<Vec<String>>,
46    controllers: OnceLock<Controllers>,
47    resource_keys: OnceLock<HashSet<String>>,
48}
49
50impl Workspace {
51    /// The heavier indexes are left until something asks for them.
52    pub fn scan(roots: &[PathBuf], settings: &serde_json::Value) -> Self {
53        let mut workspace = Workspace::default();
54        for root in roots {
55            workspace.visit(root, 0);
56        }
57        workspace.cartridges.sort_by(|a, b| a.name.cmp(&b.name));
58        workspace.cartridges.dedup_by(|a, b| a.root == b.root);
59        workspace.paths = cartridgepath::load(roots, settings);
60        workspace
61    }
62
63    pub fn cartridge_of(&self, file: &Path) -> Option<&Cartridge> {
64        self.cartridges
65            .iter()
66            .filter(|cartridge| file.starts_with(&cartridge.root))
67            .max_by_key(|cartridge| cartridge.root.as_os_str().len())
68    }
69
70    /// Every cartridge, with the one owning `file` first: `*/cartridge/...`
71    /// most often means "this cartridge, then the rest of the path".
72    pub fn cartridges_from(&self, file: &Path) -> Vec<&Cartridge> {
73        let current = self.cartridge_of(file);
74        let mut ordered: Vec<&Cartridge> = current.into_iter().collect();
75        ordered.extend(self.cartridges.iter().filter(|cartridge| {
76            Some(cartridge.root.as_path()) != current.map(|c| c.root.as_path())
77        }));
78        ordered
79    }
80
81    /// Template paths as SFCC spells them: no locale folder, no `.isml`, forward slashes.
82    /// Deduplicated, since the same path exists in every overriding cartridge.
83    pub fn templates(&self) -> &[String] {
84        self.templates.get_or_init(|| {
85            let mut paths: Vec<String> = Vec::new();
86            for cartridge in &self.cartridges {
87                let root = cartridge.cartridge_dir().join("templates").join("default");
88                collect_templates(&root, &root, 0, &mut paths);
89            }
90            paths.sort();
91            paths.dedup();
92            paths
93        })
94    }
95
96    /// Keys of every default bundle; which bundle is not recorded, since a form resolves labels against several.
97    pub fn resource_keys(&self) -> &HashSet<String> {
98        self.resource_keys.get_or_init(|| {
99            let mut keys = HashSet::new();
100            for cartridge in &self.cartridges {
101                let directory = cartridge
102                    .cartridge_dir()
103                    .join("templates")
104                    .join("resources");
105                collect_keys(&directory, &mut keys);
106            }
107            keys
108        })
109    }
110
111    pub fn controllers(&self) -> &Controllers {
112        self.controllers
113            .get_or_init(|| Controllers::scan(&self.cartridges))
114    }
115
116    fn visit(&mut self, dir: &Path, depth: usize) {
117        if depth > MAX_DEPTH {
118            return;
119        }
120        let Ok(entries) = fs::read_dir(dir) else {
121            return;
122        };
123        for entry in entries.flatten() {
124            if !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
125                continue;
126            }
127            let path = entry.path();
128            let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
129                continue;
130            };
131            if SKIPPED.contains(&name) || name.starts_with('.') {
132                continue;
133            }
134            if name == "cartridge" {
135                if let Some(cartridge) = cartridge_at(&path) {
136                    self.cartridges.push(cartridge);
137                }
138                continue;
139            }
140            if name == "modules" && dir.file_name().is_some_and(|parent| parent == "cartridges") {
141                self.module_roots.push(path.clone());
142                continue;
143            }
144            self.visit(&path, depth + 1);
145        }
146    }
147}
148
149/// Only the suffix-free bundles: a locale file defines no key of its own.
150fn collect_keys(directory: &Path, into: &mut HashSet<String>) {
151    let Ok(entries) = fs::read_dir(directory) else {
152        return;
153    };
154    for entry in entries.flatten() {
155        let path = entry.path();
156        if path
157            .extension()
158            .is_none_or(|extension| extension != "properties")
159        {
160            continue;
161        }
162        let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
163            continue;
164        };
165        if stem.contains('_') {
166            continue;
167        }
168        let Ok(text) = fs::read_to_string(&path) else {
169            continue;
170        };
171        for line in text.lines() {
172            let trimmed = line.trim_start();
173            if trimmed.starts_with('#') || trimmed.starts_with('!') {
174                continue;
175            }
176            if let Some((key, _)) = trimmed.split_once('=') {
177                let key = key.trim();
178                if !key.is_empty() {
179                    into.insert(key.to_string());
180                }
181            }
182        }
183    }
184}
185
186fn collect_templates(dir: &Path, root: &Path, depth: usize, into: &mut Vec<String>) {
187    if depth > TEMPLATE_DEPTH {
188        return;
189    }
190    let Ok(entries) = fs::read_dir(dir) else {
191        return;
192    };
193    for entry in entries.flatten() {
194        let path = entry.path();
195        if entry.file_type().is_ok_and(|kind| kind.is_dir()) {
196            collect_templates(&path, root, depth + 1, into);
197            continue;
198        }
199        if path.extension().is_none_or(|extension| extension != "isml") {
200            continue;
201        }
202        if let Some(name) = template_name(&path, root) {
203            into.push(name);
204        }
205    }
206}
207
208fn template_name(file: &Path, root: &Path) -> Option<String> {
209    let relative = file.strip_prefix(root).ok()?.with_extension("");
210    let joined = relative
211        .components()
212        .map(|component| component.as_os_str().to_string_lossy().into_owned())
213        .collect::<Vec<_>>()
214        .join("/");
215    (!joined.is_empty()).then_some(joined)
216}
217
218fn cartridge_at(cartridge_dir: &Path) -> Option<Cartridge> {
219    let root = cartridge_dir.parent()?.to_path_buf();
220    let name = root.file_name()?.to_str()?.to_string();
221    Some(Cartridge { name, root })
222}