1use std::collections::HashMap;
2
3use crate::document_kind::{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 offset = clamp_to_char_boundary(text, offset);
27 let document_kind =
28 resolve_document_kind(uri.path().as_str(), language_id, lookup_extension_map)?;
29 let start = completion_lookback_start(text, offset, document_kind);
30 let before_cursor = &text[start..offset];
31
32 match document_kind {
33 DocumentKind::Js => {
34 let slice = find_js_string_segment(before_cursor)?;
35 Some(CompletionContextSlice {
36 slice,
37 allow_without_braces: true,
38 })
39 }
40 DocumentKind::Html => find_html_style_context_slice(before_cursor),
41 DocumentKind::Css => Some(CompletionContextSlice {
42 slice: before_cursor,
43 allow_without_braces: false,
44 }),
45 }
46}
47
48fn completion_lookback_start(text: &str, offset: usize, document_kind: DocumentKind) -> usize {
49 match document_kind {
50 DocumentKind::Css => find_containing_block_start(text, offset),
51 DocumentKind::Html => find_html_completion_lookback_start(text, offset),
52 DocumentKind::Js => clamp_to_char_boundary(text, offset.saturating_sub(400)),
53 }
54}
55
56fn find_containing_block_start(text: &str, offset: usize) -> usize {
58 let offset = clamp_to_char_boundary(text, offset.min(text.len()));
59 let mut brace_depth = 0i32;
60
61 for (idx, ch) in text[..offset].char_indices().rev() {
62 match ch {
63 '}' => brace_depth += 1,
64 '{' => {
65 if brace_depth == 0 {
66 return idx;
67 }
68 brace_depth -= 1;
69 }
70 _ => {}
71 }
72 }
73
74 clamp_to_char_boundary(text, offset.saturating_sub(400))
75}
76
77fn find_html_completion_lookback_start(text: &str, offset: usize) -> usize {
79 let offset = clamp_to_char_boundary(text, offset.min(text.len()));
80 let lower = text[..offset].to_ascii_lowercase();
81
82 if let Some(style_tag_idx) = lower.rfind("<style") {
83 let inside_open_tag = lower
84 .rfind("</style")
85 .map(|close_idx| close_idx < style_tag_idx)
86 .unwrap_or(true);
87 if inside_open_tag {
88 return style_tag_idx;
89 }
90 }
91
92 find_containing_block_start(text, offset)
93}
94
95pub fn find_html_style_attribute_slice(before_cursor: &str) -> Option<&str> {
96 let lower = before_cursor.to_ascii_lowercase();
97 let bytes = lower.as_bytes();
98 let mut search_end = lower.len();
99
100 while let Some(idx) = lower[..search_end].rfind("style") {
101 if idx > 0 && is_word_byte(bytes[idx - 1]) {
102 search_end = idx;
103 continue;
104 }
105 let after_idx = idx + 5;
106 if after_idx < bytes.len() && is_word_byte(bytes[after_idx]) {
107 search_end = idx;
108 continue;
109 }
110
111 let mut j = after_idx;
112 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
113 j += 1;
114 }
115 if j >= bytes.len() || bytes[j] != b'=' {
116 search_end = idx;
117 continue;
118 }
119 j += 1;
120 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
121 j += 1;
122 }
123 if j >= bytes.len() {
124 return None;
125 }
126
127 let quote = bytes[j];
128 if quote != b'"' && quote != b'\'' {
129 search_end = idx;
130 continue;
131 }
132 let value_start = j + 1;
133 let rest = &bytes[value_start..];
134 if !rest.contains("e) {
135 return Some(&before_cursor[value_start..]);
136 }
137
138 search_end = idx;
139 }
140
141 None
142}
143
144pub fn find_html_style_block_slice(before_cursor: &str) -> Option<&str> {
145 let lower = before_cursor.to_ascii_lowercase();
146 let open_idx = lower.rfind("<style")?;
147 if let Some(close_idx) = lower.rfind("</style") {
148 if close_idx > open_idx {
149 return None;
150 }
151 }
152
153 let tag_end_rel = lower[open_idx..].find('>')?;
154 let tag_end = open_idx + tag_end_rel;
155 if tag_end + 1 > before_cursor.len() {
156 return None;
157 }
158
159 Some(&before_cursor[tag_end + 1..])
160}
161
162pub fn find_html_style_context_slice(before_cursor: &str) -> Option<CompletionContextSlice<'_>> {
163 if let Some(slice) = find_html_style_attribute_slice(before_cursor) {
164 return Some(CompletionContextSlice {
165 slice,
166 allow_without_braces: true,
167 });
168 }
169 if let Some(slice) = find_html_style_block_slice(before_cursor) {
170 return Some(CompletionContextSlice {
171 slice,
172 allow_without_braces: false,
173 });
174 }
175 None
176}
177
178pub fn find_js_string_segment(before_cursor: &str) -> Option<&str> {
179 let bytes = before_cursor.as_bytes();
180 let mut in_quote: Option<u8> = None;
181 let mut in_template = false;
182 let mut template_expr_depth: i32 = 0;
183 let mut expr_quote: Option<u8> = None;
184 let mut segment_start: Option<usize> = None;
185
186 let mut i = 0;
187 while i < bytes.len() {
188 let b = bytes[i];
189 if let Some(q) = in_quote {
190 if b == b'\\' {
191 i = i.saturating_add(2);
192 continue;
193 }
194 if b == q {
195 in_quote = None;
196 segment_start = None;
197 }
198 i += 1;
199 continue;
200 }
201
202 if in_template {
203 if template_expr_depth > 0 {
204 if let Some(q) = expr_quote {
205 if b == b'\\' {
206 i = i.saturating_add(2);
207 continue;
208 }
209 if b == q {
210 expr_quote = None;
211 }
212 i += 1;
213 continue;
214 }
215
216 if b == b'\'' || b == b'"' || b == b'`' {
217 expr_quote = Some(b);
218 i += 1;
219 continue;
220 }
221 if b == b'{' {
222 template_expr_depth += 1;
223 } else if b == b'}' {
224 template_expr_depth -= 1;
225 if template_expr_depth == 0 {
226 segment_start = Some(i + 1);
227 }
228 }
229 i += 1;
230 continue;
231 }
232
233 if b == b'\\' {
234 i = i.saturating_add(2);
235 continue;
236 }
237 if b == b'`' {
238 in_template = false;
239 segment_start = None;
240 i += 1;
241 continue;
242 }
243 if b == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'{' {
244 template_expr_depth = 1;
245 segment_start = None;
246 i += 2;
247 continue;
248 }
249 i += 1;
250 continue;
251 }
252
253 if b == b'\'' || b == b'"' {
254 in_quote = Some(b);
255 segment_start = Some(i + 1);
256 i += 1;
257 continue;
258 }
259 if b == b'`' {
260 in_template = true;
261 segment_start = Some(i + 1);
262 i += 1;
263 continue;
264 }
265 i += 1;
266 }
267
268 if in_quote.is_some() {
269 return segment_start.map(|start| &before_cursor[start..]);
270 }
271 if in_template && template_expr_depth == 0 {
272 return segment_start.map(|start| &before_cursor[start..]);
273 }
274 None
275}
276
277pub fn find_context_colon(before_cursor: &str, allow_without_braces: bool) -> Option<usize> {
278 let mut in_braces = 0i32;
279 let mut in_parens = 0i32;
280 let mut last_colon: i32 = -1;
281 let mut last_semicolon: i32 = -1;
282 let mut last_brace: i32 = -1;
283
284 for (idx, ch) in before_cursor.char_indices().rev() {
285 match ch {
286 ')' => in_parens += 1,
287 '(' => {
288 in_parens -= 1;
289 if in_parens < 0 {
290 in_parens = 0;
291 }
292 }
293 '}' => in_braces += 1,
294 '{' => {
295 in_braces -= 1;
296 if in_braces < 0 {
297 last_brace = idx as i32;
298 break;
299 }
300 }
301 ':' if in_parens == 0 && in_braces == 0 && last_colon == -1 => {
302 last_colon = idx as i32;
303 }
304 ';' if in_parens == 0 && in_braces == 0 && last_semicolon == -1 => {
305 last_semicolon = idx as i32;
306 }
307 _ => {}
308 }
309 }
310
311 if !allow_without_braces && last_brace == -1 {
312 return None;
313 }
314
315 if last_colon > last_semicolon && last_colon > last_brace {
316 Some(last_colon as usize)
317 } else {
318 None
319 }
320}
321
322pub fn get_value_context_info(before_cursor: &str, allow_without_braces: bool) -> ValueContext {
323 let colon_pos = match find_context_colon(before_cursor, allow_without_braces) {
324 Some(pos) => pos,
325 None => {
326 return ValueContext {
327 is_value_context: false,
328 property_name: None,
329 }
330 }
331 };
332 let before_colon = before_cursor[..colon_pos].trim_end();
333 if before_colon.is_empty() {
334 return ValueContext {
335 is_value_context: true,
336 property_name: None,
337 };
338 }
339
340 let mut start = before_colon.len();
341 for (idx, ch) in before_colon.char_indices().rev() {
342 if is_word_char(ch) {
343 start = idx;
344 } else {
345 break;
346 }
347 }
348
349 if start >= before_colon.len() {
350 return ValueContext {
351 is_value_context: true,
352 property_name: None,
353 };
354 }
355
356 ValueContext {
357 is_value_context: true,
358 property_name: Some(before_colon[start..].to_lowercase()),
359 }
360}
361
362pub fn score_variable_relevance(var_name: &str, property_name: Option<&str>) -> i32 {
363 let property_name = match property_name {
364 Some(name) => name,
365 None => return -1,
366 };
367
368 let lower_var_name = var_name.to_lowercase();
369
370 let color_properties = [
371 "color",
372 "background-color",
373 "background",
374 "border-color",
375 "outline-color",
376 "text-decoration-color",
377 "fill",
378 "stroke",
379 ];
380 if color_properties.contains(&property_name) {
381 if lower_var_name.contains("color")
382 || lower_var_name.contains("bg")
383 || lower_var_name.contains("background")
384 || lower_var_name.contains("primary")
385 || lower_var_name.contains("secondary")
386 || lower_var_name.contains("accent")
387 || lower_var_name.contains("text")
388 || lower_var_name.contains("border")
389 || lower_var_name.contains("link")
390 {
391 return 10;
392 }
393 if lower_var_name.contains("spacing")
394 || lower_var_name.contains("margin")
395 || lower_var_name.contains("padding")
396 || lower_var_name.contains("size")
397 || lower_var_name.contains("width")
398 || lower_var_name.contains("height")
399 || lower_var_name.contains("font")
400 || lower_var_name.contains("weight")
401 || lower_var_name.contains("radius")
402 {
403 return 0;
404 }
405 return 5;
406 }
407
408 let spacing_properties = [
409 "margin",
410 "margin-top",
411 "margin-right",
412 "margin-bottom",
413 "margin-left",
414 "padding",
415 "padding-top",
416 "padding-right",
417 "padding-bottom",
418 "padding-left",
419 "gap",
420 "row-gap",
421 "column-gap",
422 ];
423 if spacing_properties.contains(&property_name) {
424 if lower_var_name.contains("spacing")
425 || lower_var_name.contains("margin")
426 || lower_var_name.contains("padding")
427 || lower_var_name.contains("gap")
428 {
429 return 10;
430 }
431 if lower_var_name.contains("color")
432 || lower_var_name.contains("bg")
433 || lower_var_name.contains("background")
434 {
435 return 0;
436 }
437 return 5;
438 }
439
440 let size_properties = [
441 "width",
442 "height",
443 "max-width",
444 "max-height",
445 "min-width",
446 "min-height",
447 "font-size",
448 ];
449 if size_properties.contains(&property_name) {
450 if lower_var_name.contains("width")
451 || lower_var_name.contains("height")
452 || lower_var_name.contains("size")
453 {
454 return 10;
455 }
456 if lower_var_name.contains("color")
457 || lower_var_name.contains("bg")
458 || lower_var_name.contains("background")
459 {
460 return 0;
461 }
462 return 5;
463 }
464
465 if property_name.contains("radius") {
466 if lower_var_name.contains("radius") || lower_var_name.contains("rounded") {
467 return 10;
468 }
469 if lower_var_name.contains("color")
470 || lower_var_name.contains("bg")
471 || lower_var_name.contains("background")
472 {
473 return 0;
474 }
475 return 5;
476 }
477
478 let font_properties = ["font-family", "font-weight", "font-style"];
479 if font_properties.contains(&property_name) {
480 if lower_var_name.contains("font") {
481 return 10;
482 }
483 if lower_var_name.contains("color") || lower_var_name.contains("spacing") {
484 return 0;
485 }
486 return 5;
487 }
488
489 -1
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495 use crate::document_kind::build_lookup_extension_map;
496 use crate::types::{offset_to_position, Config};
497 use ls_types::Uri;
498 use std::str::FromStr;
499
500 fn build_long_css_rule() -> String {
501 let decl = " margin-top: 1px;\n";
502 let mut rule = String::from(".card {\n");
503 for _ in 0..30 {
504 rule.push_str(decl);
505 }
506 rule.push_str(" font: 400 16px/1.5 system-ui, sans-serif;\n");
507 rule.push_str(" color: var(--");
508 rule
509 }
510
511 #[test]
512 fn long_css_rule_detects_value_context_at_bottom() {
513 let text = build_long_css_rule();
514 assert!(text.len() > 500);
515
516 let lookup_map = build_lookup_extension_map(&Config::default().lookup_files);
517 let uri = Uri::from_str("file:///styles.css").unwrap();
518 let position = offset_to_position(&text, text.len());
519
520 let context = completion_value_context_slice(&text, position, None, &uri, &lookup_map)
521 .expect("css document should yield a completion slice");
522 let value_context = get_value_context_info(context.slice, context.allow_without_braces);
523
524 assert!(
525 value_context.is_value_context,
526 "long rule blocks must still detect property value context"
527 );
528 assert_eq!(value_context.property_name.as_deref(), Some("color"));
529 }
530
531 #[test]
532 fn nested_css_rule_detects_inner_property() {
533 let text = ".outer { .inner { color: var(--";
534 let lookup_map = build_lookup_extension_map(&Config::default().lookup_files);
535 let uri = Uri::from_str("file:///styles.css").unwrap();
536 let position = offset_to_position(text, text.len());
537
538 let context = completion_value_context_slice(text, position, None, &uri, &lookup_map)
539 .expect("expected css slice");
540 let value_context = get_value_context_info(context.slice, context.allow_without_braces);
541
542 assert!(value_context.is_value_context);
543 assert_eq!(value_context.property_name.as_deref(), Some("color"));
544 }
545
546 #[test]
547 fn html_style_block_lookback_includes_style_tag() {
548 let text = "<style>body { color: var(";
549 let lookup_map = build_lookup_extension_map(&Config::default().lookup_files);
550 let uri = Uri::from_str("file:///index.html").unwrap();
551 let position = offset_to_position(text, text.len());
552
553 let context = completion_value_context_slice(text, position, None, &uri, &lookup_map)
554 .expect("expected html style block slice");
555 assert_eq!(context.slice, "body { color: var(");
556 assert!(!context.allow_without_braces);
557 }
558}