1use 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
59pub 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}