css_variable_lsp/parsers/
js.rs1use ls_types::Uri;
2
3use super::css::{parse_css_snippet, CssParseContext};
4use crate::manager::CssVariableManager;
5
6pub(crate) struct JsCssSnippet {
8 pub content_start: usize,
10 pub content: String,
12}
13
14pub async fn parse_js_document(
16 text: &str,
17 uri: &Uri,
18 manager: &CssVariableManager,
19) -> Result<(), String> {
20 let snippets = extract_js_css_snippets(text);
21 let mut parse_errors = 0;
22 for snippet in snippets {
23 let context = CssParseContext {
24 css_text: &snippet.content,
25 full_text: text,
26 uri,
27 manager,
28 base_offset: snippet.content_start,
29 inline: false,
30 usage_context_override: Some("js-template"),
31 dom_node: None,
32 };
33 if let Err(e) = parse_css_snippet(context).await {
34 tracing::debug!("JS parse error at offset {}: {}", snippet.content_start, e);
35 parse_errors += 1;
36 }
37 }
38 if parse_errors > 0 {
39 tracing::warn!(
40 "Encountered {} parse errors in JS document {:?}",
41 parse_errors,
42 uri
43 );
44 }
45 Ok(())
46}
47
48fn has_css_like_content(s: &str) -> bool {
51 if s.contains("var(") || s.contains("--") {
57 return true;
58 }
59
60 if let Some(pos) = s.find(':') {
62 let after = &s[pos + 1..].trim_start();
64 if s.starts_with("http") || s.starts_with("//") {
66 return false;
67 }
68 if !after.starts_with(' ') && !after.starts_with(';') && !after.is_empty() {
72 if s.contains("://") || s.starts_with('/') {
74 return false;
75 }
76 }
77 }
78
79 s.contains(':') || s.contains("--") || s.contains("var(")
81}
82
83fn append_blank_bytes(content: &mut String, byte_count: usize) {
84 content.extend(std::iter::repeat_n(' ', byte_count));
85}
86
87fn escaped_sequence_byte_len(text: &str, slash_offset: usize) -> usize {
88 let bytes = text.as_bytes();
89 if slash_offset + 1 >= bytes.len() {
90 return 1;
91 }
92
93 if bytes[slash_offset + 1] == b'\r'
94 && slash_offset + 2 < bytes.len()
95 && bytes[slash_offset + 2] == b'\n'
96 {
97 return 3;
98 }
99
100 1 + text[slash_offset + 1..]
101 .chars()
102 .next()
103 .map(char::len_utf8)
104 .unwrap_or(0)
105}
106
107pub(crate) fn extract_js_css_snippets(text: &str) -> Vec<JsCssSnippet> {
109 let bytes = text.as_bytes();
110 let mut snippets = Vec::new();
111 let mut i = 0;
112
113 while i < bytes.len() {
114 let b = bytes[i];
115
116 match b {
117 b'\'' | b'"' => {
118 let quote = b;
120 let content_start = i + 1;
121 i += 1;
122 while i < bytes.len() {
123 if bytes[i] == b'\\' {
124 i += 2;
125 continue;
126 }
127 if bytes[i] == quote {
128 let content = &text[content_start..i];
129 if has_css_like_content(content) {
130 snippets.push(JsCssSnippet {
131 content_start,
132 content: content.to_string(),
133 });
134 }
135 i += 1;
136 break;
137 }
138 i += 1;
139 }
140 }
141 b'`' => {
142 let content_start = i + 1;
144 let mut content = String::with_capacity(64);
145 let mut expr_depth: i32 = 0;
146 let mut expr_quote: Option<u8> = None;
148 i += 1;
149
150 while i < bytes.len() {
151 if expr_depth > 0 {
152 if let Some(q) = expr_quote {
154 if bytes[i] == b'\\' {
155 let consumed = escaped_sequence_byte_len(text, i);
156 append_blank_bytes(&mut content, consumed);
157 i += consumed;
158 continue;
159 }
160 append_blank_bytes(&mut content, 1);
161 if bytes[i] == q {
162 expr_quote = None;
163 }
164 i += 1;
165 continue;
166 }
167 match bytes[i] {
168 b'\'' | b'"' | b'`' => {
169 expr_quote = Some(bytes[i]);
170 append_blank_bytes(&mut content, 1);
171 i += 1;
172 continue;
173 }
174 b'{' => {
175 expr_depth += 1;
176 append_blank_bytes(&mut content, 1);
177 i += 1;
178 continue;
179 }
180 b'}' => {
181 expr_depth -= 1;
182 append_blank_bytes(&mut content, 1);
183 i += 1;
184 continue;
185 }
186 _ => {
187 append_blank_bytes(&mut content, 1);
188 i += 1;
189 continue;
190 }
191 }
192 }
193
194 if bytes[i] == b'\\' {
196 let consumed = escaped_sequence_byte_len(text, i);
197 append_blank_bytes(&mut content, consumed);
198 i += consumed;
199 continue;
200 }
201 if bytes[i] == b'`' {
202 if has_css_like_content(&content) {
204 snippets.push(JsCssSnippet {
205 content_start,
206 content,
207 });
208 }
209 i += 1;
210 break;
211 }
212 if bytes[i] == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'{' {
213 expr_depth = 1;
214 content.push(' ');
216 content.push(' ');
217 i += 2;
218 continue;
219 }
220 if let Some(c) = text[i..].chars().next() {
222 content.push(c);
223 i += c.len_utf8();
224 } else {
225 i += 1;
226 }
227 }
228 }
229 b'/' => {
230 if i + 1 < bytes.len() {
232 if bytes[i + 1] == b'/' {
233 i += 2;
234 while i < bytes.len() && bytes[i] != b'\n' {
235 i += 1;
236 }
237 continue;
238 }
239 if bytes[i + 1] == b'*' {
240 i += 2;
241 while i + 1 < bytes.len() {
242 if bytes[i] == b'*' && bytes[i + 1] == b'/' {
243 i += 2;
244 break;
245 }
246 i += 1;
247 }
248 continue;
249 }
250 }
251 i += 1;
252 }
253 _ => {
254 i += 1;
255 }
256 }
257 }
258
259 snippets
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 use crate::manager::CssVariableManager;
267 use crate::types::{offset_to_position, Config};
268 use std::str::FromStr;
269
270 #[test]
271 fn test_extract_simple_template_literal() {
272 let text = "const css = `color: red;`";
273 let snippets = extract_js_css_snippets(text);
274 assert_eq!(snippets.len(), 1);
275 assert_eq!(snippets[0].content, "color: red;");
276 }
277
278 #[test]
279 fn test_extract_template_with_expressions() {
280 let text = "const Btn = styled.button`\n color: ${props => props.$color};\n background: #3b82f6;\n`";
281 let snippets = extract_js_css_snippets(text);
282 assert_eq!(snippets.len(), 1);
283 let c = &snippets[0].content;
284 assert!(c.contains("background: #3b82f6"));
286 assert!(c.contains("color:"));
287 assert!(!c.contains("props"));
289 }
290
291 async fn assert_variable_name_position(text: &str, name: &str) {
292 let manager = CssVariableManager::new(Config::default());
293 let uri = Uri::from_str("file:///test.ts").unwrap();
294 parse_js_document(text, &uri, &manager).await.unwrap();
295 let variables = manager.get_variables(name).await;
296 assert_eq!(variables.len(), 1);
297 let expected_offset = text.find(name).unwrap();
298 assert_eq!(variables[0].source_position, expected_offset);
299 assert_eq!(
300 variables[0].name_range.unwrap().start,
301 offset_to_position(text, expected_offset),
302 );
303 }
304
305 #[tokio::test]
306 async fn test_template_expression_preserves_following_range() {
307 let text = r#"const css = `color: ${"red"}; --after: blue;`;"#;
308 assert_variable_name_position(text, "--after").await;
309 }
310
311 #[tokio::test]
312 async fn test_escaped_template_expression_preserves_following_range() {
313 let text = r#"const css = `color: ${"re\"d"}; --after: blue;`;"#;
314 assert_variable_name_position(text, "--after").await;
315 }
316
317 #[tokio::test]
318 async fn test_template_escape_preserves_following_range() {
319 let text = r#"const css = `content: \`; --after: blue;`;"#;
320 assert_variable_name_position(text, "--after").await;
321 }
322
323 #[tokio::test]
324 async fn test_multibyte_template_expression_preserves_following_range() {
325 let text = r#"const css = `color: ${"赤色"}; --after: blue;`;"#;
326 assert_variable_name_position(text, "--after").await;
327 }
328
329 #[test]
330 fn test_extract_multiple_templates() {
331 let text = r#"
332 const a = styled.div`color: red;`;
333 const b = styled.span`background: blue;`;
334 "#;
335 let snippets = extract_js_css_snippets(text);
336 assert_eq!(snippets.len(), 2);
337 }
338
339 #[test]
340 fn test_extract_string_literal() {
341 let text = r#"const css = "color: #fff;""#;
342 let snippets = extract_js_css_snippets(text);
343 assert_eq!(snippets.len(), 1);
344 assert_eq!(snippets[0].content, "color: #fff;");
345 }
346
347 #[test]
348 fn test_skip_non_css_strings() {
349 let text = r#"const msg = "hello world";"#;
350 let snippets = extract_js_css_snippets(text);
351 assert_eq!(snippets.len(), 0);
352 }
353
354 #[test]
355 fn test_skip_comments() {
356 let text = "// this is a `comment` with a backtick\nconst css = `color: red;`";
357 let snippets = extract_js_css_snippets(text);
358 assert_eq!(snippets.len(), 1);
359 assert_eq!(snippets[0].content, "color: red;");
360 }
361
362 #[test]
363 fn test_template_nested_braces_in_expression() {
364 let text = "const css = `color: ${({theme}) => theme.primary};`";
365 let snippets = extract_js_css_snippets(text);
366 assert_eq!(snippets.len(), 1);
367 assert!(snippets[0].content.contains("color:"));
369 }
370}