1use lsp_types::{Diagnostic, DiagnosticSeverity, Position, Range};
4
5use crate::custom::{self, Access};
6use crate::metadata::{Metadata, SITE_PREFERENCES};
7
8const SOURCE: &str = "sfcc-metadata";
9
10pub fn diagnostics(text: &str, metadata: &Metadata) -> Vec<Diagnostic> {
11 if !metadata.is_loaded() {
13 return Vec::new();
14 }
15
16 let mut found = Vec::new();
17 for (number, line) in text.lines().enumerate() {
18 let accesses = custom::accesses(line)
19 .into_iter()
20 .chain(custom::preferences(line));
21 for access in accesses {
22 let known: Vec<&str> = access
25 .types
26 .iter()
27 .copied()
28 .filter(|type_id| metadata.knows(type_id))
29 .collect();
30 if known.is_empty() {
31 continue;
32 }
33 if known
34 .iter()
35 .any(|type_id| metadata.attribute(type_id, &access.attribute).is_some())
36 {
37 continue;
38 }
39 found.push(unknown(line, number as u32, &access));
40 }
41 }
42 found
43}
44
45fn unknown(line: &str, number: u32, access: &Access) -> Diagnostic {
46 let start = utf16_column(line, access.start);
47 let end = start + access.attribute.encode_utf16().count() as u32;
48 Diagnostic {
49 range: Range::new(Position::new(number, start), Position::new(number, end)),
50 severity: Some(DiagnosticSeverity::WARNING),
51 source: Some(SOURCE.to_string()),
52 message: message(access),
53 ..Default::default()
54 }
55}
56
57fn message(access: &Access) -> String {
58 if access.types == [SITE_PREFERENCES] {
59 return format!(
60 "No site preference `{}` is defined in the metadata.",
61 access.attribute
62 );
63 }
64 format!(
65 "`{}` is not a custom attribute of {} in the metadata.",
66 access.attribute,
67 access
68 .types
69 .iter()
70 .map(|type_id| format!("`{type_id}`"))
71 .collect::<Vec<_>>()
72 .join(" or ")
73 )
74}
75
76fn utf16_column(line: &str, character: usize) -> u32 {
77 line.chars()
78 .take(character)
79 .map(char::len_utf16)
80 .sum::<usize>() as u32
81}
82
83#[cfg(test)]
84mod tests {
85 use super::*;
86 use std::fs;
87
88 const SAMPLE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
89<metadata xmlns="http://www.demandware.com/xml/impex/metadata/2006-10-31">
90 <type-extension type-id="Product">
91 <custom-attribute-definitions>
92 <attribute-definition attribute-id="season"><type>string</type></attribute-definition>
93 </custom-attribute-definitions>
94 </type-extension>
95 <type-extension type-id="SitePreferences">
96 <custom-attribute-definitions>
97 <attribute-definition attribute-id="newsletterEnabled"><type>boolean</type></attribute-definition>
98 </custom-attribute-definitions>
99 </type-extension>
100</metadata>
101"#;
102
103 fn metadata(test: &str) -> Metadata {
105 let directory = std::env::temp_dir().join(format!("isml-lsp-diagnose-{test}"));
106 let _ = fs::create_dir_all(&directory);
107 fs::write(directory.join("system-objecttype-extensions.xml"), SAMPLE).unwrap();
108 Metadata::scan(&[directory])
109 }
110
111 #[test]
112 fn accepts_an_attribute_the_metadata_defines() {
113 let metadata = metadata("accepts");
114 assert!(diagnostics("var d = product.custom.season;", &metadata).is_empty());
115 }
116
117 #[test]
118 fn reports_a_typo_where_it_is() {
119 let metadata = metadata("typo");
120 let line = "var d = product.custom.seasson;";
121 let found = diagnostics(line, &metadata);
122 assert_eq!(found.len(), 1);
123 assert_eq!(
124 found[0].range.start.character as usize,
125 line.find("seasson").unwrap()
126 );
127 assert!(found[0].message.contains("Product"));
128 }
129
130 #[test]
131 fn reports_an_undefined_site_preference() {
132 let metadata = metadata("preference");
133 let found = diagnostics(
134 "Site.getCurrent().getCustomPreferenceValue('newsletterEnabledd')",
135 &metadata,
136 );
137 assert_eq!(found.len(), 1);
138 assert!(found[0].message.contains("site preference"));
139 }
140
141 #[test]
142 fn stays_quiet_on_a_type_the_metadata_never_extends() {
143 let metadata = metadata("unknown-type");
144 assert!(diagnostics("var x = coupon.custom.whatever;", &metadata).is_empty());
145 }
146
147 #[test]
148 fn stays_quiet_without_metadata() {
149 let empty = Metadata::scan(&[std::env::temp_dir().join("isml-lsp-absent")]);
150 assert!(diagnostics("var d = product.custom.nope;", &empty).is_empty());
151 }
152}