1use std::path::Path;
4
5use lsp_types::{Diagnostic, DiagnosticSeverity, Position, Range};
6
7use crate::workspace::Workspace;
8
9const SOURCE: &str = "sfcc";
10
11const KEY_ATTRIBUTES: [&str; 5] = [
12 "label",
13 "missing-error",
14 "range-error",
15 "value-error",
16 "parse-error",
17];
18
19pub fn diagnostics(file: &Path, text: &str, workspace: &Workspace) -> Vec<Diagnostic> {
20 if is_form(file) {
21 return form(text, workspace);
22 }
23 if file
24 .file_name()
25 .is_some_and(|name| name == "steptypes.json")
26 {
27 return step_types(file, text);
28 }
29 Vec::new()
30}
31
32fn is_form(file: &Path) -> bool {
33 let path = file.to_string_lossy().replace('\\', "/");
34 path.contains("/cartridge/forms/") && path.ends_with(".xml")
35}
36
37fn form(text: &str, workspace: &Workspace) -> Vec<Diagnostic> {
38 let keys = workspace.resource_keys();
39 if keys.is_empty() {
41 return Vec::new();
42 }
43
44 let mut found = Vec::new();
45 for (number, line) in text.lines().enumerate() {
46 for attribute in KEY_ATTRIBUTES {
47 let Some(value) = attribute_value(line, attribute) else {
48 continue;
49 };
50 if !looks_like_a_key(&value.text) || keys.contains(&value.text) {
51 continue;
52 }
53 found.push(warning(
54 number as u32,
55 value.start,
56 value.text.chars().count(),
57 format!(
58 "No bundle defines `{}`, so `{attribute}` renders as the key itself.",
59 value.text
60 ),
61 ));
62 }
63 }
64 found
65}
66
67fn looks_like_a_key(value: &str) -> bool {
69 value.contains('.')
70 && !value.contains(char::is_whitespace)
71 && value
72 .chars()
73 .all(|c| c.is_alphanumeric() || c == '.' || c == '_' || c == '-')
74}
75
76fn step_types(file: &Path, text: &str) -> Vec<Diagnostic> {
77 let Some(cartridges) = cartridges_dir(file) else {
78 return Vec::new();
79 };
80
81 let mut found = Vec::new();
82 let mut seen: Vec<String> = Vec::new();
83 for (number, line) in text.lines().enumerate() {
84 if let Some(value) = json_string(line, "module") {
85 if !resolves(cartridges, &value.text) {
86 found.push(warning(
87 number as u32,
88 value.start,
89 value.text.chars().count(),
90 format!(
91 "No file at `{}`; this step fails when the job runs.",
92 value.text
93 ),
94 ));
95 }
96 }
97 if let Some(value) = json_string(line, "@type-id") {
98 if seen.contains(&value.text) {
99 found.push(warning(
100 number as u32,
101 value.start,
102 value.text.chars().count(),
103 format!(
104 "`{}` is declared twice in this file; the second is ignored.",
105 value.text
106 ),
107 ));
108 }
109 seen.push(value.text);
110 }
111 }
112 found
113}
114
115fn cartridges_dir(file: &Path) -> Option<&Path> {
118 file.parent()?.parent()
119}
120
121fn resolves(cartridges: &Path, module: &str) -> bool {
123 let relative = module.replace('/', std::path::MAIN_SEPARATOR_STR);
124 let direct = cartridges.join(&relative);
125 if direct.is_file() {
126 return true;
127 }
128 ["js", "ds"]
129 .iter()
130 .any(|extension| cartridges.join(format!("{relative}.{extension}")).is_file())
131}
132
133struct Value {
134 text: String,
135 start: usize,
137}
138
139fn attribute_value(line: &str, attribute: &str) -> Option<Value> {
140 let marker = format!("{attribute}=\"");
141 let at = line.find(&marker)?;
142 let before = line[..at].chars().next_back();
144 if before.is_some_and(|c| c.is_alphanumeric() || c == '-') {
145 return None;
146 }
147 let start = at + marker.len();
148 let end = line[start..].find('"')? + start;
149 Some(Value {
150 text: line[start..end].to_string(),
151 start: line[..start].chars().count(),
152 })
153}
154
155fn json_string(line: &str, name: &str) -> Option<Value> {
157 let marker = format!("\"{name}\"");
158 let at = line.find(&marker)?;
159 let rest = &line[at + marker.len()..];
160 let colon = rest.find(':')?;
161 let quote = rest[colon..].find('"')? + colon + 1;
162 let end = rest[quote..].find('"')? + quote;
163 let start = at + marker.len() + quote;
164 Some(Value {
165 text: rest[quote..end].to_string(),
166 start: line[..start].chars().count(),
167 })
168}
169
170fn warning(line: u32, column: usize, length: usize, message: String) -> Diagnostic {
171 let start = Position::new(line, column as u32);
172 Diagnostic {
173 range: Range::new(start, Position::new(line, (column + length) as u32)),
174 severity: Some(DiagnosticSeverity::WARNING),
175 source: Some(SOURCE.to_string()),
176 message,
177 ..Default::default()
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 #[test]
186 fn reads_an_attribute_and_where_its_value_starts() {
187 let line = r#" label="label.input.firstname.profile""#;
188 let value = attribute_value(line, "label").unwrap();
189 assert_eq!(value.text, "label.input.firstname.profile");
190 assert_eq!(&line[value.start..value.start + 5], "label");
191 }
192
193 #[test]
194 fn does_not_mistake_missing_error_for_range_error() {
195 let line = r#" missing-error="error.required" "#;
196 assert!(attribute_value(line, "range-error").is_none());
197 assert!(attribute_value(line, "missing-error").is_some());
198 }
199
200 #[test]
201 fn tells_a_resource_key_from_a_literal_label() {
202 assert!(looks_like_a_key("error.message.required"));
203 assert!(!looks_like_a_key("01"));
204 assert!(!looks_like_a_key("Amex"));
205 assert!(!looks_like_a_key("Add to list"));
206 assert!(!looks_like_a_key("Alpes-Maritimes"));
207 }
208
209 #[test]
210 fn reads_a_json_string_field() {
211 let line = r#" "module": "app_brand/cartridge/scripts/jobs/x.js","#;
212 let value = json_string(line, "module").unwrap();
213 assert_eq!(value.text, "app_brand/cartridge/scripts/jobs/x.js");
214 assert_eq!(value.start, line.find("app_brand").unwrap());
215 }
216
217 #[test]
218 fn accepts_a_module_path_written_without_its_extension() {
219 let cartridges = std::env::temp_dir().join("isml-lsp-steps");
220 let directory = cartridges.join("app/cartridge/scripts");
221 let _ = std::fs::create_dir_all(&directory);
222 std::fs::write(
223 directory.join("Step.js"),
224 "exports.execute = function () {};",
225 )
226 .unwrap();
227 assert!(resolves(&cartridges, "app/cartridge/scripts/Step"));
228 assert!(resolves(&cartridges, "app/cartridge/scripts/Step.js"));
229 assert!(!resolves(&cartridges, "app/cartridge/scripts/Missing"));
230 }
231
232 #[test]
233 fn recognises_which_files_it_has_an_opinion_about() {
234 assert!(is_form(Path::new(
235 r"C:\repo\source\cartridges\app\cartridge\forms\default\profile.xml"
236 )));
237 assert!(!is_form(Path::new(r"C:\repo\metadata\meta\system.xml")));
238 }
239}