Skip to main content

css_variable_lsp/
completion_context.rs

1use 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(&quote) {
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}