Skip to main content

isml_lsp/
diagnose.rs

1//! Unknown custom attributes: SFCC returns `undefined` for a typo rather than failing.
2
3use 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    // Without metadata every attribute is unknown; say nothing rather than paint the file.
12    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            // A type the metadata never extends says nothing about the
23            // attribute; one that defines it settles the matter.
24            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    /// Tests run in parallel, so each gets its own directory.
104    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}