Skip to main content

isml_lsp/
api.rs

1//! The `dw.*` API index, generated by `examples/generate-api.rs` and embedded: the checkout has no `.d.ts`.
2
3use std::collections::BTreeMap;
4use std::sync::OnceLock;
5
6const INDEX: &str = include_str!("api.json");
7
8#[derive(Debug, Default, serde::Deserialize)]
9pub struct Class {
10    /// Trimmed to fit a popup.
11    #[serde(rename = "d", default)]
12    pub description: String,
13    #[serde(rename = "c", default)]
14    pub constants: Vec<Member>,
15    #[serde(rename = "p", default)]
16    pub properties: Vec<Member>,
17    #[serde(rename = "m", default)]
18    pub methods: Vec<Member>,
19}
20
21#[derive(Debug, serde::Deserialize)]
22pub struct Member {
23    #[serde(rename = "n")]
24    pub name: String,
25    /// A type for a property or constant, a signature for a method.
26    #[serde(rename = "s", default)]
27    pub shape: String,
28    #[serde(rename = "d", default)]
29    pub description: String,
30}
31
32#[derive(Debug, Default)]
33pub struct Api {
34    classes: BTreeMap<String, Class>,
35}
36
37/// Parsed on the first question, so a session that never asks never pays for it.
38pub fn api() -> &'static Api {
39    static API: OnceLock<Api> = OnceLock::new();
40    API.get_or_init(|| Api {
41        classes: serde_json::from_str(INDEX).unwrap_or_default(),
42    })
43}
44
45impl Api {
46    pub fn class(&self, qualified: &str) -> Option<&Class> {
47        self.classes.get(qualified)
48    }
49
50    /// `TopLevel` classes are left out: they are globals, not modules.
51    pub fn modules(&self) -> impl Iterator<Item = (String, &Class)> {
52        self.classes
53            .iter()
54            .filter(|(name, _)| name.starts_with("dw."))
55            .map(|(name, class)| (name.replace('.', "/"), class))
56    }
57
58    /// `TopLevel` is left out: those are globals, not modules.
59    pub fn classes(&self) -> impl Iterator<Item = (&str, &Class)> {
60        self.classes
61            .iter()
62            .filter(|(name, _)| name.starts_with("dw."))
63            .map(|(name, class)| (name.as_str(), class))
64    }
65
66    pub fn by_module(&self, path: &str) -> Option<&Class> {
67        self.class(&path.trim_matches('/').replace('/', "."))
68    }
69}
70
71impl Class {
72    pub fn members(&self) -> impl Iterator<Item = (&Member, MemberKind)> {
73        let methods = self
74            .methods
75            .iter()
76            .map(|member| (member, MemberKind::Method));
77        let properties = self
78            .properties
79            .iter()
80            .map(|member| (member, MemberKind::Property));
81        let constants = self
82            .constants
83            .iter()
84            .map(|member| (member, MemberKind::Constant));
85        methods.chain(properties).chain(constants)
86    }
87
88    pub fn member(&self, name: &str) -> Option<(&Member, MemberKind)> {
89        self.members().find(|(member, _)| member.name == name)
90    }
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum MemberKind {
95    Method,
96    Property,
97    Constant,
98}
99
100/// Identifiers bound by `require` to an API class, and the class each one names.
101pub fn bindings(text: &str) -> BTreeMap<String, String> {
102    const CALL: &str = "require(";
103    let mut found = BTreeMap::new();
104    for line in text.lines() {
105        let Some(call) = line.find(CALL) else {
106            continue;
107        };
108        let Some(module) = quoted(&line[call + CALL.len()..]) else {
109            continue;
110        };
111        if !module.starts_with("dw/") {
112            continue;
113        }
114        if let Some(name) = assigned_name(&line[..call]) {
115            found.insert(name, module.replace('/', "."));
116        }
117    }
118    found
119}
120
121#[derive(Debug, PartialEq, Eq)]
122pub struct Imports {
123    pub names: BTreeMap<String, String>,
124    /// `var`, `const` or `let` — whichever the document already prefers.
125    pub keyword: String,
126}
127
128impl Imports {
129    pub fn name_of(&self, class: &str) -> Option<&str> {
130        self.names.get(class).map(String::as_str)
131    }
132}
133
134/// Lazy `require`s inside handlers count too: either way the class is already in hand.
135pub fn imports(text: &str) -> Imports {
136    let mut names = BTreeMap::new();
137    let mut keyword = None;
138
139    for line in text.lines() {
140        let Some(declaration) = require_declaration(line) else {
141            continue;
142        };
143        if keyword.is_none() {
144            keyword = Some(declaration.keyword);
145        }
146        if declaration.module.starts_with("dw/") {
147            names
148                .entry(declaration.module.replace('/', "."))
149                .or_insert(declaration.name);
150        }
151    }
152
153    Imports {
154        names,
155        keyword: keyword.unwrap_or_else(|| "var".to_string()),
156    }
157}
158
159struct Declaration {
160    keyword: String,
161    name: String,
162    module: String,
163}
164
165/// `var Site = require('dw/system/Site');` broken into its three parts.
166fn require_declaration(line: &str) -> Option<Declaration> {
167    const CALL: &str = "require(";
168    let call = line.find(CALL)?;
169    let module = quoted(&line[call + CALL.len()..])?;
170    let head = line[..call].trim_end().strip_suffix('=')?.trim();
171
172    let (keyword, name) = head.split_once(char::is_whitespace)?;
173    if !matches!(keyword, "var" | "const" | "let") {
174        return None;
175    }
176    Some(Declaration {
177        keyword: keyword.to_string(),
178        name: name.trim().to_string(),
179        module,
180    })
181}
182
183fn assigned_name(before: &str) -> Option<String> {
184    let head = before.trim_end().strip_suffix('=')?.trim_end();
185    let name: String = head
186        .chars()
187        .rev()
188        .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '$')
189        .collect::<Vec<_>>()
190        .into_iter()
191        .rev()
192        .collect();
193    (!name.is_empty()).then_some(name)
194}
195
196fn quoted(text: &str) -> Option<String> {
197    let trimmed = text.trim_start();
198    let quote = trimmed.chars().next()?;
199    if quote != '\'' && quote != '"' {
200        return None;
201    }
202    let body = &trimmed[quote.len_utf8()..];
203    let end = body.find(quote)?;
204    Some(body[..end].to_string())
205}
206
207pub fn member_at(line: &str, column: usize, text: &str) -> Option<(String, String)> {
208    let chars: Vec<char> = line.chars().collect();
209    let column = column.min(chars.len().saturating_sub(1));
210
211    let mut start = column;
212    while start > 0 && is_name(chars[start - 1]) {
213        start -= 1;
214    }
215    let mut end = column;
216    while end < chars.len() && is_name(chars[end]) {
217        end += 1;
218    }
219    if start == end || start == 0 || chars[start - 1] != '.' {
220        return None;
221    }
222
223    let mut receiver_start = start - 1;
224    while receiver_start > 0 && is_name(chars[receiver_start - 1]) {
225        receiver_start -= 1;
226    }
227    let receiver: String = chars[receiver_start..start - 1].iter().collect();
228    let class = bindings(text).get(&receiver)?.clone();
229    Some((class, chars[start..end].iter().collect()))
230}
231
232fn is_name(c: char) -> bool {
233    c.is_alphanumeric() || c == '_' || c == '$'
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    #[test]
241    fn the_embedded_index_parses_and_holds_the_platform() {
242        let api = api();
243        assert!(api.class("dw.system.Site").is_some());
244        assert!(api.class("dw.catalog.ProductMgr").is_some());
245        assert!(api.modules().count() > 300);
246    }
247
248    #[test]
249    fn resolves_a_module_path_the_way_require_spells_it() {
250        assert!(api().by_module("dw/web/URLUtils").is_some());
251        assert!(api().by_module("dw/nope/Nope").is_none());
252    }
253
254    #[test]
255    fn finds_a_method_with_its_signature() {
256        let (member, kind) = api()
257            .class("dw.system.Transaction")
258            .unwrap()
259            .member("wrap")
260            .unwrap();
261        assert_eq!(kind, MemberKind::Method);
262        assert!(member.shape.starts_with("static wrap("));
263        assert!(!member.description.is_empty());
264    }
265
266    #[test]
267    fn reads_the_require_bindings_of_a_document() {
268        let text = "var Site = require('dw/system/Site');\n\
269                    const URLUtils = require('dw/web/URLUtils');\n\
270                    var helper = require('*/cartridge/scripts/helper');\n";
271        let found = bindings(text);
272        assert_eq!(
273            found.get("Site").map(String::as_str),
274            Some("dw.system.Site")
275        );
276        assert_eq!(
277            found.get("URLUtils").map(String::as_str),
278            Some("dw.web.URLUtils")
279        );
280        assert!(!found.contains_key("helper"));
281    }
282
283    #[test]
284    fn resolves_the_member_under_the_cursor() {
285        let text = "var Site = require('dw/system/Site');\nvar id = Site.getCurrent();";
286        let line = "var id = Site.getCurrent();";
287        let column = line.find("getCurrent").unwrap() + 2;
288        assert_eq!(
289            member_at(line, column, text),
290            Some(("dw.system.Site".into(), "getCurrent".into()))
291        );
292    }
293
294    #[test]
295    fn counts_a_class_as_in_hand_wherever_it_is_required() {
296        let text = "'use strict';
297                    var Site = require('dw/system/Site');
298                    server.get('Show', function () {
299                            var OrderMgr = require('dw/order/OrderMgr');
300                    });
301";
302        let found = imports(text);
303        assert_eq!(found.name_of("dw.system.Site"), Some("Site"));
304        // Lazy, inside a handler — still no reason to declare it twice.
305        assert_eq!(found.name_of("dw.order.OrderMgr"), Some("OrderMgr"));
306        assert_eq!(found.name_of("dw.system.Transaction"), None);
307    }
308
309    #[test]
310    fn keeps_the_declaration_keyword_the_file_already_uses() {
311        let text = "const Logger = require('dw/system/Logger');\n";
312        assert_eq!(imports(text).keyword, "const");
313    }
314
315    #[test]
316    fn ignores_a_receiver_that_is_not_an_api_class() {
317        let text = "var product = getProduct();";
318        let line = "var x = product.custom;";
319        let column = line.find("custom").unwrap() + 1;
320        assert_eq!(member_at(line, column, text), None);
321    }
322}