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;
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}