Skip to main content

omena_query/style/
code_actions.rs

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}