css_variable_lsp/
completion_context.rs1use std::collections::HashMap;
2
3use crate::document_kind::{normalize_extension, resolve_document_kind, DocumentKind};
4use crate::text_utils::{clamp_to_char_boundary, is_word_byte, is_word_char};
5use crate::types::position_to_offset;
6use ls_types::{Position, Uri};
7
8pub struct CompletionContextSlice<'a> {
9 pub slice: &'a str,
10 pub allow_without_braces: bool,
11}
12
13pub struct ValueContext {
14 pub is_value_context: bool,
15 pub property_name: Option<String>,
16}
17
18pub fn completion_value_context_slice<'a>(
19 text: &'a str,
20 position: Position,
21 language_id: Option<&str>,
22 uri: &Uri,
23 lookup_extension_map: &HashMap<String, DocumentKind>,
24) -> Option<CompletionContextSlice<'a>> {
25 let offset = position_to_offset(text, position)?;
26 let start = clamp_to_char_boundary(text, offset.saturating_sub(400));
27 let offset = clamp_to_char_boundary(text, offset);
28 let before_cursor = &text[start..offset];
29
30 if is_js_like_document(uri.path().as_str(), language_id) {
31 let slice = find_js_string_segment(before_cursor)?;
32 return Some(CompletionContextSlice {
33 slice,
34 allow_without_braces: true,
35 });
36 }
37
38 match resolve_document_kind(uri.path().as_str(), language_id, lookup_extension_map) {
39 Some(DocumentKind::Html) => find_html_style_context_slice(before_cursor),
40 Some(DocumentKind::Css) => Some(CompletionContextSlice {
41 slice: before_cursor,
42 allow_without_braces: false,
43 }),
44 None => None,
45 }
46}
47
48pub fn is_js_like_language_id(language_id: &str) -> bool {
49 matches!(
50 language_id.to_lowercase().as_str(),
51 "javascript"
52 | "javascriptreact"
53 | "typescript"
54 | "typescriptreact"
55 | "js"
56 | "jsx"
57 | "ts"
58 | "tsx"
59 )
60}
61
62pub fn is_js_like_extension(ext: &str) -> bool {
63 matches!(
64 ext,
65 ".js" | ".jsx" | ".ts" | ".tsx" | ".mjs" | ".cjs" | ".mts" | ".cts"
66 )
67}
68
69pub fn is_js_like_document(path: &str, language_id: Option<&str>) -> bool {
70 if let Some(language_id) = language_id {
71 if is_js_like_language_id(language_id) {
72 return true;
73 }
74 }
75
76 let ext = std::path::Path::new(path)
77 .extension()
78 .and_then(|ext| ext.to_str())
79 .and_then(normalize_extension);
80 ext.as_deref().map(is_js_like_extension).unwrap_or(false)
81}
82
83pub fn find_html_style_attribute_slice(before_cursor: &str) -> Option<&str> {
84 let lower = before_cursor.to_ascii_lowercase();
85 let bytes = lower.as_bytes();
86 let mut search_end = lower.len();
87
88 while let Some(idx) = lower[..search_end].rfind("style") {
89 if idx > 0 && is_word_byte(bytes[idx - 1]) {
90 search_end = idx;
91 continue;
92 }
93 let after_idx = idx + 5;
94 if after_idx < bytes.len() && is_word_byte(bytes[after_idx]) {
95 search_end = idx;
96 continue;
97 }
98
99 let mut j = after_idx;
100 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
101 j += 1;
102 }
103 if j >= bytes.len() || bytes[j] != b'=' {
104 search_end = idx;
105 continue;
106 }
107 j += 1;
108 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
109 j += 1;
110 }
111 if j >= bytes.len() {
112 return None;
113 }
114
115 let quote = bytes[j];
116 if quote != b'"' && quote != b'\'' {
117 search_end = idx;
118 continue;
119 }
120 let value_start = j + 1;
121 let rest = &bytes[value_start..];
122 if !rest.contains("e) {
123 return Some(&before_cursor[value_start..]);
124 }
125
126 search_end = idx;
127 }
128
129 None
130}
131
132pub fn find_html_style_block_slice(before_cursor: &str) -> Option<&str> {
133 let lower = before_cursor.to_ascii_lowercase();
134 let open_idx = lower.rfind("<style")?;
135 if let Some(close_idx) = lower.rfind("</style") {
136 if close_idx > open_idx {
137 return None;
138 }
139 }
140
141 let tag_end_rel = lower[open_idx..].find('>')?;
142 let tag_end = open_idx + tag_end_rel;
143 if tag_end + 1 > before_cursor.len() {
144 return None;
145 }
146
147 Some(&before_cursor[tag_end + 1..])
148}
149
150pub fn find_html_style_context_slice(before_cursor: &str) -> Option<CompletionContextSlice<'_>> {
151 if let Some(slice) = find_html_style_attribute_slice(before_cursor) {
152 return Some(CompletionContextSlice {
153 slice,
154 allow_without_braces: true,
155 });
156 }
157 if let Some(slice) = find_html_style_block_slice(before_cursor) {
158 return Some(CompletionContextSlice {
159 slice,
160 allow_without_braces: false,
161 });
162 }
163 None
164}
165
166pub fn find_js_string_segment(before_cursor: &str) -> Option<&str> {
167 let bytes = before_cursor.as_bytes();
168 let mut in_quote: Option<u8> = None;
169 let mut in_template = false;
170 let mut template_expr_depth: i32 = 0;
171 let mut expr_quote: Option<u8> = None;
172 let mut segment_start: Option<usize> = None;
173
174 let mut i = 0;
175 while i < bytes.len() {
176 let b = bytes[i];
177 if let Some(q) = in_quote {
178 if b == b'\\' {
179 i = i.saturating_add(2);
180 continue;
181 }
182 if b == q {
183 in_quote = None;
184 segment_start = None;
185 }
186 i += 1;
187 continue;
188 }
189
190 if in_template {
191 if template_expr_depth > 0 {
192 if let Some(q) = expr_quote {
193 if b == b'\\' {
194 i = i.saturating_add(2);
195 continue;
196 }
197 if b == q {
198 expr_quote = None;
199 }
200 i += 1;
201 continue;
202 }
203
204 if b == b'\'' || b == b'"' || b == b'`' {
205 expr_quote = Some(b);
206 i += 1;
207 continue;
208 }
209 if b == b'{' {
210 template_expr_depth += 1;
211 } else if b == b'}' {
212 template_expr_depth -= 1;
213 if template_expr_depth == 0 {
214 segment_start = Some(i + 1);
215 }
216 }
217 i += 1;
218 continue;
219 }
220
221 if b == b'\\' {
222 i = i.saturating_add(2);
223 continue;
224 }
225 if b == b'`' {
226 in_template = false;
227 segment_start = None;
228 i += 1;
229 continue;
230 }
231 if b == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'{' {
232 template_expr_depth = 1;
233 segment_start = None;
234 i += 2;
235 continue;
236 }
237 i += 1;
238 continue;
239 }
240
241 if b == b'\'' || b == b'"' {
242 in_quote = Some(b);
243 segment_start = Some(i + 1);
244 i += 1;
245 continue;
246 }
247 if b == b'`' {
248 in_template = true;
249 segment_start = Some(i + 1);
250 i += 1;
251 continue;
252 }
253 i += 1;
254 }
255
256 if in_quote.is_some() {
257 return segment_start.map(|start| &before_cursor[start..]);
258 }
259 if in_template && template_expr_depth == 0 {
260 return segment_start.map(|start| &before_cursor[start..]);
261 }
262 None
263}
264
265pub fn find_context_colon(before_cursor: &str, allow_without_braces: bool) -> Option<usize> {
266 let mut in_braces = 0i32;
267 let mut in_parens = 0i32;
268 let mut last_colon: i32 = -1;
269 let mut last_semicolon: i32 = -1;
270 let mut last_brace: i32 = -1;
271
272 for (idx, ch) in before_cursor.char_indices().rev() {
273 match ch {
274 ')' => in_parens += 1,
275 '(' => {
276 in_parens -= 1;
277 if in_parens < 0 {
278 in_parens = 0;
279 }
280 }
281 '}' => in_braces += 1,
282 '{' => {
283 in_braces -= 1;
284 if in_braces < 0 {
285 last_brace = idx as i32;
286 break;
287 }
288 }
289 ':' if in_parens == 0 && in_braces == 0 && last_colon == -1 => {
290 last_colon = idx as i32;
291 }
292 ';' if in_parens == 0 && in_braces == 0 && last_semicolon == -1 => {
293 last_semicolon = idx as i32;
294 }
295 _ => {}
296 }
297 }
298
299 if !allow_without_braces && last_brace == -1 {
300 return None;
301 }
302
303 if last_colon > last_semicolon && last_colon > last_brace {
304 Some(last_colon as usize)
305 } else {
306 None
307 }
308}
309
310pub fn get_value_context_info(before_cursor: &str, allow_without_braces: bool) -> ValueContext {
311 let colon_pos = match find_context_colon(before_cursor, allow_without_braces) {
312 Some(pos) => pos,
313 None => {
314 return ValueContext {
315 is_value_context: false,
316 property_name: None,
317 }
318 }
319 };
320 let before_colon = before_cursor[..colon_pos].trim_end();
321 if before_colon.is_empty() {
322 return ValueContext {
323 is_value_context: true,
324 property_name: None,
325 };
326 }
327
328 let mut start = before_colon.len();
329 for (idx, ch) in before_colon.char_indices().rev() {
330 if is_word_char(ch) {
331 start = idx;
332 } else {
333 break;
334 }
335 }
336
337 if start >= before_colon.len() {
338 return ValueContext {
339 is_value_context: true,
340 property_name: None,
341 };
342 }
343
344 ValueContext {
345 is_value_context: true,
346 property_name: Some(before_colon[start..].to_lowercase()),
347 }
348}
349
350pub fn score_variable_relevance(var_name: &str, property_name: Option<&str>) -> i32 {
351 let property_name = match property_name {
352 Some(name) => name,
353 None => return -1,
354 };
355
356 let lower_var_name = var_name.to_lowercase();
357
358 let color_properties = [
359 "color",
360 "background-color",
361 "background",
362 "border-color",
363 "outline-color",
364 "text-decoration-color",
365 "fill",
366 "stroke",
367 ];
368 if color_properties.contains(&property_name) {
369 if lower_var_name.contains("color")
370 || lower_var_name.contains("bg")
371 || lower_var_name.contains("background")
372 || lower_var_name.contains("primary")
373 || lower_var_name.contains("secondary")
374 || lower_var_name.contains("accent")
375 || lower_var_name.contains("text")
376 || lower_var_name.contains("border")
377 || lower_var_name.contains("link")
378 {
379 return 10;
380 }
381 if lower_var_name.contains("spacing")
382 || lower_var_name.contains("margin")
383 || lower_var_name.contains("padding")
384 || lower_var_name.contains("size")
385 || lower_var_name.contains("width")
386 || lower_var_name.contains("height")
387 || lower_var_name.contains("font")
388 || lower_var_name.contains("weight")
389 || lower_var_name.contains("radius")
390 {
391 return 0;
392 }
393 return 5;
394 }
395
396 let spacing_properties = [
397 "margin",
398 "margin-top",
399 "margin-right",
400 "margin-bottom",
401 "margin-left",
402 "padding",
403 "padding-top",
404 "padding-right",
405 "padding-bottom",
406 "padding-left",
407 "gap",
408 "row-gap",
409 "column-gap",
410 ];
411 if spacing_properties.contains(&property_name) {
412 if lower_var_name.contains("spacing")
413 || lower_var_name.contains("margin")
414 || lower_var_name.contains("padding")
415 || lower_var_name.contains("gap")
416 {
417 return 10;
418 }
419 if lower_var_name.contains("color")
420 || lower_var_name.contains("bg")
421 || lower_var_name.contains("background")
422 {
423 return 0;
424 }
425 return 5;
426 }
427
428 let size_properties = [
429 "width",
430 "height",
431 "max-width",
432 "max-height",
433 "min-width",
434 "min-height",
435 "font-size",
436 ];
437 if size_properties.contains(&property_name) {
438 if lower_var_name.contains("width")
439 || lower_var_name.contains("height")
440 || lower_var_name.contains("size")
441 {
442 return 10;
443 }
444 if lower_var_name.contains("color")
445 || lower_var_name.contains("bg")
446 || lower_var_name.contains("background")
447 {
448 return 0;
449 }
450 return 5;
451 }
452
453 if property_name.contains("radius") {
454 if lower_var_name.contains("radius") || lower_var_name.contains("rounded") {
455 return 10;
456 }
457 if lower_var_name.contains("color")
458 || lower_var_name.contains("bg")
459 || lower_var_name.contains("background")
460 {
461 return 0;
462 }
463 return 5;
464 }
465
466 let font_properties = ["font-family", "font-weight", "font-style"];
467 if font_properties.contains(&property_name) {
468 if lower_var_name.contains("font") {
469 return 10;
470 }
471 if lower_var_name.contains("color") || lower_var_name.contains("spacing") {
472 return 0;
473 }
474 return 5;
475 }
476
477 -1
478}