1use std::{
2 collections::{BTreeMap, BTreeSet},
3 path::{Component, Path, PathBuf},
4};
5
6use omena_parser::{ParsedCssModuleComposesEdgeKind, css_keyword};
7
8use super::*;
9
10pub fn summarize_omena_query_style_extract_code_actions(
11 style_uri: &str,
12 source: &str,
13 range: ParserRangeV0,
14) -> OmenaQueryCodeActionPlanV0 {
15 let mut actions = Vec::new();
16
17 if let Some(value) = selected_extractable_css_value(source, range) {
18 let property_name = next_custom_property_name(source, custom_property_stem(value));
19 actions.push(OmenaQueryCodeActionV0 {
20 title: format!("Extract CSS custom property '{property_name}'"),
21 kind: "refactor.extract",
22 edits: vec![
23 OmenaQueryWorkspaceTextEditV0 {
24 uri: style_uri.to_string(),
25 range: start_of_source_range(),
26 new_text: format!(":root {{\n {property_name}: {value};\n}}\n\n"),
27 },
28 OmenaQueryWorkspaceTextEditV0 {
29 uri: style_uri.to_string(),
30 range,
31 new_text: format!("var({property_name})"),
32 },
33 ],
34 source: "omenaQueryStyleExtractCodeActions",
35 });
36
37 let value_name = next_value_name(source, value_name_stem(value));
38 actions.push(OmenaQueryCodeActionV0 {
39 title: format!("Extract @value '{value_name}'"),
40 kind: "refactor.extract",
41 edits: vec![
42 OmenaQueryWorkspaceTextEditV0 {
43 uri: style_uri.to_string(),
44 range: start_of_source_range(),
45 new_text: format!("@value {value_name}: {value};\n\n"),
46 },
47 OmenaQueryWorkspaceTextEditV0 {
48 uri: style_uri.to_string(),
49 range,
50 new_text: value_name,
51 },
52 ],
53 source: "omenaQueryStyleExtractCodeActions",
54 });
55 }
56
57 OmenaQueryCodeActionPlanV0 {
58 schema_version: "0",
59 product: "omena-query.code-actions",
60 file_uri: style_uri.to_string(),
61 file_kind: "style",
62 action_count: actions.len(),
63 actions,
64 ready_surfaces: vec!["styleExtractRefactorActions", "productFacingCodeActions"],
65 }
66}
67
68pub fn summarize_omena_query_style_inline_code_actions(
69 style_uri: &str,
70 style_sources: &[OmenaQueryStyleSourceInputV0],
71 range: ParserRangeV0,
72 package_manifests: &[OmenaQueryStylePackageManifestV0],
73) -> OmenaQueryCodeActionPlanV0 {
74 summarize_omena_query_style_inline_code_actions_with_resolution_inputs(
75 style_uri,
76 style_sources,
77 range,
78 package_manifests,
79 &OmenaQueryStyleResolutionInputsV0::default(),
80 )
81}
82
83pub fn summarize_omena_query_style_inline_code_actions_with_resolution_inputs(
84 style_uri: &str,
85 style_sources: &[OmenaQueryStyleSourceInputV0],
86 range: ParserRangeV0,
87 package_manifests: &[OmenaQueryStylePackageManifestV0],
88 resolution_inputs: &OmenaQueryStyleResolutionInputsV0,
89) -> OmenaQueryCodeActionPlanV0 {
90 let Some(target_source) = style_sources
91 .iter()
92 .find(|source| source.style_path == style_uri)
93 else {
94 return empty_style_code_action_plan(style_uri, "styleInlineRefactorActions");
95 };
96 let Some(range_start) =
97 byte_offset_for_parser_position(&target_source.style_source, range.start)
98 else {
99 return empty_style_code_action_plan(style_uri, "styleInlineRefactorActions");
100 };
101
102 let style_source_by_path = style_sources
103 .iter()
104 .map(|source| (source.style_path.as_str(), source.style_source.as_str()))
105 .collect::<BTreeMap<_, _>>();
106 let available_style_paths = style_sources
107 .iter()
108 .map(|source| source.style_path.as_str())
109 .collect::<BTreeSet<_>>();
110 let dialect = omena_parser_dialect_for_style_path(style_uri);
111 let facts = collect_omena_query_omena_parser_style_facts_raw(
112 target_source.style_source.as_str(),
113 dialect,
114 );
115
116 for edge in facts.css_module_composes_edges {
117 let edge_start: u32 = edge.range.start().into();
118 let edge_end: u32 = edge.range.end().into();
119 let edge_span = ParserByteSpanV0 {
120 start: edge_start as usize,
121 end: edge_end as usize,
122 };
123 if range_start < edge_span.start || range_start > edge_span.end {
124 continue;
125 }
126 if edge.kind == ParsedCssModuleComposesEdgeKind::Global {
127 continue;
128 }
129
130 let mut context = InlineDeclarationContext {
131 style_source_by_path: &style_source_by_path,
132 available_style_paths: &available_style_paths,
133 package_manifests,
134 resolution_inputs,
135 emitted: BTreeSet::new(),
136 visiting: BTreeSet::new(),
137 };
138 let mut declarations = Vec::new();
139 for target_name in &edge.target_names {
140 let Some(target_style_path) = resolve_inline_target_style_path(
141 style_uri,
142 &edge,
143 &available_style_paths,
144 package_manifests,
145 resolution_inputs,
146 ) else {
147 return empty_style_code_action_plan(style_uri, "styleInlineRefactorActions");
148 };
149 let Some(target_declarations) = collect_inline_declarations(
150 target_style_path.as_str(),
151 target_name.as_str(),
152 &mut context,
153 ) else {
154 return empty_style_code_action_plan(style_uri, "styleInlineRefactorActions");
155 };
156 declarations.extend(target_declarations);
157 }
158 if declarations.is_empty() {
159 continue;
160 }
161
162 let replacement_range =
163 expand_inline_composes_statement_range(target_source.style_source.as_str(), edge_span);
164 let replacement_text = format_inline_declarations(
165 declarations.as_slice(),
166 line_indent_at(
167 target_source.style_source.as_str(),
168 replacement_range.start.line,
169 )
170 .as_str(),
171 );
172 let action = OmenaQueryCodeActionV0 {
173 title: format_inline_action_title(edge.target_names.as_slice()),
174 kind: "refactor.inline",
175 edits: vec![OmenaQueryWorkspaceTextEditV0 {
176 uri: style_uri.to_string(),
177 range: replacement_range,
178 new_text: replacement_text,
179 }],
180 source: "omenaQueryStyleInlineCodeActions",
181 };
182 return OmenaQueryCodeActionPlanV0 {
183 schema_version: "0",
184 product: "omena-query.code-actions",
185 file_uri: style_uri.to_string(),
186 file_kind: "style",
187 action_count: 1,
188 actions: vec![action],
189 ready_surfaces: vec!["styleInlineRefactorActions", "productFacingCodeActions"],
190 };
191 }
192
193 empty_style_code_action_plan(style_uri, "styleInlineRefactorActions")
194}
195
196fn empty_style_code_action_plan(
197 style_uri: &str,
198 ready_surface: &'static str,
199) -> OmenaQueryCodeActionPlanV0 {
200 OmenaQueryCodeActionPlanV0 {
201 schema_version: "0",
202 product: "omena-query.code-actions",
203 file_uri: style_uri.to_string(),
204 file_kind: "style",
205 action_count: 0,
206 actions: Vec::new(),
207 ready_surfaces: vec![ready_surface, "productFacingCodeActions"],
208 }
209}
210
211pub fn summarize_omena_query_style_refactor_code_actions(
212 style_uri: &str,
213 style_sources: &[OmenaQueryStyleSourceInputV0],
214 source: &str,
215 range: ParserRangeV0,
216 package_manifests: &[OmenaQueryStylePackageManifestV0],
217) -> OmenaQueryCodeActionPlanV0 {
218 summarize_omena_query_style_refactor_code_actions_with_resolution_inputs(
219 style_uri,
220 style_sources,
221 source,
222 range,
223 package_manifests,
224 &OmenaQueryStyleResolutionInputsV0::default(),
225 )
226}
227
228pub fn summarize_omena_query_style_refactor_code_actions_with_resolution_inputs(
229 style_uri: &str,
230 style_sources: &[OmenaQueryStyleSourceInputV0],
231 source: &str,
232 range: ParserRangeV0,
233 package_manifests: &[OmenaQueryStylePackageManifestV0],
234 resolution_inputs: &OmenaQueryStyleResolutionInputsV0,
235) -> OmenaQueryCodeActionPlanV0 {
236 let inline = summarize_omena_query_style_inline_code_actions_with_resolution_inputs(
237 style_uri,
238 style_sources,
239 range,
240 package_manifests,
241 resolution_inputs,
242 );
243 if inline.action_count > 0 {
244 return inline;
245 }
246
247 let insight = summarize_omena_query_style_insight_code_actions(style_uri, source, range);
248 if insight.action_count > 0 {
249 return insight;
250 }
251
252 summarize_omena_query_style_extract_code_actions(style_uri, source, range)
253}
254
255struct InlineDeclarationContext<'a> {
256 style_source_by_path: &'a BTreeMap<&'a str, &'a str>,
257 available_style_paths: &'a BTreeSet<&'a str>,
258 package_manifests: &'a [OmenaQueryStylePackageManifestV0],
259 resolution_inputs: &'a OmenaQueryStyleResolutionInputsV0,
260 emitted: BTreeSet<(String, String)>,
261 visiting: BTreeSet<(String, String)>,
262}
263
264fn collect_inline_declarations(
265 style_path: &str,
266 selector_name: &str,
267 context: &mut InlineDeclarationContext<'_>,
268) -> Option<Vec<String>> {
269 let key = (style_path.to_string(), selector_name.to_string());
270 if context.emitted.contains(&key) {
271 return Some(Vec::new());
272 }
273 if !context.visiting.insert(key.clone()) {
274 return None;
275 }
276
277 let source = *context.style_source_by_path.get(style_path)?;
278 let dialect = omena_parser_dialect_for_style_path(style_path);
279 let facts = collect_omena_query_omena_parser_style_facts_raw(source, dialect);
280 let mut declarations = Vec::new();
281
282 for edge in facts.css_module_composes_edges.iter().filter(|edge| {
283 edge.owner_selector_names
284 .iter()
285 .any(|owner| owner == selector_name)
286 }) {
287 if edge.kind == ParsedCssModuleComposesEdgeKind::Global {
288 return None;
289 }
290 let target_style_path = resolve_inline_target_style_path(
291 style_path,
292 edge,
293 context.available_style_paths,
294 context.package_manifests,
295 context.resolution_inputs,
296 )?;
297 for target_name in &edge.target_names {
298 declarations.extend(collect_inline_declarations(
299 target_style_path.as_str(),
300 target_name.as_str(),
301 context,
302 )?);
303 }
304 }
305
306 declarations.extend(selector_inline_declarations(source, selector_name)?);
307 context.visiting.remove(&key);
308 context.emitted.insert(key);
309 Some(declarations)
310}
311
312fn resolve_inline_target_style_path(
313 owner_style_path: &str,
314 edge: &omena_parser::ParsedCssModuleComposesEdgeFact,
315 available_style_paths: &BTreeSet<&str>,
316 package_manifests: &[OmenaQueryStylePackageManifestV0],
317 resolution_inputs: &OmenaQueryStyleResolutionInputsV0,
318) -> Option<String> {
319 if edge.kind == ParsedCssModuleComposesEdgeKind::Local {
320 return Some(owner_style_path.to_string());
321 }
322 let source = edge.import_source.as_deref()?;
323 resolve_style_module_source_with_resolution_inputs(
324 owner_style_path,
325 source,
326 available_style_paths,
327 package_manifests,
328 resolution_inputs,
329 )
330 .or_else(|| {
331 resolve_file_uri_relative_style_module_source(
332 owner_style_path,
333 source,
334 available_style_paths,
335 )
336 })
337}
338
339fn resolve_file_uri_relative_style_module_source(
340 owner_style_uri: &str,
341 source: &str,
342 available_style_paths: &BTreeSet<&str>,
343) -> Option<String> {
344 if !source.starts_with('.') {
345 return None;
346 }
347 let owner_path = owner_style_uri.strip_prefix("file://")?;
348 let owner_dir = Path::new(owner_path).parent()?;
349 let source_path = owner_dir.join(source);
350 let mut candidates = Vec::new();
351 push_file_uri_candidate_paths(
352 &mut candidates,
353 source_path.clone(),
354 Path::new(source).extension().is_none(),
355 );
356 candidates.into_iter().find(|candidate| {
357 available_style_paths
358 .iter()
359 .any(|available| normalize_file_uri(available).as_deref() == Some(candidate.as_str()))
360 })
361}
362
363fn push_file_uri_candidate_paths(
364 candidates: &mut Vec<String>,
365 base_path: PathBuf,
366 needs_extension: bool,
367) {
368 if needs_extension {
369 for extension in ["css", "scss", "sass", "less"] {
370 let mut candidate = base_path.clone();
371 candidate.set_extension(extension);
372 if let Some(uri) = file_uri_from_path(candidate.as_path()) {
373 candidates.push(uri);
374 }
375 }
376 } else if let Some(uri) = file_uri_from_path(base_path.as_path()) {
377 candidates.push(uri);
378 }
379}
380
381fn file_uri_from_path(path: &Path) -> Option<String> {
382 let mut normalized = PathBuf::new();
383 for component in path.components() {
384 match component {
385 Component::RootDir => normalized.push("/"),
386 Component::CurDir => {}
387 Component::ParentDir => {
388 normalized.pop();
389 }
390 Component::Normal(value) => normalized.push(value),
391 Component::Prefix(_) => return None,
392 }
393 }
394 Some(format!(
395 "file://{}",
396 normalized.to_string_lossy().replace('\\', "/")
397 ))
398}
399
400fn normalize_file_uri(uri: &str) -> Option<String> {
401 file_uri_from_path(Path::new(uri.strip_prefix("file://")?))
402}
403
404fn selector_inline_declarations(source: &str, selector_name: &str) -> Option<Vec<String>> {
405 let body = selector_rule_body(source, selector_name)?;
406 let declarations = body
407 .split(';')
408 .map(str::trim)
409 .filter(|declaration| !declaration.is_empty())
410 .filter(|declaration| css_keyword(declaration).strip_prefix("composes:").is_none())
411 .map(ToString::to_string)
412 .collect::<Vec<_>>();
413 Some(declarations)
414}
415
416fn selector_rule_body<'a>(source: &'a str, selector_name: &str) -> Option<&'a str> {
417 let needle = format!(".{selector_name}");
418 let mut search_start = 0usize;
419 while let Some(relative_index) = source.get(search_start..)?.find(needle.as_str()) {
420 let selector_start = search_start + relative_index;
421 let before_is_identifier = source
422 .get(..selector_start)
423 .and_then(|prefix| prefix.chars().next_back())
424 .is_some_and(is_css_selector_identifier_char);
425 let selector_end = selector_start + needle.len();
426 let after_is_identifier = source
427 .get(selector_end..)
428 .and_then(|suffix| suffix.chars().next())
429 .is_some_and(is_css_selector_identifier_char);
430 if before_is_identifier || after_is_identifier {
431 search_start = selector_end;
432 continue;
433 }
434 let open_brace = selector_end + source.get(selector_end..)?.find('{')?;
435 let close_brace = matching_close_brace(source, open_brace)?;
436 return source.get(open_brace + 1..close_brace);
437 }
438 None
439}
440
441fn matching_close_brace(source: &str, open_brace: usize) -> Option<usize> {
442 let mut depth = 0usize;
443 for (offset, ch) in source.get(open_brace..)?.char_indices() {
444 if ch == '{' {
445 depth += 1;
446 } else if ch == '}' {
447 depth = depth.checked_sub(1)?;
448 if depth == 0 {
449 return Some(open_brace + offset);
450 }
451 }
452 }
453 None
454}
455
456fn expand_inline_composes_statement_range(source: &str, span: ParserByteSpanV0) -> ParserRangeV0 {
457 let mut end = span.end;
458 while source
459 .as_bytes()
460 .get(end)
461 .is_some_and(u8::is_ascii_whitespace)
462 {
463 end += 1;
464 }
465 if source.as_bytes().get(end) == Some(&b';') {
466 end += 1;
467 }
468 parser_range_for_byte_span(
469 source,
470 ParserByteSpanV0 {
471 start: span.start,
472 end,
473 },
474 )
475}
476
477fn line_indent_at(source: &str, line: usize) -> String {
478 source
479 .lines()
480 .nth(line)
481 .map(|line| {
482 line.chars()
483 .take_while(|ch| *ch == ' ' || *ch == '\t')
484 .collect()
485 })
486 .unwrap_or_default()
487}
488
489fn format_inline_declarations(declarations: &[String], continuation_indent: &str) -> String {
490 declarations
491 .iter()
492 .enumerate()
493 .map(|(index, declaration)| {
494 if index == 0 {
495 format!("{declaration};")
496 } else {
497 format!("{continuation_indent}{declaration};")
498 }
499 })
500 .collect::<Vec<_>>()
501 .join("\n")
502}
503
504fn format_inline_action_title(target_names: &[String]) -> String {
505 if target_names.len() == 1 {
506 return format!("Inline composed class '{}'", target_names[0]);
507 }
508 format!("Inline composed classes '{}'", target_names.join(", "))
509}
510
511fn is_css_selector_identifier_char(ch: char) -> bool {
512 ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '\\')
513}
514
515fn selected_extractable_css_value(source: &str, range: ParserRangeV0) -> Option<&str> {
516 let start = byte_offset_for_parser_position(source, range.start)?;
517 let end = byte_offset_for_parser_position(source, range.end)?;
518 if end <= start {
519 return None;
520 }
521 let value = source.get(start..end)?.trim();
522 is_extractable_css_value(value).then_some(value)
523}
524
525fn start_of_source_range() -> ParserRangeV0 {
526 ParserRangeV0 {
527 start: ParserPositionV0 {
528 line: 0,
529 character: 0,
530 },
531 end: ParserPositionV0 {
532 line: 0,
533 character: 0,
534 },
535 }
536}
537
538fn is_extractable_css_value(value: &str) -> bool {
539 if value.is_empty() || value.contains('\n') || value.starts_with("var(") {
540 return false;
541 }
542 is_hex_color(value)
543 || numeric_value_unit_kind(value).is_some()
544 || is_color_function(value)
545 || is_ascii_css_keyword(value)
546}
547
548fn is_hex_color(value: &str) -> bool {
549 value.strip_prefix('#').is_some_and(|hex| {
550 (3..=8).contains(&hex.len()) && hex.chars().all(|ch| ch.is_ascii_hexdigit())
551 })
552}
553
554fn numeric_value_unit_kind(value: &str) -> Option<&'static str> {
555 let mut chars = value.char_indices().peekable();
556 if matches!(chars.peek(), Some((_, '-'))) {
557 chars.next();
558 }
559
560 let mut digit_count = 0usize;
561 let mut last_number_end = 0usize;
562 while let Some((index, ch)) = chars.peek().copied() {
563 if !ch.is_ascii_digit() {
564 break;
565 }
566 digit_count += 1;
567 last_number_end = index + ch.len_utf8();
568 chars.next();
569 }
570 if digit_count == 0 {
571 return None;
572 }
573
574 if matches!(chars.peek(), Some((_, '.'))) {
575 chars.next();
576 let mut fractional_digit_count = 0usize;
577 while let Some((index, ch)) = chars.peek().copied() {
578 if !ch.is_ascii_digit() {
579 break;
580 }
581 fractional_digit_count += 1;
582 last_number_end = index + ch.len_utf8();
583 chars.next();
584 }
585 if fractional_digit_count == 0 {
586 return None;
587 }
588 }
589
590 let unit = value.get(last_number_end..)?;
591 match unit {
592 "s" | "ms" => Some("duration"),
593 "deg" => Some("angle"),
594 "" | "px" | "rem" | "em" | "%" | "vh" | "vw" | "vmin" | "vmax" | "ch" | "ex" => {
595 Some("size")
596 }
597 _ => None,
598 }
599}
600
601fn is_color_function(value: &str) -> bool {
602 ["rgb(", "rgba(", "hsl(", "hsla("]
603 .iter()
604 .any(|prefix| value.starts_with(prefix))
605 && value.ends_with(')')
606}
607
608fn is_ascii_css_keyword(value: &str) -> bool {
609 let mut chars = value.chars();
610 chars.next().is_some_and(|ch| ch.is_ascii_alphabetic())
611 && chars.all(|ch| ch.is_ascii_alphabetic() || ch == '-')
612}
613
614fn custom_property_stem(value: &str) -> &'static str {
615 if is_hex_color(value) || is_color_function(value) {
616 return "extracted-color";
617 }
618 match numeric_value_unit_kind(value) {
619 Some("duration") => "extracted-duration",
620 Some("angle") => "extracted-angle",
621 Some("size") => "extracted-size",
622 _ => "extracted-token",
623 }
624}
625
626fn next_custom_property_name(source: &str, stem: &str) -> String {
627 let mut candidate = format!("--{stem}");
628 let mut suffix = 2usize;
629 while source.contains(candidate.as_str()) {
630 candidate = format!("--{stem}-{suffix}");
631 suffix += 1;
632 }
633 candidate
634}
635
636fn value_name_stem(value: &str) -> &'static str {
637 if is_hex_color(value) || is_color_function(value) {
638 return "extractedColor";
639 }
640 match numeric_value_unit_kind(value) {
641 Some("duration") => "extractedDuration",
642 Some("angle") => "extractedAngle",
643 Some("size") => "extractedSize",
644 _ => "extractedToken",
645 }
646}
647
648fn next_value_name(source: &str, stem: &str) -> String {
649 let mut candidate = stem.to_string();
650 let mut suffix = 2usize;
651 while contains_identifier(source, candidate.as_str()) {
652 candidate = format!("{stem}{suffix}");
653 suffix += 1;
654 }
655 candidate
656}
657
658fn contains_identifier(source: &str, candidate: &str) -> bool {
659 source.match_indices(candidate).any(|(start, _)| {
660 let end = start + candidate.len();
661 let before_is_identifier = source
662 .get(..start)
663 .and_then(|prefix| prefix.chars().next_back())
664 .is_some_and(is_identifier_char);
665 let after_is_identifier = source
666 .get(end..)
667 .and_then(|suffix| suffix.chars().next())
668 .is_some_and(is_identifier_char);
669 !before_is_identifier && !after_is_identifier
670 })
671}
672
673fn is_identifier_char(ch: char) -> bool {
674 ch.is_ascii_alphanumeric() || ch == '_'
675}