Skip to main content

isml_lsp/
custom.rs

1//! `x.custom.y` and preference reads, with the type of `x` guessed from its name.
2//! Conservative: an unknown receiver yields nothing, an ambiguous one yields every candidate type.
3
4use crate::metadata::SITE_PREFERENCES;
5
6pub const PREFERENCE_CALL: &str = "getCustomPreferenceValue";
7
8const NOT_ATTRIBUTES: [&str; 7] = [
9    "hasOwnProperty",
10    "isPrototypeOf",
11    "propertyIsEnumerable",
12    "toString",
13    "toLocaleString",
14    "valueOf",
15    "constructor",
16];
17
18/// Variable-name suffixes, longest first, and the types they may stand for.
19/// Several means the name does not decide: `paymentInstrument` is an order's or a customer's.
20const SUBJECTS: &[(&str, &[&str])] = &[
21    ("bonusdiscountlineitem", &["BonusDiscountLineItem"]),
22    ("giftcertificatelineitem", &["GiftCertificateLineItem"]),
23    ("slotconfiguration", &["SlotConfiguration"]),
24    ("shippinglineitem", &["ShippingLineItem"]),
25    ("productlineitem", &["ProductLineItem"]),
26    ("couponlineitem", &["CouponLineItem"]),
27    (
28        "paymentinstrument",
29        &["OrderPaymentInstrument", "CustomerPaymentInstrument"],
30    ),
31    ("paymenttransaction", &["PaymentTransaction"]),
32    // Only a qualified address name is mapped: a custom object is very often
33    // called `..._address`, and its attributes are its own.
34    ("customeraddress", &["CustomerAddress"]),
35    ("shippingaddress", &["OrderAddress"]),
36    ("billingaddress", &["OrderAddress"]),
37    ("giftcertificate", &["GiftCertificate"]),
38    ("sourcecodegroup", &["SourceCodeGroup"]),
39    ("variationgroup", &["Product"]),
40    ("customergroup", &["CustomerGroup"]),
41    ("shippingmethod", &["ShippingMethod"]),
42    ("shippingorder", &["ShippingOrder"]),
43    ("slotcontent", &["SlotConfiguration"]),
44    ("appeasement", &["Appeasement"]),
45    ("lineitem", &["ProductLineItem"]),
46    ("promotion", &["Promotion"]),
47    ("campaign", &["Campaign"]),
48    ("shipment", &["Shipment"]),
49    ("customer", &["Customer"]),
50    ("category", &["Category"]),
51    ("product", &["Product"]),
52    ("content", &["Content"]),
53    ("invoice", &["Invoice"]),
54    ("profile", &["Profile"]),
55    ("session", &["Session"]),
56    ("basket", &["Basket"]),
57    ("coupon", &["Coupon"]),
58    ("folder", &["Folder"]),
59    ("return", &["Return"]),
60    ("order", &["Order"]),
61    ("store", &["Store"]),
62    ("variant", &["Product"]),
63    ("master", &["Product"]),
64    ("cart", &["Basket"]),
65];
66
67#[derive(Debug, PartialEq, Eq)]
68pub struct Access {
69    pub types: &'static [&'static str],
70    pub attribute: String,
71    /// Character offset of the attribute name.
72    pub start: usize,
73}
74
75#[derive(Debug, PartialEq, Eq)]
76pub struct Pending {
77    pub types: &'static [&'static str],
78    /// Characters of the attribute name already typed.
79    pub typed: usize,
80}
81
82pub fn types_of(receiver: &str) -> Option<&'static [&'static str]> {
83    let lowered = receiver.to_ascii_lowercase();
84    SUBJECTS
85        .iter()
86        .find(|(suffix, _)| lowered.ends_with(suffix))
87        .map(|(_, types)| *types)
88}
89
90pub fn accesses(line: &str) -> Vec<Access> {
91    let chars: Vec<char> = line.chars().collect();
92    let mut found = Vec::new();
93    let mut index = 0;
94    while let Some(marker) = find_from(&chars, ".custom.", index) {
95        let start = marker + ".custom.".len();
96        let end = identifier_end(&chars, start);
97        index = start.max(marker + 1);
98        if end == start {
99            continue;
100        }
101        let attribute: String = chars[start..end].iter().collect();
102        index = end;
103        let Some(types) = types_of(&identifier_before(&chars, marker)) else {
104            continue;
105        };
106        if is_not_an_attribute(&attribute) {
107            continue;
108        }
109        found.push(Access {
110            types,
111            attribute,
112            start,
113        });
114    }
115    found
116}
117
118pub fn pending(head: &str) -> Option<Pending> {
119    let chars: Vec<char> = head.chars().collect();
120    let typed = trailing_identifier_len(&chars);
121    let marker = chars.len() - typed;
122    let prefix: String = chars[..marker].iter().collect();
123    let receiver_end = marker.checked_sub(".custom.".len())?;
124    if !prefix.ends_with(".custom.") {
125        return None;
126    }
127    let types = types_of(&identifier_before(&chars, receiver_end))?;
128    Some(Pending { types, typed })
129}
130
131pub fn preferences(line: &str) -> Vec<Access> {
132    let chars: Vec<char> = line.chars().collect();
133    let mut found = Vec::new();
134    let mut index = 0;
135    while let Some(call) = find_from(&chars, PREFERENCE_CALL, index) {
136        index = call + PREFERENCE_CALL.len();
137        let Some(literal) = whole_argument(&chars, index) else {
138            continue;
139        };
140        index = literal.start;
141        if is_not_an_attribute(&literal.text) {
142            continue;
143        }
144        found.push(Access {
145            types: SITE_PREFERENCE_TYPES,
146            attribute: literal.text,
147            start: literal.start,
148        });
149    }
150    found
151}
152
153const SITE_PREFERENCE_TYPES: &[&str] = &[SITE_PREFERENCES];
154
155pub fn is_pending_preference(head: &str) -> bool {
156    let Some(quote) = head.rfind(['\'', '"']) else {
157        return false;
158    };
159    let before = head[..quote].trim_end();
160    before
161        .strip_suffix('(')
162        .is_some_and(|call| call.trim_end().ends_with(PREFERENCE_CALL))
163}
164
165/// Present at runtime without metadata: plain JS object members and platform `__` injections.
166fn is_not_an_attribute(name: &str) -> bool {
167    name.starts_with("__") || NOT_ATTRIBUTES.contains(&name)
168}
169
170struct Literal {
171    text: String,
172    start: usize,
173}
174
175/// The quoted literal that is the *whole* argument at `from`; a concatenated one names no single preference.
176fn whole_argument(chars: &[char], from: usize) -> Option<Literal> {
177    let mut index = from;
178    while index < chars.len() && (chars[index].is_whitespace() || chars[index] == '(') {
179        index += 1;
180    }
181    let quote = *chars.get(index)?;
182    if quote != '\'' && quote != '"' {
183        return None;
184    }
185    let start = index + 1;
186    let mut end = start;
187    while end < chars.len() && chars[end] != quote {
188        end += 1;
189    }
190    if end >= chars.len() {
191        return None;
192    }
193    let mut after = end + 1;
194    while after < chars.len() && chars[after].is_whitespace() {
195        after += 1;
196    }
197    if chars.get(after).is_some_and(|c| *c == '+') {
198        return None;
199    }
200    Some(Literal {
201        text: chars[start..end].iter().collect(),
202        start,
203    })
204}
205
206fn find_from(chars: &[char], needle: &str, from: usize) -> Option<usize> {
207    let needle: Vec<char> = needle.chars().collect();
208    if needle.is_empty() || chars.len() < needle.len() {
209        return None;
210    }
211    (from..=chars.len() - needle.len())
212        .find(|index| chars[*index..index + needle.len()] == needle[..])
213}
214
215fn is_identifier(c: char) -> bool {
216    c.is_alphanumeric() || c == '_' || c == '$'
217}
218
219fn identifier_end(chars: &[char], from: usize) -> usize {
220    let mut end = from;
221    while end < chars.len() && is_identifier(chars[end]) {
222        end += 1;
223    }
224    end
225}
226
227/// The identifier ending at `end`, ignoring a trailing `()` call.
228fn identifier_before(chars: &[char], end: usize) -> String {
229    let mut cursor = end;
230    if cursor >= 2 && chars[cursor - 1] == ')' && chars[cursor - 2] == '(' {
231        cursor -= 2;
232    }
233    let mut start = cursor;
234    while start > 0 && is_identifier(chars[start - 1]) {
235        start -= 1;
236    }
237    chars[start..cursor].iter().collect()
238}
239
240fn trailing_identifier_len(chars: &[char]) -> usize {
241    let mut length = 0;
242    while length < chars.len() && is_identifier(chars[chars.len() - 1 - length]) {
243        length += 1;
244    }
245    length
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn maps_a_variable_name_to_an_object_type() {
254        assert_eq!(types_of("product"), Some(&["Product"][..]));
255        assert_eq!(types_of("apiProduct"), Some(&["Product"][..]));
256        assert_eq!(types_of("currentBasket"), Some(&["Basket"][..]));
257        assert_eq!(types_of("productLineItem"), Some(&["ProductLineItem"][..]));
258        assert_eq!(types_of("result"), None);
259    }
260
261    #[test]
262    fn keeps_both_types_when_the_name_does_not_decide() {
263        assert_eq!(
264            types_of("paymentInstrument"),
265            Some(&["OrderPaymentInstrument", "CustomerPaymentInstrument"][..])
266        );
267    }
268
269    #[test]
270    fn reads_slot_content_as_a_slot_configuration() {
271        assert_eq!(types_of("slotcontent"), Some(&["SlotConfiguration"][..]));
272    }
273
274    #[test]
275    fn finds_an_access_and_where_the_attribute_starts() {
276        let line = "if (apiProduct.custom.season) {";
277        let found = accesses(line);
278        assert_eq!(found.len(), 1);
279        assert_eq!(found[0].types, ["Product"]);
280        assert_eq!(found[0].attribute, "season");
281        assert_eq!(found[0].start, line.find("season").unwrap());
282    }
283
284    #[test]
285    fn ignores_a_receiver_it_cannot_place() {
286        assert!(accesses("thing.custom.whatever").is_empty());
287    }
288
289    #[test]
290    fn ignores_the_members_every_javascript_object_has() {
291        assert!(accesses("product.custom.hasOwnProperty('x')").is_empty());
292        assert!(accesses("slotcontent.custom.__SlotName").is_empty());
293    }
294
295    #[test]
296    fn finds_several_accesses_in_one_line() {
297        let found = accesses("order.custom.a + basket.custom.b");
298        assert_eq!(found.len(), 2);
299        assert_eq!(found[0].types, ["Order"]);
300        assert_eq!(found[1].types, ["Basket"]);
301    }
302
303    #[test]
304    fn recognises_an_access_being_typed() {
305        assert_eq!(
306            pending("    if (product.custom.gue"),
307            Some(Pending {
308                types: &["Product"],
309                typed: 3
310            })
311        );
312        assert_eq!(
313            pending("    if (order.custom."),
314            Some(Pending {
315                types: &["Order"],
316                typed: 0
317            })
318        );
319        assert_eq!(pending("    if (product.cus"), None);
320    }
321
322    #[test]
323    fn reads_a_site_preference_through_its_getter() {
324        let found = preferences("Site.getCurrent().getCustomPreferenceValue('newsletterEnabled')");
325        assert_eq!(found.len(), 1);
326        assert_eq!(found[0].types, [SITE_PREFERENCES]);
327        assert_eq!(found[0].attribute, "newsletterEnabled");
328    }
329
330    #[test]
331    fn ignores_a_preference_name_that_is_built_by_concatenation() {
332        assert!(preferences("getCustomPreferenceValue('logo_' + locale)").is_empty());
333        assert!(preferences("getCustomPreferenceValue(prefix + 'logo')").is_empty());
334    }
335
336    #[test]
337    fn recognises_a_preference_being_typed() {
338        assert!(is_pending_preference(
339            "Site.getCurrent().getCustomPreferenceValue('loyal"
340        ));
341        assert!(!is_pending_preference("Resource.msg('label"));
342    }
343}