Skip to main content

isml_lsp/
live.rs

1//! The value under the cursor while debugging. Zed asks a debug adapter nothing on hover, so
2//! sfcc-dap answers on localhost.
3
4use std::io::{BufRead, BufReader, Write};
5use std::net::{Ipv4Addr, SocketAddr, TcpStream};
6use std::path::Path;
7use std::time::Duration;
8
9use serde_json::Value;
10use sfcc_core::state::is_within;
11use sfcc_core::state::sessions::{self, Session};
12
13const CONNECT_TIMEOUT: Duration = Duration::from_millis(200);
14const ANSWER_TIMEOUT: Duration = Duration::from_secs(4);
15const VALUE: usize = 80;
16
17const KEYWORDS: [&str; 24] = [
18    "var",
19    "let",
20    "const",
21    "function",
22    "return",
23    "if",
24    "else",
25    "for",
26    "while",
27    "do",
28    "new",
29    "typeof",
30    "instanceof",
31    "in",
32    "of",
33    "true",
34    "false",
35    "null",
36    "undefined",
37    "switch",
38    "case",
39    "break",
40    "continue",
41    "throw",
42];
43
44pub fn markdown(file: &Path, line: &str, column: usize) -> Option<String> {
45    let script = file
46        .extension()
47        .is_some_and(|extension| extension == "js" || extension == "ds");
48    if !script {
49        return None;
50    }
51    let expression = expression_at(line, column)?;
52    let answer = sessions::all()
53        .into_iter()
54        .filter(|session| is_within(file, Path::new(&session.cartridges)))
55        .find_map(|session| ask(&session, &expression))?;
56    render(&expression, &answer)
57}
58
59/// On `totalGrossPrice` in `order.totalGrossPrice.value`: `order.totalGrossPrice`.
60pub fn expression_at(line: &str, column: usize) -> Option<String> {
61    let chars: Vec<char> = line.chars().collect();
62    let part = |c: char| c.is_alphanumeric() || c == '_' || c == '$';
63    if !chars.get(column).is_some_and(|&c| part(c)) {
64        return None;
65    }
66    let mut end = column;
67    while chars.get(end).is_some_and(|&c| part(c)) {
68        end += 1;
69    }
70    let mut start = column;
71    loop {
72        while start > 0 && part(chars[start - 1]) {
73            start -= 1;
74        }
75        if start >= 2 && chars[start - 1] == '.' && part(chars[start - 2]) {
76            start -= 1;
77            continue;
78        }
79        if start >= 1 && chars[start - 1] == '.' {
80            return None;
81        }
82        break;
83    }
84    let expression: String = chars[start..end].iter().collect();
85    let first = expression.split('.').next()?;
86    let keyword = KEYWORDS.contains(&first) || expression.split('.').any(|part| part.is_empty());
87    let number = first.starts_with(|c: char| c.is_ascii_digit());
88    (!keyword && !number).then_some(expression)
89}
90
91fn ask(session: &Session, expression: &str) -> Option<Value> {
92    let address = SocketAddr::from((Ipv4Addr::LOCALHOST, session.port));
93    let mut stream = TcpStream::connect_timeout(&address, CONNECT_TIMEOUT).ok()?;
94    stream.set_read_timeout(Some(ANSWER_TIMEOUT)).ok()?;
95    let asked = serde_json::json!({ "token": session.token, "expression": expression });
96    writeln!(stream, "{asked}").ok()?;
97    let mut line = String::new();
98    BufReader::new(&stream).read_line(&mut line).ok()?;
99    serde_json::from_str(&line).ok()
100}
101
102pub fn render(expression: &str, answer: &Value) -> Option<String> {
103    let value = answer.get("value")?.as_str()?;
104    let mut out = format!("**{expression}**");
105    if let Some(kind) = answer.get("type").and_then(Value::as_str) {
106        out.push_str(&format!(" · `{kind}`"));
107    }
108    out.push_str(&format!("\n```js\n{value}\n```\n"));
109
110    let members = answer
111        .get("members")
112        .and_then(Value::as_array)
113        .filter(|members| !members.is_empty());
114    if let Some(members) = members {
115        out.push_str("\n| | | |\n| :-- | :-- | :-- |\n");
116        for member in members {
117            let text = |key: &str| member.get(key).and_then(Value::as_str).unwrap_or_default();
118            out.push_str(&format!(
119                "| {} | {} | {} |\n",
120                cell(text("name")),
121                cell(text("type")),
122                cell(&shorten(text("value"))),
123            ));
124        }
125    }
126    Some(out)
127}
128
129fn shorten(value: &str) -> String {
130    if value.chars().count() <= VALUE {
131        return value.to_string();
132    }
133    let cut: String = value.chars().take(VALUE - 1).collect();
134    format!("{cut}…")
135}
136
137fn cell(text: &str) -> String {
138    text.replace('|', "\\|").replace('\n', " ")
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    fn at(line: &str, word: &str) -> Option<String> {
146        expression_at(line, line.find(word).unwrap())
147    }
148
149    #[test]
150    fn reads_the_chain_up_to_the_word_under_the_cursor() {
151        let line = "    var points = order.totalGrossPrice.value * rate;";
152        assert_eq!(at(line, "order").as_deref(), Some("order"));
153        assert_eq!(at(line, "total").as_deref(), Some("order.totalGrossPrice"));
154        assert_eq!(
155            at(line, "value").as_deref(),
156            Some("order.totalGrossPrice.value")
157        );
158        assert_eq!(at(line, "rate").as_deref(), Some("rate"));
159    }
160
161    #[test]
162    fn leaves_calls_keywords_and_blanks_alone() {
163        assert_eq!(at("basket.getTotal().value", "value"), None);
164        assert_eq!(at("items[0].price", "price"), None);
165        assert_eq!(at("    var points = 1;", "var"), None);
166        assert_eq!(expression_at("a  b", 1), None);
167    }
168
169    #[test]
170    fn shows_the_value_and_one_level_of_members() {
171        let answer = serde_json::json!({
172            "value": "EUR 125.00",
173            "type": "dw.value.Money",
174            "members": [
175                { "name": "value", "type": "Number", "value": "125" },
176                { "name": "currencyCode", "type": "String", "value": "a|b" },
177            ],
178        });
179        let shown = render("order.totalGrossPrice", &answer).unwrap();
180        assert!(shown.starts_with("**order.totalGrossPrice** · `dw.value.Money`"));
181        assert!(shown.contains("EUR 125.00"));
182        assert!(shown.contains("| value | Number | 125 |"));
183        assert!(shown.contains("a\\|b"));
184    }
185
186    #[test]
187    fn shows_nothing_when_nothing_is_halted() {
188        assert_eq!(render("order", &serde_json::json!({})), None);
189        assert_eq!(
190            render("order", &serde_json::json!({ "error": "ReferenceError" })),
191            None
192        );
193    }
194}