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