1use 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
18const 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 ("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 pub start: usize,
73}
74
75#[derive(Debug, PartialEq, Eq)]
76pub struct Pending {
77 pub types: &'static [&'static str],
78 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
165fn 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
175fn 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
227fn 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}