Skip to main content

isml_lsp/
validate.rs

1//! Form definitions and `steptypes.json`: read only at run time, so typos otherwise surface in production.
2
3use 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    // Without a bundle every key is unknown; say nothing rather than paint the file.
40    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
67/// A `label` may be a literal (month, card brand); only a dotted, unspaced value is a key.
68fn 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
115/// `steptypes.json` sits at the cartridge root and its `module` paths start at
116/// the cartridge name, so they resolve against the directory holding them all.
117fn cartridges_dir(file: &Path) -> Option<&Path> {
118    file.parent()?.parent()
119}
120
121/// The extension is optional in a `module` path, as it is in a `require`.
122fn 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    /// Character offset of the value in the line.
136    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    // `range-error` must not match inside `missing-error`.
143    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
155/// `"name": "value"` on one line, with where the value starts.
156fn 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}