1use 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 #[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 #[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
37pub 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 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 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
100pub 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 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
134pub 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
165fn 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 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}