1use std::collections::{BTreeMap, BTreeSet};
2
3use omena_parser::{
4 ParsedAnimationFactKind, ParsedCssModuleComposesEdgeKind, ParsedExtendTargetFactKind,
5 ParsedSassModuleEdgeFact, ParsedSassModuleEdgeFactKind, ParsedSelectorFactKind,
6 ParsedVariableFactKind,
7};
8use omena_query_checker_orchestrator::{
9 ModuleGraphEdgeV0, ModuleGraphV0, OutcomeMode, REPLICA_ENSEMBLE_FEATURE_GATE_V0,
10 REPLICA_ENSEMBLE_LAYER_MARKER_V0, REPLICA_ENSEMBLE_SCHEMA_VERSION_V0, ReplicaSnapshotV0,
11 ReportOptionsV0, ReportRecommendation, build_cross_file_inconsistency_report,
12};
13use omena_query_checker_orchestrator::{
14 OmenaCheckerReplicaEnsembleInputV0, OmenaCheckerReplicaEnsembleReportInputV0,
15 run_omena_query_checker_replica_ensemble_gate_v0,
16};
17
18use super::cascade_checker::collect_query_replica_ensemble_site_outcomes;
19use super::cascade_checker::summarize_query_cascade_checker_diagnostics_with_deep_analysis;
20use super::diagnostic_suppressions::OmenaStrictnessLevelV0;
21use super::diagnostic_suppressions::apply_omena_query_style_diagnostic_suppressions;
22use super::diagnostic_suppressions::parse_omena_query_style_strictness_level;
23use super::parser_facade::collect_omena_query_omena_parser_style_facts_raw;
24use super::*;
25
26const LSP_DIAGNOSTIC_TAG_UNNECESSARY: u8 = 1;
27const LSP_DIAGNOSTIC_TAG_DEPRECATED: u8 = 2;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum OmenaQueryExternalModuleModeV0 {
31 Ignored,
32 Sif,
33}
34
35pub fn summarize_omena_query_missing_custom_property_diagnostics(
36 style_uri: &str,
37 source: &str,
38 candidates: &[OmenaQueryStyleHoverCandidateV0],
39) -> Vec<OmenaQueryStyleDiagnosticV0> {
40 let declaration_names = candidates
41 .iter()
42 .filter(|candidate| candidate.kind == "customPropertyDeclaration")
43 .map(|candidate| candidate.name.as_str())
44 .collect::<BTreeSet<_>>();
45 if declaration_names.is_empty() {
46 return Vec::new();
47 }
48
49 let dialect = omena_parser_dialect_for_style_path(style_uri);
56 let facts = collect_omena_query_omena_parser_style_facts_raw(source, dialect);
57 let fallback_ranges = facts
58 .variables
59 .iter()
60 .filter(|fact| {
61 fact.kind == ParsedVariableFactKind::CustomPropertyReference && fact.has_fallback
62 })
63 .map(|fact| {
64 let byte_span = ParserByteSpanV0 {
65 start: u32::from(fact.range.start()) as usize,
66 end: u32::from(fact.range.end()) as usize,
67 };
68 (
69 fact.name.clone(),
70 parser_range_for_byte_span(source, byte_span),
71 )
72 })
73 .collect::<BTreeSet<_>>();
74
75 let insertion_range = end_of_source_range(source);
76 candidates
77 .iter()
78 .filter(|candidate| {
79 candidate.kind == "customPropertyReference"
80 && !declaration_names.contains(candidate.name.as_str())
81 && !fallback_ranges.contains(&(candidate.name.clone(), candidate.range))
82 })
83 .map(|candidate| OmenaQueryStyleDiagnosticV0 {
84 code: "missingCustomProperty",
85 severity: "warning",
86 provenance: vec![
87 "omena-parser.custom-property-facts",
88 "omena-query.style-diagnostics",
89 ],
90 range: candidate.range,
91 message: format!(
92 "CSS custom property '{}' not found in indexed style tokens.",
93 candidate.name
94 ),
95 tags: Vec::new(),
96 create_custom_property: Some(OmenaQueryCreateCustomPropertyActionV0 {
97 uri: style_uri.to_string(),
98 range: insertion_range,
99 new_text: format!("\n\n:root {{\n {}: ;\n}}\n", candidate.name),
100 property_name: candidate.name.clone(),
101 }),
102 })
103 .collect()
104}
105
106pub fn summarize_omena_query_cascade_aware_style_diagnostics(
107 style_uri: &str,
108 source: &str,
109 candidates: &[OmenaQueryStyleHoverCandidateV0],
110) -> Vec<OmenaQueryStyleDiagnosticV0> {
111 summarize_omena_query_cascade_aware_style_diagnostics_with_deep_analysis(
112 style_uri, source, candidates, false,
113 )
114}
115
116pub fn summarize_omena_query_cascade_aware_style_diagnostics_with_deep_analysis(
121 style_uri: &str,
122 source: &str,
123 candidates: &[OmenaQueryStyleHoverCandidateV0],
124 deep_analysis: bool,
125) -> Vec<OmenaQueryStyleDiagnosticV0> {
126 let declarations_by_name = candidates
127 .iter()
128 .filter(|candidate| candidate.kind == "customPropertyDeclaration")
129 .map(|candidate| (candidate.name.as_str(), candidate.range))
130 .collect::<BTreeMap<_, _>>();
131
132 let dialect = omena_parser_dialect_for_style_path(style_uri);
133 let mut diagnostics =
134 summarize_static_css_custom_property_fixed_point_from_source(source, dialect)
135 .entries
136 .into_iter()
137 .filter(|entry| entry.guaranteed_invalid)
138 .filter_map(|entry| {
139 declarations_by_name
140 .get(entry.name.as_str())
141 .copied()
142 .map(|range| OmenaQueryStyleDiagnosticV0 {
143 code: "guaranteedInvalidCustomProperty",
144 severity: "warning",
145 provenance: vec![
146 "omena-transform-passes.custom-property-lfp",
147 "omena-query.cascade-aware-diagnostics",
148 ],
149 range,
150 message: format!(
151 "CSS custom property '{}' resolves to the guaranteed-invalid value.",
152 entry.name
153 ),
154 tags: Vec::new(),
155 create_custom_property: None,
156 })
157 })
158 .collect::<Vec<_>>();
159
160 diagnostics.extend(
161 summarize_query_cascade_checker_diagnostics_with_deep_analysis(
162 style_uri,
163 source,
164 deep_analysis,
165 ),
166 );
167
168 diagnostics
169}
170
171pub fn summarize_omena_query_missing_keyframes_diagnostics(
172 style_uri: &str,
173 source: &str,
174) -> Vec<OmenaQueryStyleDiagnosticV0> {
175 let dialect = omena_parser_dialect_for_style_path(style_uri);
176 let facts = collect_omena_query_omena_parser_style_facts_raw(source, dialect);
177 let declared_keyframes = facts
178 .animations
179 .iter()
180 .filter(|animation| animation.kind == ParsedAnimationFactKind::KeyframesDeclaration)
181 .map(|animation| animation.name.clone())
182 .collect::<BTreeSet<_>>();
183 let mut emitted = BTreeSet::new();
184
185 facts
186 .animations
187 .into_iter()
188 .filter(|animation| animation.kind == ParsedAnimationFactKind::AnimationNameReference)
189 .filter(|animation| !declared_keyframes.contains(animation.name.as_str()))
190 .filter_map(|animation| {
191 let start: u32 = animation.range.start().into();
192 let end: u32 = animation.range.end().into();
193 let byte_span = ParserByteSpanV0 {
194 start: start as usize,
195 end: end as usize,
196 };
197 if !emitted.insert((animation.name.clone(), byte_span.start, byte_span.end)) {
198 return None;
199 }
200 Some((animation, parser_range_for_byte_span(source, byte_span)))
201 })
202 .map(|(animation, range)| OmenaQueryStyleDiagnosticV0 {
203 code: "missingKeyframes",
204 severity: "warning",
205 provenance: vec![
206 "omena-parser.animation-facts",
207 "omena-query.style-diagnostics",
208 ],
209 range,
210 message: format!("@keyframes '{}' not found in this file.", animation.name),
211 tags: Vec::new(),
212 create_custom_property: None,
213 })
214 .collect()
215}
216
217pub fn summarize_omena_query_missing_sass_symbol_diagnostics(
218 style_uri: &str,
219 source: &str,
220) -> Vec<OmenaQueryStyleDiagnosticV0> {
221 let dialect = omena_parser_dialect_for_style_path(style_uri);
222 let facts = collect_omena_query_omena_parser_style_facts_raw(source, dialect);
223 let mut declarations = BTreeSet::<SassSymbolKey>::new();
224 let mut emitted = BTreeSet::new();
225 let mut diagnostics = Vec::new();
226
227 for symbol in facts.sass_symbols {
228 let key = sass_symbol_key(
232 symbol.symbol_kind,
233 symbol.namespace.clone(),
234 symbol.name.clone(),
235 );
236 if omena_query_sass_symbol_fact_kind_is_declaration(symbol.kind) {
237 declarations.insert(key);
238 continue;
239 }
240 if !omena_query_sass_symbol_fact_kind_is_reference(symbol.kind) {
241 continue;
242 }
243 if declarations.contains(&key) {
244 continue;
245 }
246 if is_omena_query_sass_builtin_symbol_reference_resolved(
247 &facts.sass_module_edges,
248 symbol.symbol_kind,
249 symbol.namespace.as_deref(),
250 symbol.name.as_str(),
251 ) {
252 continue;
253 }
254
255 let start: u32 = symbol.range.start().into();
256 let end: u32 = symbol.range.end().into();
257 let byte_span = ParserByteSpanV0 {
258 start: start as usize,
259 end: end as usize,
260 };
261 if !emitted.insert((
262 symbol.symbol_kind,
263 symbol.namespace.clone(),
264 symbol.name.clone(),
265 byte_span.start,
266 byte_span.end,
267 )) {
268 continue;
269 }
270 diagnostics.push(OmenaQueryStyleDiagnosticV0 {
271 code: "missingSassSymbol",
272 severity: "warning",
273 provenance: vec![
274 "omena-parser.sass-symbol-facts",
275 "omena-query.style-diagnostics",
276 ],
277 range: parser_range_for_byte_span(source, byte_span),
278 message: format!(
279 "{} not found in this file.",
280 format_query_sass_symbol_label(symbol.symbol_kind, symbol.name.as_str())
281 ),
282 tags: Vec::new(),
283 create_custom_property: None,
284 });
285 }
286
287 diagnostics
288}
289
290pub fn summarize_omena_query_missing_extend_target_diagnostics(
307 style_uri: &str,
308 source: &str,
309) -> Vec<OmenaQueryStyleDiagnosticV0> {
310 let dialect = omena_parser_dialect_for_style_path(style_uri);
311 if !matches!(
312 dialect,
313 OmenaParserStyleDialect::Scss | OmenaParserStyleDialect::Sass
314 ) {
315 return Vec::new();
316 }
317
318 let facts = collect_omena_query_omena_parser_style_facts_raw(source, dialect);
319 if facts.extend_targets.is_empty() {
320 return Vec::new();
321 }
322
323 let mut declared_placeholders = BTreeSet::new();
324 let mut declared_classes = BTreeSet::new();
325 for selector in &facts.selectors {
326 match selector.kind {
327 ParsedSelectorFactKind::Placeholder => {
328 declared_placeholders.insert(selector.name.clone());
329 }
330 ParsedSelectorFactKind::Class => {
331 declared_classes.insert(selector.name.clone());
332 }
333 ParsedSelectorFactKind::Id => {}
334 }
335 }
336
337 let mut emitted = BTreeSet::new();
338 let mut diagnostics = Vec::new();
339 for target in &facts.extend_targets {
340 if target.optional {
342 continue;
343 }
344 let (resolved, label) = match target.kind {
345 ParsedExtendTargetFactKind::Placeholder => (
346 declared_placeholders.contains(&target.name),
347 format!("%{}", target.name),
348 ),
349 ParsedExtendTargetFactKind::Class => (
350 declared_classes.contains(&target.name),
351 format!(".{}", target.name),
352 ),
353 };
354 if resolved {
355 continue;
356 }
357 let start: u32 = target.range.start().into();
358 let end: u32 = target.range.end().into();
359 let byte_span = ParserByteSpanV0 {
360 start: start as usize,
361 end: end as usize,
362 };
363 if !emitted.insert((byte_span.start, byte_span.end)) {
364 continue;
365 }
366 diagnostics.push(OmenaQueryStyleDiagnosticV0 {
367 code: "missingExtendTarget",
368 severity: "error",
369 provenance: vec![
370 "omena-parser.extend-target-facts",
371 "omena-query.missing-extend-target-diagnostics",
372 ],
373 range: parser_range_for_byte_span(source, byte_span),
374 message: format!(
375 "@extend target '{label}' does not exist in this file. dart-sass rejects this as a hard error."
376 ),
377 tags: Vec::new(),
378 create_custom_property: None,
379 });
380 }
381
382 diagnostics
383}
384
385fn summarize_omena_query_missing_extend_target_diagnostics_for_workspace(
393 target_style_path: &str,
394 style_sources: &[OmenaQueryStyleSourceInputV0],
395) -> Vec<OmenaQueryStyleDiagnosticV0> {
396 let Some(target) = style_sources
397 .iter()
398 .find(|source| source.style_path == target_style_path)
399 else {
400 return Vec::new();
401 };
402 let dialect = omena_parser_dialect_for_style_path(target_style_path);
403 if !matches!(
404 dialect,
405 OmenaParserStyleDialect::Scss | OmenaParserStyleDialect::Sass
406 ) {
407 return Vec::new();
408 }
409
410 let target_facts =
411 collect_omena_query_omena_parser_style_facts_raw(target.style_source.as_str(), dialect);
412 if target_facts.extend_targets.is_empty() {
413 return Vec::new();
414 }
415
416 let style_source_refs = style_sources
420 .iter()
421 .map(|source| (source.style_path.as_str(), source.style_source.as_str()))
422 .collect::<Vec<_>>();
423 let style_fact_entries = collect_omena_query_style_fact_entries(style_source_refs.as_slice());
424 let resolution = summarize_sass_module_cross_file_resolution(&style_fact_entries, &[]);
425 let reachable_paths =
426 collect_sass_module_graph_reachable_style_paths(target_style_path, &resolution);
427
428 let mut declared_placeholders = BTreeSet::new();
432 let mut declared_classes = BTreeSet::new();
433 for source in style_sources {
434 if !reachable_paths.contains(source.style_path.as_str()) {
435 continue;
436 }
437 let facts = collect_omena_query_omena_parser_style_facts_raw(
438 source.style_source.as_str(),
439 omena_parser_dialect_for_style_path(source.style_path.as_str()),
440 );
441 for selector in facts.selectors {
442 match selector.kind {
443 ParsedSelectorFactKind::Placeholder => {
444 declared_placeholders.insert(selector.name);
445 }
446 ParsedSelectorFactKind::Class => {
447 declared_classes.insert(selector.name);
448 }
449 ParsedSelectorFactKind::Id => {}
450 }
451 }
452 }
453
454 let mut emitted = BTreeSet::new();
455 let mut diagnostics = Vec::new();
456 for extend_target in &target_facts.extend_targets {
457 if extend_target.optional {
458 continue;
459 }
460 let (resolved, label) = match extend_target.kind {
461 ParsedExtendTargetFactKind::Placeholder => (
462 declared_placeholders.contains(&extend_target.name),
463 format!("%{}", extend_target.name),
464 ),
465 ParsedExtendTargetFactKind::Class => (
466 declared_classes.contains(&extend_target.name),
467 format!(".{}", extend_target.name),
468 ),
469 };
470 if resolved {
471 continue;
472 }
473 let start: u32 = extend_target.range.start().into();
474 let end: u32 = extend_target.range.end().into();
475 let byte_span = ParserByteSpanV0 {
476 start: start as usize,
477 end: end as usize,
478 };
479 if !emitted.insert((byte_span.start, byte_span.end)) {
480 continue;
481 }
482 diagnostics.push(OmenaQueryStyleDiagnosticV0 {
483 code: "missingExtendTarget",
484 severity: "error",
485 provenance: vec![
486 "omena-parser.extend-target-facts",
487 "omena-query.missing-extend-target-diagnostics",
488 ],
489 range: parser_range_for_byte_span(target.style_source.as_str(), byte_span),
490 message: format!(
491 "@extend target '{label}' does not exist in the visible Sass module graph. dart-sass rejects this as a hard error."
492 ),
493 tags: Vec::new(),
494 create_custom_property: None,
495 });
496 }
497
498 diagnostics
499}
500
501fn collect_sass_module_graph_reachable_style_paths<'a>(
507 target_style_path: &'a str,
508 resolution: &'a OmenaQuerySassModuleCrossFileResolutionV0,
509) -> BTreeSet<&'a str> {
510 let mut reachable = BTreeSet::new();
511 let mut stack = vec![target_style_path];
512 while let Some(current) = stack.pop() {
513 if !reachable.insert(current) {
514 continue;
515 }
516 for edge in resolution
517 .edges
518 .iter()
519 .filter(|edge| edge.from_style_path == current && edge.status == "resolved")
520 {
521 if let Some(next) = edge.resolved_style_path.as_deref() {
522 stack.push(next);
523 }
524 }
525 }
526 reachable
527}
528
529fn summarize_omena_query_replica_ensemble_inconsistency_diagnostics_for_workspace(
553 target_style_path: &str,
554 style_sources: &[OmenaQueryStyleSourceInputV0],
555 package_manifests: &[OmenaQueryStylePackageManifestV0],
556) -> Vec<OmenaQueryStyleDiagnosticV0> {
557 let Some(target) = style_sources
558 .iter()
559 .find(|source| source.style_path == target_style_path)
560 else {
561 return Vec::new();
562 };
563
564 let style_source_refs = style_sources
565 .iter()
566 .map(|source| (source.style_path.as_str(), source.style_source.as_str()))
567 .collect::<Vec<_>>();
568 let style_fact_entries = collect_omena_query_style_fact_entries(style_source_refs.as_slice());
569 let resolution =
570 summarize_sass_module_cross_file_resolution(&style_fact_entries, package_manifests);
571 let reachable_paths =
572 collect_sass_module_graph_reachable_style_paths(target_style_path, &resolution);
573
574 if reachable_paths.len() < 2 {
577 return Vec::new();
578 }
579
580 let replicas = style_sources
584 .iter()
585 .filter(|source| reachable_paths.contains(source.style_path.as_str()))
586 .filter_map(|source| {
587 let sites = collect_query_replica_ensemble_site_outcomes(source.style_source.as_str());
588 if sites.is_empty() {
589 return None;
590 }
591 Some(ReplicaSnapshotV0 {
592 schema_version: REPLICA_ENSEMBLE_SCHEMA_VERSION_V0,
593 product: "omena-ensemble.replica-snapshot",
594 layer_marker: REPLICA_ENSEMBLE_LAYER_MARKER_V0,
595 feature_gate: REPLICA_ENSEMBLE_FEATURE_GATE_V0,
596 path: source.style_path.clone(),
597 sites,
598 })
599 })
600 .collect::<Vec<_>>();
601
602 if replicas.len() < 2 {
604 return Vec::new();
605 }
606
607 let module_graph = replica_ensemble_module_graph_from_resolution(
608 target_style_path,
609 &resolution,
610 &reachable_paths,
611 &replicas,
612 );
613 let report = build_cross_file_inconsistency_report(
614 target_style_path,
615 replicas.clone(),
616 &module_graph,
617 OutcomeMode::DefiniteOnly,
618 ReportOptionsV0::default(),
619 None,
620 );
621
622 let genuine_disagreement_pair_count = report
627 .top_disagreement_pairs
628 .iter()
629 .filter(|pair| pair.shared_site_count > 0 && pair.overlap_q < 1.0)
630 .count();
631 let recommendation = replica_ensemble_recommendation_name(report.recommendation);
632
633 let gate =
634 run_omena_query_checker_replica_ensemble_gate_v0(OmenaCheckerReplicaEnsembleInputV0 {
635 reports: vec![OmenaCheckerReplicaEnsembleReportInputV0 {
636 workspace_root: target_style_path.to_string(),
637 recommendation: recommendation.to_string(),
638 mean_q: report.distribution.mean_q,
639 variance_q: report.distribution.variance_q,
640 top_disagreement_pair_count: genuine_disagreement_pair_count,
641 }],
642 });
643 if !gate.enforcement_passed {
644 return Vec::new();
645 }
646
647 let whole_file_range = parser_range_for_byte_span(
648 target.style_source.as_str(),
649 ParserByteSpanV0 {
650 start: 0,
651 end: target.style_source.len(),
652 },
653 );
654
655 gate.evaluations
656 .into_iter()
657 .map(|evaluation| {
658 let mut provenance = vec![
659 "omena-query-checker-orchestrator.replica-ensemble-gate",
660 "omena-checker.replica-ensemble-rules",
661 "omena-ensemble.cross-file-inconsistency-report",
662 "omena-query.cross-file-replica-ensemble",
663 ];
664 provenance.extend(evaluation.mechanism_products.iter().copied());
665 OmenaQueryStyleDiagnosticV0 {
666 code: "replicaEnsembleInconsistency",
667 severity: "hint",
668 provenance,
669 range: whole_file_range,
670 message: evaluation.message,
671 tags: Vec::new(),
672 create_custom_property: None,
673 }
674 })
675 .collect()
676}
677
678fn replica_ensemble_module_graph_from_resolution(
684 workspace_root: &str,
685 resolution: &OmenaQuerySassModuleCrossFileResolutionV0,
686 reachable_paths: &BTreeSet<&str>,
687 replicas: &[ReplicaSnapshotV0],
688) -> ModuleGraphV0 {
689 let nodes = replicas
690 .iter()
691 .map(|replica| replica.path.clone())
692 .collect::<Vec<_>>();
693 let node_set = nodes.iter().map(String::as_str).collect::<BTreeSet<_>>();
694
695 let edges = resolution
696 .edges
697 .iter()
698 .filter(|edge| edge.status == "resolved")
699 .filter(|edge| reachable_paths.contains(edge.from_style_path.as_str()))
700 .filter_map(|edge| {
701 let to = edge.resolved_style_path.as_deref()?;
702 if node_set.contains(edge.from_style_path.as_str()) && node_set.contains(to) {
703 Some(ModuleGraphEdgeV0 {
704 schema_version: REPLICA_ENSEMBLE_SCHEMA_VERSION_V0,
705 product: "omena-ensemble.module-graph-edge",
706 layer_marker: REPLICA_ENSEMBLE_LAYER_MARKER_V0,
707 feature_gate: REPLICA_ENSEMBLE_FEATURE_GATE_V0,
708 from_module: edge.from_style_path.clone(),
709 to_module: to.to_string(),
710 edge_kind: "resolvedModuleEdge",
711 })
712 } else {
713 None
714 }
715 })
716 .collect::<Vec<_>>();
717
718 ModuleGraphV0 {
719 schema_version: REPLICA_ENSEMBLE_SCHEMA_VERSION_V0,
720 product: "omena-ensemble.module-graph",
721 layer_marker: REPLICA_ENSEMBLE_LAYER_MARKER_V0,
722 feature_gate: REPLICA_ENSEMBLE_FEATURE_GATE_V0,
723 workspace_root: workspace_root.to_string(),
724 nodes,
725 edges,
726 }
727}
728
729fn replica_ensemble_recommendation_name(recommendation: ReportRecommendation) -> &'static str {
730 match recommendation {
731 ReportRecommendation::NoActionNeeded => "noActionNeeded",
732 ReportRecommendation::InvestigateRsbBroken => "investigateRsbBroken",
733 ReportRecommendation::UndetectablePhase => "undetectablePhase",
734 }
735}
736
737pub fn summarize_omena_query_sass_import_deprecation_hints(
738 style_uri: &str,
739 source: &str,
740) -> Vec<OmenaQueryStyleDiagnosticV0> {
741 let dialect = omena_parser_dialect_for_style_path(style_uri);
742 if !matches!(
743 dialect,
744 OmenaParserStyleDialect::Scss | OmenaParserStyleDialect::Sass
745 ) {
746 return Vec::new();
747 }
748
749 let facts = collect_omena_query_omena_parser_style_facts_raw(source, dialect);
750 facts
751 .sass_module_edges
752 .into_iter()
753 .filter(|edge| edge.kind == ParsedSassModuleEdgeFactKind::Import)
754 .filter(|edge| !edge.media_qualified && !sass_import_is_plain_css(edge.source.as_str()))
760 .map(|edge| {
761 let start: u32 = edge.range.start().into();
762 let end: u32 = edge.range.end().into();
763 OmenaQueryStyleDiagnosticV0 {
764 code: "deprecatedSassImport",
765 severity: "information",
766 provenance: vec![
767 "omena-parser.sass-module-edges",
768 "omena-query.sass-import-deprecation-hints",
769 ],
770 range: parser_range_for_byte_span(
771 source,
772 ParserByteSpanV0 {
773 start: start as usize,
774 end: end as usize,
775 },
776 ),
777 message: "Sass @import is deprecated; prefer @use or @forward.".to_string(),
778 tags: vec![LSP_DIAGNOSTIC_TAG_DEPRECATED],
779 create_custom_property: None,
780 }
781 })
782 .collect()
783}
784
785fn sass_import_is_plain_css(source: &str) -> bool {
804 let trimmed = source.trim();
805 let lower = trimmed.to_ascii_lowercase();
806 if lower.starts_with("url(") {
808 return true;
809 }
810 if lower.starts_with("//") || lower.contains("://") {
813 return true;
814 }
815 if lower.ends_with(".css") {
817 return true;
818 }
819 false
820}
821
822pub fn summarize_omena_query_missing_sass_symbol_diagnostics_for_workspace(
823 target_style_path: &str,
824 style_sources: &[OmenaQueryStyleSourceInputV0],
825 package_manifests: &[OmenaQueryStylePackageManifestV0],
826) -> Vec<OmenaQueryStyleDiagnosticV0> {
827 summarize_omena_query_missing_sass_symbol_diagnostics_for_workspace_with_sifs(
828 target_style_path,
829 style_sources,
830 package_manifests,
831 &[],
832 )
833}
834
835fn summarize_omena_query_missing_sass_symbol_diagnostics_for_workspace_with_sifs(
836 target_style_path: &str,
837 style_sources: &[OmenaQueryStyleSourceInputV0],
838 package_manifests: &[OmenaQueryStylePackageManifestV0],
839 external_sifs: &[OmenaQueryExternalSifInputV0],
840) -> Vec<OmenaQueryStyleDiagnosticV0> {
841 let Some(target) = style_sources
842 .iter()
843 .find(|source| source.style_path == target_style_path)
844 else {
845 return Vec::new();
846 };
847 let style_source_refs = style_sources
848 .iter()
849 .map(|source| (source.style_path.as_str(), source.style_source.as_str()))
850 .collect::<Vec<_>>();
851 let style_fact_entries = collect_omena_query_style_fact_entries(style_source_refs.as_slice());
852 let facts_by_path = style_fact_entries
853 .iter()
854 .map(|entry| (entry.style_path.as_str(), &entry.facts))
855 .collect::<BTreeMap<_, _>>();
856 let resolution =
857 summarize_sass_module_cross_file_resolution(&style_fact_entries, package_manifests);
858 let visible_symbols = collect_visible_sass_symbol_keys(
859 target_style_path,
860 &facts_by_path,
861 &resolution,
862 external_sifs,
863 );
864 let facts = collect_omena_query_omena_parser_style_facts_raw(
865 target.style_source.as_str(),
866 omena_parser_dialect_for_style_path(target_style_path),
867 );
868 let mut emitted = BTreeSet::new();
869 let mut diagnostics = Vec::new();
870
871 for symbol in facts.sass_symbols {
872 if !omena_query_sass_symbol_fact_kind_is_reference(symbol.kind) {
873 continue;
874 }
875 let key = sass_symbol_key(
876 symbol.symbol_kind,
877 symbol.namespace.clone(),
878 symbol.name.clone(),
879 );
880 if visible_symbols.contains(&key) {
881 continue;
882 }
883 if is_omena_query_sass_builtin_symbol_reference_resolved(
884 &facts.sass_module_edges,
885 symbol.symbol_kind,
886 symbol.namespace.as_deref(),
887 symbol.name.as_str(),
888 ) {
889 continue;
890 }
891
892 let start: u32 = symbol.range.start().into();
893 let end: u32 = symbol.range.end().into();
894 let byte_span = ParserByteSpanV0 {
895 start: start as usize,
896 end: end as usize,
897 };
898 if !emitted.insert((
899 symbol.symbol_kind,
900 symbol.namespace.clone(),
901 symbol.name.clone(),
902 byte_span.start,
903 byte_span.end,
904 )) {
905 continue;
906 }
907 diagnostics.push(OmenaQueryStyleDiagnosticV0 {
908 code: "missingSassSymbol",
909 severity: "warning",
910 provenance: vec![
911 "omena-parser.sass-symbol-facts",
912 "omena-query.graph-aware-sass-diagnostics",
913 ],
914 range: parser_range_for_byte_span(target.style_source.as_str(), byte_span),
915 message: format!(
916 "{} not found in the visible Sass module graph.",
917 format_query_sass_symbol_label(symbol.symbol_kind, symbol.name.as_str())
918 ),
919 tags: Vec::new(),
920 create_custom_property: None,
921 });
922 }
923
924 diagnostics
925}
926
927fn summarize_omena_query_sass_use_cycle_diagnostics_for_workspace(
943 target_style_path: &str,
944 style_sources: &[OmenaQueryStyleSourceInputV0],
945 package_manifests: &[OmenaQueryStylePackageManifestV0],
946) -> Vec<OmenaQueryStyleDiagnosticV0> {
947 let Some(target) = style_sources
948 .iter()
949 .find(|source| source.style_path == target_style_path)
950 else {
951 return Vec::new();
952 };
953 let style_source_refs = style_sources
954 .iter()
955 .map(|source| (source.style_path.as_str(), source.style_source.as_str()))
956 .collect::<Vec<_>>();
957 let style_fact_entries = collect_omena_query_style_fact_entries(style_source_refs.as_slice());
958 let resolution =
959 summarize_sass_module_cross_file_resolution(&style_fact_entries, package_manifests);
960 if resolution.cycles.is_empty() {
961 return Vec::new();
962 }
963
964 let target_facts = collect_omena_query_omena_parser_style_facts_raw(
968 target.style_source.as_str(),
969 omena_parser_dialect_for_style_path(target_style_path),
970 );
971
972 let mut emitted = BTreeSet::new();
973 let mut diagnostics = Vec::new();
974
975 for cycle in &resolution.cycles {
976 if !cycle.path.iter().any(|node| node == target_style_path) {
978 continue;
979 }
980 let canonical_cycle = canonical_sass_module_cycle(&cycle.path);
985 let Some(next_module) = cycle
988 .path
989 .windows(2)
990 .find(|window| window[0] == target_style_path)
991 .map(|window| window[1].clone())
992 else {
993 continue;
994 };
995 let Some(loop_edge) = resolution.edges.iter().find(|edge| {
997 edge.from_style_path == target_style_path
998 && edge.resolved_style_path.as_deref() == Some(next_module.as_str())
999 }) else {
1000 continue;
1001 };
1002 let Some(fact) = target_facts.sass_module_edges.iter().find(|fact| {
1004 fact.source == loop_edge.source
1005 && parsed_sass_module_edge_fact_kind_matches(fact.kind, loop_edge.edge_kind)
1006 }) else {
1007 continue;
1008 };
1009 let start: u32 = fact.range.start().into();
1010 let end: u32 = fact.range.end().into();
1011 let byte_span = ParserByteSpanV0 {
1012 start: start as usize,
1013 end: end as usize,
1014 };
1015 if !emitted.insert((byte_span.start, byte_span.end, canonical_cycle.clone())) {
1016 continue;
1017 }
1018 diagnostics.push(OmenaQueryStyleDiagnosticV0 {
1019 code: "sassUseCycle",
1020 severity: "error",
1021 provenance: vec![
1022 "omena-query.sass-module-cross-file-resolution",
1023 "omena-query.sass-use-cycle-diagnostics",
1024 ],
1025 range: parser_range_for_byte_span(target.style_source.as_str(), byte_span),
1026 message: format!(
1027 "Sass module loop: {}. dart-sass rejects this as a hard error.",
1028 render_sass_module_cycle_from(&canonical_cycle, target_style_path)
1029 ),
1030 tags: Vec::new(),
1031 create_custom_property: None,
1032 });
1033 }
1034
1035 diagnostics
1036}
1037
1038fn summarize_omena_query_unresolved_sass_import_diagnostics_for_workspace(
1061 target_style_path: &str,
1062 style_sources: &[OmenaQueryStyleSourceInputV0],
1063 package_manifests: &[OmenaQueryStylePackageManifestV0],
1064) -> Vec<OmenaQueryStyleDiagnosticV0> {
1065 let Some(target) = style_sources
1066 .iter()
1067 .find(|source| source.style_path == target_style_path)
1068 else {
1069 return Vec::new();
1070 };
1071 let style_source_refs = style_sources
1072 .iter()
1073 .map(|source| (source.style_path.as_str(), source.style_source.as_str()))
1074 .collect::<Vec<_>>();
1075 let style_fact_entries = collect_omena_query_style_fact_entries(style_source_refs.as_slice());
1076 let resolution =
1077 summarize_sass_module_cross_file_resolution(&style_fact_entries, package_manifests);
1078
1079 let target_facts = collect_omena_query_omena_parser_style_facts_raw(
1080 target.style_source.as_str(),
1081 omena_parser_dialect_for_style_path(target_style_path),
1082 );
1083
1084 let mut emitted = BTreeSet::new();
1085 let mut diagnostics = Vec::new();
1086
1087 for edge in resolution.edges.iter().filter(|edge| {
1088 edge.from_style_path == target_style_path
1089 && edge.status == "unresolved"
1090 && sass_module_source_is_workspace_local(edge.source.as_str())
1091 }) {
1092 let Some(fact) = target_facts.sass_module_edges.iter().find(|fact| {
1093 fact.source == edge.source
1094 && parsed_sass_module_edge_fact_kind_matches(fact.kind, edge.edge_kind)
1095 }) else {
1096 continue;
1097 };
1098 let start: u32 = fact.range.start().into();
1099 let end: u32 = fact.range.end().into();
1100 let byte_span = ParserByteSpanV0 {
1101 start: start as usize,
1102 end: end as usize,
1103 };
1104 if !emitted.insert((byte_span.start, byte_span.end)) {
1105 continue;
1106 }
1107 diagnostics.push(OmenaQueryStyleDiagnosticV0 {
1108 code: "missingModule",
1109 severity: "error",
1110 provenance: vec![
1111 "omena-query.sass-module-cross-file-resolution",
1112 "omena-query.unresolved-sass-import-diagnostics",
1113 ],
1114 range: parser_range_for_byte_span(target.style_source.as_str(), byte_span),
1115 message: format!(
1116 "Cannot resolve Sass module '{}'. dart-sass rejects this as a hard error.",
1117 edge.source
1118 ),
1119 tags: Vec::new(),
1120 create_custom_property: None,
1121 });
1122 }
1123
1124 diagnostics
1125}
1126
1127fn sass_module_source_is_workspace_local(source: &str) -> bool {
1132 let trimmed = source.trim();
1133 trimmed.starts_with("./") || trimmed.starts_with("../") || trimmed.starts_with('/')
1134}
1135
1136fn parsed_sass_module_edge_fact_kind_matches(
1137 fact_kind: ParsedSassModuleEdgeFactKind,
1138 edge_kind: &str,
1139) -> bool {
1140 matches!(
1141 (fact_kind, edge_kind),
1142 (ParsedSassModuleEdgeFactKind::Use, "sassUse")
1143 | (ParsedSassModuleEdgeFactKind::Forward, "sassForward")
1144 | (ParsedSassModuleEdgeFactKind::Import, "sassImport")
1145 )
1146}
1147
1148fn canonical_sass_module_cycle(path: &[String]) -> Vec<String> {
1154 let ring: &[String] = match path.split_last() {
1155 Some((last, head)) if Some(last) == path.first() && !head.is_empty() => head,
1156 _ => path,
1157 };
1158 if ring.is_empty() {
1159 return path.to_vec();
1160 }
1161 let len = ring.len();
1162 (0..len)
1163 .map(|offset| {
1164 (0..len)
1165 .map(|index| ring[(offset + index) % len].clone())
1166 .collect::<Vec<_>>()
1167 })
1168 .min()
1169 .unwrap_or_else(|| ring.to_vec())
1170}
1171
1172fn render_sass_module_cycle_from(canonical_cycle: &[String], start: &str) -> String {
1176 let len = canonical_cycle.len();
1177 let begin = canonical_cycle
1178 .iter()
1179 .position(|node| node == start)
1180 .unwrap_or(0);
1181 let mut ordered = (0..len)
1182 .map(|index| canonical_cycle[(begin + index) % len].clone())
1183 .collect::<Vec<_>>();
1184 ordered.push(canonical_cycle[begin].clone());
1186 ordered.join(" -> ")
1187}
1188
1189pub fn summarize_omena_query_style_diagnostics_for_file(
1190 style_uri: &str,
1191 source: &str,
1192 candidates: &[OmenaQueryStyleHoverCandidateV0],
1193) -> OmenaQueryStyleDiagnosticsForFileV0 {
1194 summarize_omena_query_style_diagnostics_for_file_with_deep_analysis(
1195 style_uri, source, candidates, false,
1196 )
1197}
1198
1199pub fn summarize_omena_query_style_diagnostics_for_file_with_deep_analysis(
1204 style_uri: &str,
1205 source: &str,
1206 candidates: &[OmenaQueryStyleHoverCandidateV0],
1207 deep_analysis: bool,
1208) -> OmenaQueryStyleDiagnosticsForFileV0 {
1209 let mut diagnostics =
1210 summarize_omena_query_missing_custom_property_diagnostics(style_uri, source, candidates);
1211 diagnostics.extend(
1212 summarize_omena_query_cascade_aware_style_diagnostics_with_deep_analysis(
1213 style_uri,
1214 source,
1215 candidates,
1216 deep_analysis,
1217 ),
1218 );
1219 diagnostics.extend(summarize_omena_query_missing_keyframes_diagnostics(
1220 style_uri, source,
1221 ));
1222 diagnostics.extend(summarize_omena_query_sass_import_deprecation_hints(
1223 style_uri, source,
1224 ));
1225 diagnostics.extend(summarize_omena_query_missing_sass_symbol_diagnostics(
1226 style_uri, source,
1227 ));
1228 diagnostics.extend(summarize_omena_query_missing_extend_target_diagnostics(
1229 style_uri, source,
1230 ));
1231 apply_omena_query_checker_product_gate_to_style_diagnostics(&mut diagnostics);
1232 let mut summary = OmenaQueryStyleDiagnosticsForFileV0 {
1233 schema_version: "0",
1234 product: "omena-query.diagnostics-for-file",
1235 file_uri: style_uri.to_string(),
1236 file_kind: "style",
1237 diagnostic_count: diagnostics.len(),
1238 diagnostics,
1239 ready_surfaces: vec![
1240 "missingCustomPropertyDiagnostics",
1241 "cascadeAwareDiagnostics",
1242 "missingKeyframesDiagnostics",
1243 "sassImportDeprecationHints",
1244 "missingSassSymbolDiagnostics",
1245 "missingExtendTargetDiagnostics",
1246 "checkerProductDiagnosticGate",
1247 ],
1248 };
1249 apply_omena_query_style_diagnostic_suppressions(source, &mut summary);
1250 summary
1251}
1252
1253pub fn summarize_omena_query_style_diagnostics_for_file_with_local_composes(
1261 style_uri: &str,
1262 source: &str,
1263 candidates: &[OmenaQueryStyleHoverCandidateV0],
1264) -> OmenaQueryStyleDiagnosticsForFileV0 {
1265 summarize_omena_query_style_diagnostics_for_file_with_local_composes_and_deep_analysis(
1266 style_uri, source, candidates, false,
1267 )
1268}
1269
1270pub fn summarize_omena_query_style_diagnostics_for_file_with_local_composes_and_deep_analysis(
1275 style_uri: &str,
1276 source: &str,
1277 candidates: &[OmenaQueryStyleHoverCandidateV0],
1278 deep_analysis: bool,
1279) -> OmenaQueryStyleDiagnosticsForFileV0 {
1280 let mut summary = summarize_omena_query_style_diagnostics_for_file_with_deep_analysis(
1281 style_uri,
1282 source,
1283 candidates,
1284 deep_analysis,
1285 );
1286 let mut local_composes =
1287 summarize_omena_query_css_modules_local_composes_style_diagnostics(style_uri, source);
1288 apply_omena_query_checker_product_gate_to_style_diagnostics(&mut local_composes);
1289 if !local_composes.is_empty() {
1290 summary.diagnostics.extend(local_composes);
1291 push_omena_query_ready_surface(
1292 &mut summary.ready_surfaces,
1293 "cssModulesComposesResolutionDiagnostics",
1294 );
1295 apply_omena_query_style_diagnostic_suppressions(source, &mut summary);
1297 summary.diagnostic_count = summary.diagnostics.len();
1298 }
1299 summary
1300}
1301
1302pub fn summarize_omena_query_style_diagnostics_for_workspace_file(
1303 target_style_path: &str,
1304 style_sources: &[OmenaQueryStyleSourceInputV0],
1305 source_documents: &[OmenaQuerySourceDocumentInputV0],
1306 package_manifests: &[OmenaQueryStylePackageManifestV0],
1307 classname_transform: Option<&str>,
1308) -> Option<OmenaQueryStyleDiagnosticsForFileV0> {
1309 summarize_omena_query_style_diagnostics_for_workspace_file_with_external_mode(
1310 target_style_path,
1311 style_sources,
1312 source_documents,
1313 package_manifests,
1314 classname_transform,
1315 OmenaQueryExternalModuleModeV0::Ignored,
1316 )
1317}
1318
1319pub fn summarize_omena_query_style_diagnostics_for_workspace_file_with_external_mode(
1320 target_style_path: &str,
1321 style_sources: &[OmenaQueryStyleSourceInputV0],
1322 source_documents: &[OmenaQuerySourceDocumentInputV0],
1323 package_manifests: &[OmenaQueryStylePackageManifestV0],
1324 classname_transform: Option<&str>,
1325 external_mode: OmenaQueryExternalModuleModeV0,
1326) -> Option<OmenaQueryStyleDiagnosticsForFileV0> {
1327 summarize_omena_query_style_diagnostics_for_workspace_file_with_external_mode_and_sifs(
1328 target_style_path,
1329 style_sources,
1330 source_documents,
1331 package_manifests,
1332 classname_transform,
1333 external_mode,
1334 &[],
1335 )
1336}
1337
1338pub fn summarize_omena_query_style_diagnostics_for_workspace_file_with_external_mode_and_sifs(
1339 target_style_path: &str,
1340 style_sources: &[OmenaQueryStyleSourceInputV0],
1341 source_documents: &[OmenaQuerySourceDocumentInputV0],
1342 package_manifests: &[OmenaQueryStylePackageManifestV0],
1343 classname_transform: Option<&str>,
1344 external_mode: OmenaQueryExternalModuleModeV0,
1345 external_sifs: &[OmenaQueryExternalSifInputV0],
1346) -> Option<OmenaQueryStyleDiagnosticsForFileV0> {
1347 summarize_omena_query_style_diagnostics_for_workspace_file_with_external_mode_and_sifs_and_resolution_inputs(
1348 target_style_path,
1349 style_sources,
1350 source_documents,
1351 package_manifests,
1352 classname_transform,
1353 external_mode,
1354 external_sifs,
1355 &OmenaQueryStyleResolutionInputsV0 {
1356 package_manifests: package_manifests.to_vec(),
1357 ..Default::default()
1358 },
1359 )
1360}
1361
1362#[allow(clippy::too_many_arguments)]
1369pub fn summarize_omena_query_style_diagnostics_for_workspace_file_with_external_mode_and_sifs_and_resolution_inputs(
1370 target_style_path: &str,
1371 style_sources: &[OmenaQueryStyleSourceInputV0],
1372 source_documents: &[OmenaQuerySourceDocumentInputV0],
1373 package_manifests: &[OmenaQueryStylePackageManifestV0],
1374 classname_transform: Option<&str>,
1375 external_mode: OmenaQueryExternalModuleModeV0,
1376 external_sifs: &[OmenaQueryExternalSifInputV0],
1377 resolution_inputs: &OmenaQueryStyleResolutionInputsV0,
1378) -> Option<OmenaQueryStyleDiagnosticsForFileV0> {
1379 let target = style_sources
1380 .iter()
1381 .find(|source| source.style_path == target_style_path)?;
1382 let candidates =
1383 summarize_omena_query_style_hover_candidates(target_style_path, &target.style_source)?;
1384 let mut summary = summarize_omena_query_style_diagnostics_for_file(
1385 target_style_path,
1386 &target.style_source,
1387 candidates.candidates.as_slice(),
1388 );
1389 summary
1390 .diagnostics
1391 .retain(|diagnostic| diagnostic.code != "missingSassSymbol");
1392 summary
1400 .diagnostics
1401 .retain(|diagnostic| diagnostic.code != "missingExtendTarget");
1402 summary.diagnostics.extend(
1403 summarize_omena_query_missing_extend_target_diagnostics_for_workspace(
1404 target_style_path,
1405 style_sources,
1406 ),
1407 );
1408 summary.diagnostics.extend(
1409 summarize_omena_query_missing_sass_symbol_diagnostics_for_workspace_with_sifs(
1410 target_style_path,
1411 style_sources,
1412 package_manifests,
1413 external_sifs,
1414 ),
1415 );
1416 summary.diagnostics.extend(
1417 summarize_omena_query_css_modules_resolution_style_diagnostics(
1418 target_style_path,
1419 &target.style_source,
1420 style_sources,
1421 package_manifests,
1422 ),
1423 );
1424 summary.diagnostics.extend(
1425 summarize_omena_query_sass_use_cycle_diagnostics_for_workspace(
1426 target_style_path,
1427 style_sources,
1428 package_manifests,
1429 ),
1430 );
1431 summary.diagnostics.extend(
1432 summarize_omena_query_unresolved_sass_import_diagnostics_for_workspace(
1433 target_style_path,
1434 style_sources,
1435 package_manifests,
1436 ),
1437 );
1438 summary.diagnostics.extend(
1439 summarize_omena_query_unused_selector_style_diagnostics_with_path_mappings(
1440 target_style_path,
1441 &target.style_source,
1442 style_sources,
1443 source_documents,
1444 package_manifests,
1445 classname_transform,
1446 resolution_inputs.bundler_path_mappings.as_slice(),
1447 resolution_inputs.tsconfig_path_mappings.as_slice(),
1448 ),
1449 );
1450 summary.diagnostics.extend(
1451 summarize_omena_query_replica_ensemble_inconsistency_diagnostics_for_workspace(
1452 target_style_path,
1453 style_sources,
1454 package_manifests,
1455 ),
1456 );
1457 summary.diagnostic_count = summary.diagnostics.len();
1458 push_omena_query_ready_surface(
1459 &mut summary.ready_surfaces,
1460 "cssModulesComposesResolutionDiagnostics",
1461 );
1462 push_omena_query_ready_surface(
1463 &mut summary.ready_surfaces,
1464 "cssModulesValueResolutionDiagnostics",
1465 );
1466 push_omena_query_ready_surface(&mut summary.ready_surfaces, "unusedSelectorDiagnostics");
1467 push_omena_query_ready_surface(&mut summary.ready_surfaces, "sassUseCycleDiagnostics");
1468 push_omena_query_ready_surface(
1469 &mut summary.ready_surfaces,
1470 "unresolvedSassImportDiagnostics",
1471 );
1472 push_omena_query_ready_surface(
1473 &mut summary.ready_surfaces,
1474 "missingExtendTargetDiagnostics",
1475 );
1476 push_omena_query_ready_surface(
1477 &mut summary.ready_surfaces,
1478 "graphAwareSassSymbolDiagnostics",
1479 );
1480 push_omena_query_ready_surface(
1481 &mut summary.ready_surfaces,
1482 "crossFileReplicaEnsembleDiagnostics",
1483 );
1484 if external_mode == OmenaQueryExternalModuleModeV0::Sif {
1485 let strictness = parse_omena_query_style_strictness_level(&target.style_source);
1489 let top_any_external_symbol_ranges =
1490 collect_omena_query_external_top_any_sass_symbol_ranges(
1491 target_style_path,
1492 style_sources,
1493 package_manifests,
1494 external_sifs,
1495 );
1496 if strictness.suppresses_top_any_external_symbols() {
1497 summary.diagnostics.retain(|diagnostic| {
1498 diagnostic.code != "missingSassSymbol"
1499 || !top_any_external_symbol_ranges.contains(&diagnostic.range)
1500 });
1501 } else {
1502 for diagnostic in summary.diagnostics.iter_mut() {
1505 if diagnostic.code == "missingSassSymbol"
1506 && top_any_external_symbol_ranges.contains(&diagnostic.range)
1507 {
1508 diagnostic.severity = "error";
1509 }
1510 }
1511 }
1512 if strictness.emits_external_boundary_diagnostics() {
1513 summary
1514 .diagnostics
1515 .extend(summarize_omena_query_external_sif_boundary_diagnostics(
1516 target_style_path,
1517 style_sources,
1518 package_manifests,
1519 external_sifs,
1520 strictness,
1521 ));
1522 }
1523 push_omena_query_ready_surface(
1524 &mut summary.ready_surfaces,
1525 "externalSifBoundaryDiagnostics",
1526 );
1527 push_omena_query_ready_surface(&mut summary.ready_surfaces, "strictnessSigilGating");
1528 }
1529 apply_omena_query_checker_product_gate_to_style_diagnostics(&mut summary.diagnostics);
1530 push_omena_query_ready_surface(&mut summary.ready_surfaces, "checkerProductDiagnosticGate");
1531 apply_omena_query_style_diagnostic_suppressions(&target.style_source, &mut summary);
1532 summary.diagnostic_count = summary.diagnostics.len();
1533 Some(summary)
1534}
1535
1536fn collect_omena_query_external_top_any_sass_symbol_ranges(
1537 target_style_path: &str,
1538 style_sources: &[OmenaQueryStyleSourceInputV0],
1539 package_manifests: &[OmenaQueryStylePackageManifestV0],
1540 external_sifs: &[OmenaQueryExternalSifInputV0],
1541) -> BTreeSet<ParserRangeV0> {
1542 let Some(target) = style_sources
1543 .iter()
1544 .find(|source| source.style_path == target_style_path)
1545 else {
1546 return BTreeSet::new();
1547 };
1548 let style_source_refs = style_sources
1549 .iter()
1550 .map(|source| (source.style_path.as_str(), source.style_source.as_str()))
1551 .collect::<Vec<_>>();
1552 let style_fact_entries = collect_omena_query_style_fact_entries(style_source_refs.as_slice());
1553 let resolution =
1554 summarize_sass_module_cross_file_resolution(&style_fact_entries, package_manifests);
1555 let external_sources = resolution
1556 .edges
1557 .iter()
1558 .filter(|edge| edge.from_style_path == target_style_path)
1559 .filter(|edge| edge.status == "external")
1560 .map(|edge| edge.source.as_str())
1561 .collect::<BTreeSet<_>>();
1562 if external_sources.is_empty() {
1563 return BTreeSet::new();
1564 }
1565
1566 let facts = collect_omena_query_omena_parser_style_facts_raw(
1567 target.style_source.as_str(),
1568 omena_parser_dialect_for_style_path(target_style_path),
1569 );
1570 let top_any_namespaces = facts
1575 .sass_module_edges
1576 .iter()
1577 .filter(|edge| external_sources.contains(edge.source.as_str()))
1578 .filter(|edge| {
1579 let sif = find_omena_query_external_sif(edge.source.as_str(), external_sifs);
1580 classify_external_boundary_state(edge, sif, &facts, external_sifs).top
1581 == OmenaResolverBoundaryTopV0::TopAny
1582 })
1583 .filter_map(|edge| match edge.kind {
1584 ParsedSassModuleEdgeFactKind::Use
1585 if edge.namespace_kind == Some("default")
1586 || edge.namespace_kind == Some("alias") =>
1587 {
1588 edge.namespace.clone().map(Some)
1589 }
1590 ParsedSassModuleEdgeFactKind::Use if edge.namespace_kind == Some("wildcard") => {
1591 Some(None)
1592 }
1593 ParsedSassModuleEdgeFactKind::Import => Some(None),
1594 _ => None,
1595 })
1596 .collect::<BTreeSet<_>>();
1597 if top_any_namespaces.is_empty() {
1598 return BTreeSet::new();
1599 }
1600
1601 facts
1602 .sass_symbols
1603 .into_iter()
1604 .filter(|symbol| omena_query_sass_symbol_fact_kind_is_reference(symbol.kind))
1605 .filter(|symbol| top_any_namespaces.contains(&symbol.namespace))
1606 .map(|symbol| {
1607 let start: u32 = symbol.range.start().into();
1608 let end: u32 = symbol.range.end().into();
1609 parser_range_for_byte_span(
1610 target.style_source.as_str(),
1611 ParserByteSpanV0 {
1612 start: start as usize,
1613 end: end as usize,
1614 },
1615 )
1616 })
1617 .collect()
1618}
1619
1620fn classify_external_boundary_state(
1636 edge: &ParsedSassModuleEdgeFact,
1637 sif: Option<&OmenaQueryExternalSifInputV0>,
1638 target_facts: &omena_parser::ParsedStyleFacts,
1639 external_sifs: &[OmenaQueryExternalSifInputV0],
1640) -> OmenaResolverBoundaryStateV0 {
1641 let Some(sif) = sif else {
1642 return OmenaResolverBoundaryStateV0::missing(
1643 None,
1644 "SIF mode requires a local SIF artifact for this external Sass module",
1645 );
1646 };
1647
1648 let canonical_url = OmenaResolverCanonicalUrlV0 {
1649 url: edge.source.clone(),
1650 };
1651
1652 if let Some(dependency) = sif.sif.dependencies.iter().find(|dependency| {
1655 find_omena_query_external_sif(dependency.canonical_url.as_str(), external_sifs)
1656 .map(|dependency_sif| {
1657 dependency_sif.sif.fingerprints.interface_hash != dependency.interface_hash
1658 })
1659 .unwrap_or(false)
1660 }) {
1661 return OmenaResolverBoundaryStateV0::stale(
1662 canonical_url,
1663 format!(
1664 "external SIF dependency '{}' interface hash drifted from the lockfile-recorded hash",
1665 dependency.canonical_url
1666 ),
1667 );
1668 }
1669
1670 let exported = collect_sif_exported_sass_symbol_keys(&sif.sif);
1673 let mut referenced = 0usize;
1674 let mut covered = 0usize;
1675 for symbol in &target_facts.sass_symbols {
1676 if !omena_query_sass_symbol_fact_kind_is_reference(symbol.kind) {
1677 continue;
1678 }
1679 if !sass_symbol_reference_belongs_to_edge(edge, symbol.namespace.as_deref()) {
1680 continue;
1681 }
1682 referenced += 1;
1683 if exported.contains(&(symbol.symbol_kind, fold_sass_symbol_name(&symbol.name))) {
1684 covered += 1;
1685 }
1686 }
1687
1688 if referenced > 0 && covered < referenced {
1689 return OmenaResolverBoundaryStateV0::partial(format!(
1690 "external SIF for '{}' exports only {}/{} referenced symbol(s)",
1691 edge.source, covered, referenced
1692 ));
1693 }
1694
1695 OmenaResolverBoundaryStateV0::resolved(canonical_url)
1696}
1697
1698fn sass_symbol_reference_belongs_to_edge(
1704 edge: &ParsedSassModuleEdgeFact,
1705 reference_namespace: Option<&str>,
1706) -> bool {
1707 match edge.kind {
1708 ParsedSassModuleEdgeFactKind::Use
1709 if edge.namespace_kind == Some("default") || edge.namespace_kind == Some("alias") =>
1710 {
1711 edge.namespace.as_deref() == reference_namespace
1712 }
1713 ParsedSassModuleEdgeFactKind::Use if edge.namespace_kind == Some("wildcard") => {
1714 reference_namespace.is_none()
1715 }
1716 ParsedSassModuleEdgeFactKind::Import | ParsedSassModuleEdgeFactKind::Forward => {
1717 reference_namespace.is_none()
1718 }
1719 _ => false,
1720 }
1721}
1722
1723fn summarize_omena_query_external_sif_boundary_diagnostics(
1724 target_style_path: &str,
1725 style_sources: &[OmenaQueryStyleSourceInputV0],
1726 package_manifests: &[OmenaQueryStylePackageManifestV0],
1727 external_sifs: &[OmenaQueryExternalSifInputV0],
1728 strictness: OmenaStrictnessLevelV0,
1729) -> Vec<OmenaQueryStyleDiagnosticV0> {
1730 let Some(target) = style_sources
1731 .iter()
1732 .find(|source| source.style_path == target_style_path)
1733 else {
1734 return Vec::new();
1735 };
1736 let style_source_refs = style_sources
1737 .iter()
1738 .map(|source| (source.style_path.as_str(), source.style_source.as_str()))
1739 .collect::<Vec<_>>();
1740 let style_fact_entries = collect_omena_query_style_fact_entries(style_source_refs.as_slice());
1741 let resolution =
1742 summarize_sass_module_cross_file_resolution(&style_fact_entries, package_manifests);
1743 let external_sources = resolution
1747 .edges
1748 .iter()
1749 .filter(|edge| edge.from_style_path == target_style_path)
1750 .filter(|edge| edge.status == "external")
1751 .map(|edge| edge.source.as_str())
1752 .collect::<BTreeSet<_>>();
1753 let unresolved_sources = resolution
1761 .edges
1762 .iter()
1763 .filter(|edge| edge.from_style_path == target_style_path)
1764 .filter(|edge| edge.status == "unresolved")
1765 .filter(|edge| !sass_module_source_is_workspace_local(edge.source.as_str()))
1766 .map(|edge| edge.source.as_str())
1767 .collect::<BTreeSet<_>>();
1768 if external_sources.is_empty() && unresolved_sources.is_empty() {
1769 return Vec::new();
1770 }
1771
1772 let facts = collect_omena_query_omena_parser_style_facts_raw(
1773 target.style_source.as_str(),
1774 omena_parser_dialect_for_style_path(target_style_path),
1775 );
1776 let mut emitted = BTreeSet::new();
1777 let mut diagnostics = Vec::new();
1778 for edge in &facts.sass_module_edges {
1779 let is_external = external_sources.contains(edge.source.as_str());
1780 let is_unresolved = unresolved_sources.contains(edge.source.as_str());
1781 if !is_external && !is_unresolved {
1782 continue;
1783 }
1784 if !emitted.insert((edge.kind, edge.source.clone())) {
1785 continue;
1786 }
1787 let state = if is_unresolved {
1790 omena_resolver_boundary_state_for_unresolved_reference_v0(edge.source.as_str())
1791 } else {
1792 let sif = find_omena_query_external_sif(edge.source.as_str(), external_sifs);
1793 classify_external_boundary_state(edge, sif, &facts, external_sifs)
1794 };
1795 let (code, default_severity) = match state.state {
1796 OmenaResolverBoundaryStateKindV0::Resolved => continue,
1798 OmenaResolverBoundaryStateKindV0::Stale => ("staleExternalSif", "warning"),
1799 OmenaResolverBoundaryStateKindV0::Partial => ("partialExternalSif", "information"),
1800 OmenaResolverBoundaryStateKindV0::Missing => ("missingExternalSif", "warning"),
1801 OmenaResolverBoundaryStateKindV0::Unresolved => {
1802 ("unresolvedExternalReference", "warning")
1803 }
1804 };
1805 let severity = strictness.boundary_severity(default_severity);
1808 let start: u32 = edge.range.start().into();
1809 let end: u32 = edge.range.end().into();
1810 diagnostics.push(OmenaQueryStyleDiagnosticV0 {
1811 code,
1812 severity,
1813 provenance: vec![
1814 "omena-resolver.boundary-state",
1815 "omena-query.external-sif-boundary-diagnostics",
1816 ],
1817 range: parser_range_for_byte_span(
1818 target.style_source.as_str(),
1819 ParserByteSpanV0 {
1820 start: start as usize,
1821 end: end as usize,
1822 },
1823 ),
1824 message: format!(
1825 "External Sass module '{}' is {} ({}); {}",
1826 edge.source,
1827 state.state_name,
1828 state.top_name,
1829 external_boundary_remediation_hint(state.state)
1830 ),
1831 tags: Vec::new(),
1832 create_custom_property: None,
1833 });
1834 }
1835 diagnostics
1836}
1837
1838fn external_boundary_remediation_hint(state: OmenaResolverBoundaryStateKindV0) -> &'static str {
1840 match state {
1841 OmenaResolverBoundaryStateKindV0::Missing => {
1842 "generate or provide a SIF artifact, or use --external ignored."
1843 }
1844 OmenaResolverBoundaryStateKindV0::Stale => {
1845 "regenerate the SIF/lockfile so its dependency interface hashes match."
1846 }
1847 OmenaResolverBoundaryStateKindV0::Partial => {
1848 "some referenced symbols are absent from the SIF interface; regenerate the SIF or fix the reference."
1849 }
1850 OmenaResolverBoundaryStateKindV0::Unresolved => {
1851 "the resolver cannot canonicalize this reference; fix the specifier or add it to the workspace."
1852 }
1853 OmenaResolverBoundaryStateKindV0::Resolved => "",
1854 }
1855}
1856
1857type SassSymbolKey = (&'static str, Option<String>, String);
1858
1859fn fold_sass_symbol_name(name: &str) -> String {
1868 name.replace('_', "-")
1869}
1870
1871fn sass_symbol_key(
1872 symbol_kind: &'static str,
1873 namespace: Option<String>,
1874 name: String,
1875) -> SassSymbolKey {
1876 let folded = fold_sass_symbol_name(&name);
1877 (symbol_kind, namespace, folded)
1878}
1879
1880fn collect_visible_sass_symbol_keys(
1881 target_style_path: &str,
1882 facts_by_path: &BTreeMap<&str, &OmenaQueryOmenaParserStyleFactsV0>,
1883 resolution: &OmenaQuerySassModuleCrossFileResolutionV0,
1884 external_sifs: &[OmenaQueryExternalSifInputV0],
1885) -> BTreeSet<SassSymbolKey> {
1886 let mut visible = BTreeSet::new();
1887 if let Some(facts) = facts_by_path.get(target_style_path) {
1888 visible.extend(
1889 own_sass_symbol_declaration_keys(facts)
1890 .into_iter()
1891 .map(|(symbol_kind, name)| sass_symbol_key(symbol_kind, None, name)),
1892 );
1893 }
1894
1895 for edge in resolution
1896 .edges
1897 .iter()
1898 .filter(|edge| edge.from_style_path == target_style_path)
1899 {
1900 let exported = if let Some(module_name) = sass_builtin_module_name(edge.source.as_str()) {
1901 builtin_sass_symbol_exports(module_name)
1902 } else if edge.status == "resolved" {
1903 let mut visiting = BTreeSet::new();
1904 edge.resolved_style_path
1905 .as_deref()
1906 .map(|path| {
1907 collect_exported_sass_symbol_keys(
1908 path,
1909 facts_by_path,
1910 resolution,
1911 external_sifs,
1912 &mut visiting,
1913 )
1914 })
1915 .unwrap_or_default()
1916 } else if edge.status == "external" {
1917 find_omena_query_external_sif(edge.source.as_str(), external_sifs)
1918 .map(|sif| collect_sif_exported_sass_symbol_keys(&sif.sif))
1919 .unwrap_or_default()
1920 } else {
1921 BTreeSet::new()
1922 };
1923
1924 match edge.edge_kind {
1925 "sassUse"
1926 if edge.namespace_kind == Some("default")
1927 || edge.namespace_kind == Some("alias") =>
1928 {
1929 if let Some(namespace) = edge.namespace.clone() {
1930 visible.extend(exported.into_iter().map(|(symbol_kind, name)| {
1931 sass_symbol_key(symbol_kind, Some(namespace.clone()), name)
1932 }));
1933 }
1934 }
1935 "sassUse" if edge.namespace_kind == Some("wildcard") => {
1936 visible.extend(
1937 exported
1938 .into_iter()
1939 .map(|(symbol_kind, name)| sass_symbol_key(symbol_kind, None, name)),
1940 );
1941 }
1942 "sassImport" => {
1943 visible.extend(
1944 exported
1945 .into_iter()
1946 .map(|(symbol_kind, name)| sass_symbol_key(symbol_kind, None, name)),
1947 );
1948 }
1949 _ => {}
1950 }
1951 }
1952
1953 visible
1954}
1955
1956fn collect_exported_sass_symbol_keys(
1957 style_path: &str,
1958 facts_by_path: &BTreeMap<&str, &OmenaQueryOmenaParserStyleFactsV0>,
1959 resolution: &OmenaQuerySassModuleCrossFileResolutionV0,
1960 external_sifs: &[OmenaQueryExternalSifInputV0],
1961 visiting: &mut BTreeSet<String>,
1962) -> BTreeSet<(&'static str, String)> {
1963 if !visiting.insert(style_path.to_string()) {
1964 return BTreeSet::new();
1965 }
1966
1967 let mut exported = facts_by_path
1968 .get(style_path)
1969 .map(|facts| own_sass_symbol_declaration_keys(facts))
1970 .unwrap_or_default();
1971
1972 for edge in resolution
1973 .edges
1974 .iter()
1975 .filter(|edge| edge.from_style_path == style_path)
1976 .filter(|edge| edge.edge_kind == "sassForward" || edge.edge_kind == "sassImport")
1977 {
1978 let module_exports =
1979 if let Some(module_name) = sass_builtin_module_name(edge.source.as_str()) {
1980 builtin_sass_symbol_exports(module_name)
1981 } else if edge.status == "resolved" {
1982 edge.resolved_style_path
1983 .as_deref()
1984 .map(|path| {
1985 collect_exported_sass_symbol_keys(
1986 path,
1987 facts_by_path,
1988 resolution,
1989 external_sifs,
1990 visiting,
1991 )
1992 })
1993 .unwrap_or_default()
1994 } else if edge.status == "external" {
1995 find_omena_query_external_sif(edge.source.as_str(), external_sifs)
1996 .map(|sif| collect_sif_exported_sass_symbol_keys(&sif.sif))
1997 .unwrap_or_default()
1998 } else {
1999 BTreeSet::new()
2000 };
2001
2002 for (symbol_kind, name) in module_exports {
2003 if !sass_forward_visibility_allows(edge, symbol_kind, name.as_str()) {
2004 continue;
2005 }
2006 let exported_name = if edge.edge_kind == "sassForward" {
2007 apply_sass_forward_prefix(edge.forward_prefix.as_deref(), name.as_str())
2008 } else {
2009 name
2010 };
2011 exported.insert((symbol_kind, exported_name));
2012 }
2013 }
2014
2015 visiting.remove(style_path);
2016 exported
2017}
2018
2019fn own_sass_symbol_declaration_keys(
2020 facts: &OmenaQueryOmenaParserStyleFactsV0,
2021) -> BTreeSet<(&'static str, String)> {
2022 facts
2023 .sass_symbol_facts
2024 .iter()
2025 .filter(|fact| is_omena_query_sass_symbol_declaration_kind(fact.kind))
2026 .map(|fact| (fact.symbol_kind, fact.name.clone()))
2027 .collect()
2028}
2029
2030fn collect_sif_exported_sass_symbol_keys(
2031 sif: &omena_sif::OmenaSifV1,
2032) -> BTreeSet<(&'static str, String)> {
2033 let mut exported = BTreeSet::new();
2034 exported.extend(sif.exports.variables.iter().map(|variable| {
2035 (
2036 "variable",
2037 variable.name.trim_start_matches('$').to_string(),
2038 )
2039 }));
2040 exported.extend(
2041 sif.exports
2042 .mixins
2043 .iter()
2044 .map(|mixin| ("mixin", mixin.name.clone())),
2045 );
2046 exported.extend(
2047 sif.exports
2048 .functions
2049 .iter()
2050 .map(|function| ("function", function.name.clone())),
2051 );
2052 exported
2053}
2054
2055fn find_omena_query_external_sif<'a>(
2056 canonical_url: &str,
2057 external_sifs: &'a [OmenaQueryExternalSifInputV0],
2058) -> Option<&'a OmenaQueryExternalSifInputV0> {
2059 external_sifs.iter().find(|input| {
2060 input.canonical_url == canonical_url || input.sif.canonical_url == canonical_url
2061 })
2062}
2063
2064fn sass_forward_visibility_allows(
2065 edge: &OmenaQuerySassModuleEdgeResolutionV0,
2066 symbol_kind: &'static str,
2067 name: &str,
2068) -> bool {
2069 let prefixed = apply_sass_forward_prefix(edge.forward_prefix.as_deref(), name);
2070 let matches_filter = |filter_name: &String| {
2071 filter_name == name
2072 || filter_name == prefixed.as_str()
2073 || filter_name.trim_start_matches('$') == name
2074 || filter_name.trim_start_matches('$') == prefixed.as_str()
2075 || (symbol_kind != "variable" && filter_name.trim_start_matches('@') == name)
2076 };
2077 match edge.visibility_filter_kind {
2078 Some("show") => edge.visibility_filter_names.iter().any(matches_filter),
2079 Some("hide") => !edge.visibility_filter_names.iter().any(matches_filter),
2080 _ => true,
2081 }
2082}
2083
2084fn apply_sass_forward_prefix(prefix: Option<&str>, name: &str) -> String {
2085 match prefix {
2086 Some(prefix) if prefix.contains('*') => prefix.replace('*', name),
2087 Some(prefix) => format!("{prefix}{name}"),
2088 None => name.to_string(),
2089 }
2090}
2091
2092fn is_omena_query_sass_builtin_symbol_reference_resolved(
2093 edges: &[omena_parser::ParsedSassModuleEdgeFact],
2094 symbol_kind: &'static str,
2095 namespace: Option<&str>,
2096 name: &str,
2097) -> bool {
2098 edges
2099 .iter()
2100 .filter(|edge| edge.kind == ParsedSassModuleEdgeFactKind::Use)
2101 .filter_map(|edge| {
2102 sass_builtin_module_name(edge.source.as_str()).map(|module| (edge, module))
2103 })
2104 .any(|(edge, module)| {
2105 let namespace_matches =
2106 match (namespace, edge.namespace_kind, edge.namespace.as_deref()) {
2107 (Some(reference_namespace), Some("default" | "alias"), Some(use_namespace)) => {
2108 reference_namespace == use_namespace
2109 }
2110 (None, Some("wildcard"), _) => true,
2111 _ => false,
2112 };
2113 namespace_matches && sass_builtin_module_has_symbol(module, symbol_kind, name)
2114 })
2115}
2116
2117fn sass_builtin_module_name(source: &str) -> Option<&str> {
2118 source.strip_prefix("sass:")
2119}
2120
2121fn builtin_sass_symbol_exports(module: &str) -> BTreeSet<(&'static str, String)> {
2122 let mut exports = BTreeSet::new();
2123 for name in sass_builtin_module_function_names(module) {
2124 exports.insert(("function", (*name).to_string()));
2125 }
2126 for name in sass_builtin_module_mixin_names(module) {
2127 exports.insert(("mixin", (*name).to_string()));
2128 }
2129 for name in sass_builtin_module_variable_names(module) {
2130 exports.insert(("variable", (*name).to_string()));
2131 }
2132 exports
2133}
2134
2135fn sass_builtin_module_has_symbol(module: &str, symbol_kind: &'static str, name: &str) -> bool {
2136 match symbol_kind {
2137 "function" => sass_builtin_module_function_names(module).contains(&name),
2138 "mixin" => sass_builtin_module_mixin_names(module).contains(&name),
2139 "variable" => sass_builtin_module_variable_names(module).contains(&name),
2140 _ => false,
2141 }
2142}
2143
2144fn sass_builtin_module_function_names(module: &str) -> &'static [&'static str] {
2145 match module {
2146 "color" => &[
2147 "adjust",
2148 "alpha",
2149 "blue",
2150 "channel",
2151 "change",
2152 "complement",
2153 "desaturate",
2154 "fade-in",
2155 "fade-out",
2156 "grayscale",
2157 "green",
2158 "hsl",
2159 "hsla",
2160 "hue",
2161 "ie-hex-str",
2162 "invert",
2163 "is-legacy",
2164 "is-missing",
2165 "is-powerless",
2166 "lighten",
2167 "lightness",
2168 "mix",
2169 "opacify",
2170 "opacity",
2171 "red",
2172 "same",
2173 "saturate",
2174 "saturation",
2175 "scale",
2176 "space",
2177 "to-gamut",
2178 "to-space",
2179 "transparentize",
2180 ],
2181 "math" => &[
2182 "abs",
2183 "acos",
2184 "asin",
2185 "atan",
2186 "atan2",
2187 "ceil",
2188 "clamp",
2189 "compatible",
2190 "cos",
2191 "div",
2192 "floor",
2193 "hypot",
2194 "is-unitless",
2195 "log",
2196 "max",
2197 "min",
2198 "percentage",
2199 "pow",
2200 "random",
2201 "round",
2202 "sin",
2203 "sqrt",
2204 "tan",
2205 "unit",
2206 ],
2207 "list" => &[
2208 "append",
2209 "index",
2210 "is-bracketed",
2211 "join",
2212 "length",
2213 "separator",
2214 "set-nth",
2215 "slash",
2216 "nth",
2217 "zip",
2218 ],
2219 "map" => &[
2220 "deep-merge",
2221 "deep-remove",
2222 "get",
2223 "has-key",
2224 "keys",
2225 "merge",
2226 "remove",
2227 "set",
2228 "values",
2229 ],
2230 "string" => &[
2231 "index",
2232 "insert",
2233 "length",
2234 "quote",
2235 "slice",
2236 "split",
2237 "to-lower-case",
2238 "to-upper-case",
2239 "unique-id",
2240 "unquote",
2241 ],
2242 "selector" => &[
2243 "append",
2244 "extend",
2245 "is-superselector",
2246 "nest",
2247 "parse",
2248 "replace",
2249 "simple-selectors",
2250 "unify",
2251 ],
2252 "meta" => &[
2253 "accepts-content",
2254 "calc-args",
2255 "calc-name",
2256 "call",
2257 "content-exists",
2258 "feature-exists",
2259 "function-exists",
2260 "get-function",
2261 "get-mixin",
2263 "global-variable-exists",
2264 "inspect",
2265 "keywords",
2266 "mixin-exists",
2267 "module-functions",
2268 "module-mixins",
2269 "module-variables",
2270 "type-of",
2271 "variable-exists",
2272 ],
2273 _ => &[],
2274 }
2275}
2276
2277fn sass_builtin_module_mixin_names(module: &str) -> &'static [&'static str] {
2278 match module {
2279 "meta" => &["apply", "load-css"],
2281 _ => &[],
2282 }
2283}
2284
2285fn sass_builtin_module_variable_names(module: &str) -> &'static [&'static str] {
2286 match module {
2287 "math" => &["e", "epsilon", "max-safe-integer", "min-safe-integer", "pi"],
2288 _ => &[],
2289 }
2290}
2291
2292pub fn summarize_omena_query_css_modules_local_composes_style_diagnostics(
2300 target_style_path: &str,
2301 target_source: &str,
2302) -> Vec<OmenaQueryStyleDiagnosticV0> {
2303 let dialect = omena_parser_dialect_for_style_path(target_style_path);
2304 let target_facts = collect_omena_query_omena_parser_style_facts_raw(target_source, dialect);
2305 let target_class_names = target_facts
2306 .selectors
2307 .iter()
2308 .filter(|selector| selector.kind == ParsedSelectorFactKind::Class)
2309 .map(|selector| selector.name.as_str())
2310 .collect::<BTreeSet<_>>();
2311 let mut diagnostics = Vec::new();
2312
2313 for edge in target_facts.css_module_composes_edges {
2314 if edge.kind != ParsedCssModuleComposesEdgeKind::Local {
2317 continue;
2318 }
2319 let start: u32 = edge.range.start().into();
2320 let end: u32 = edge.range.end().into();
2321 let range = parser_range_for_byte_span(
2322 target_source,
2323 ParserByteSpanV0 {
2324 start: start as usize,
2325 end: end as usize,
2326 },
2327 );
2328 for target_name in edge.target_names {
2329 if target_class_names.contains(target_name.as_str()) {
2330 continue;
2331 }
2332 diagnostics.push(OmenaQueryStyleDiagnosticV0 {
2333 code: "missingComposedSelector",
2334 severity: "warning",
2335 provenance: vec![
2336 "omena-parser.css-modules-composes-facts",
2337 "omena-query.css-modules-resolution-diagnostics",
2338 ],
2339 range,
2340 message: format!(
2341 "Selector '.{}' not found in this file for composes.",
2342 target_name
2343 ),
2344 tags: Vec::new(),
2345 create_custom_property: None,
2346 });
2347 }
2348 }
2349
2350 diagnostics
2351}
2352
2353pub fn summarize_omena_query_css_modules_resolution_style_diagnostics(
2354 target_style_path: &str,
2355 target_source: &str,
2356 style_sources: &[OmenaQueryStyleSourceInputV0],
2357 package_manifests: &[OmenaQueryStylePackageManifestV0],
2358) -> Vec<OmenaQueryStyleDiagnosticV0> {
2359 let style_source_refs = style_sources
2360 .iter()
2361 .map(|source| (source.style_path.as_str(), source.style_source.as_str()))
2362 .collect::<Vec<_>>();
2363 let style_fact_entries = collect_omena_query_style_fact_entries(style_source_refs.as_slice());
2364 let available_style_paths = style_fact_entries
2365 .iter()
2366 .map(|entry| entry.style_path.as_str())
2367 .collect::<BTreeSet<_>>();
2368 let facts_by_path = style_fact_entries
2369 .iter()
2370 .map(|entry| (entry.style_path.as_str(), entry.facts.clone()))
2371 .collect::<BTreeMap<_, _>>();
2372 let dialect = omena_parser_dialect_for_style_path(target_style_path);
2373 let target_facts = collect_omena_query_omena_parser_style_facts_raw(target_source, dialect);
2374 let mut diagnostics = Vec::new();
2375
2376 for edge in target_facts.css_module_composes_edges {
2377 if edge.kind == ParsedCssModuleComposesEdgeKind::Global {
2378 continue;
2379 }
2380 let start: u32 = edge.range.start().into();
2381 let end: u32 = edge.range.end().into();
2382 let range = parser_range_for_byte_span(
2383 target_source,
2384 ParserByteSpanV0 {
2385 start: start as usize,
2386 end: end as usize,
2387 },
2388 );
2389 let target_style = if edge.kind == ParsedCssModuleComposesEdgeKind::External {
2390 let Some(source) = edge.import_source.as_deref() else {
2391 continue;
2392 };
2393 let Some(resolved_style_path) = resolve_style_module_source(
2394 target_style_path,
2395 source,
2396 &available_style_paths,
2397 package_manifests,
2398 ) else {
2399 diagnostics.push(OmenaQueryStyleDiagnosticV0 {
2400 code: "missingComposedModule",
2401 severity: "warning",
2402 provenance: vec![
2403 "omena-parser.css-modules-composes-facts",
2404 "omena-resolver.style-module-resolution",
2405 ],
2406 range,
2407 message: format!("Cannot resolve composed CSS Module '{}'.", source),
2408 tags: Vec::new(),
2409 create_custom_property: None,
2410 });
2411 continue;
2412 };
2413 resolved_style_path
2414 } else {
2415 target_style_path.to_string()
2416 };
2417 let target_class_names = facts_by_path
2418 .get(target_style.as_str())
2419 .map(|facts| facts.class_selector_names.as_slice())
2420 .unwrap_or(&[])
2421 .iter()
2422 .map(String::as_str)
2423 .collect::<BTreeSet<_>>();
2424
2425 for target_name in edge.target_names {
2426 if target_class_names.contains(target_name.as_str()) {
2427 continue;
2428 }
2429 let message = if let Some(source) = edge.import_source.as_deref() {
2430 format!(
2431 "Selector '.{}' not found in composed module '{}'.",
2432 target_name, source
2433 )
2434 } else {
2435 format!(
2436 "Selector '.{}' not found in this file for composes.",
2437 target_name
2438 )
2439 };
2440 diagnostics.push(OmenaQueryStyleDiagnosticV0 {
2441 code: "missingComposedSelector",
2442 severity: "warning",
2443 provenance: vec![
2444 "omena-parser.css-modules-composes-facts",
2445 "omena-query.css-modules-resolution-diagnostics",
2446 ],
2447 range,
2448 message,
2449 tags: Vec::new(),
2450 create_custom_property: None,
2451 });
2452 }
2453 }
2454
2455 let mut reported_missing_value_modules = BTreeSet::new();
2456 for edge in target_facts.css_module_value_import_edges {
2457 let start: u32 = edge.range.start().into();
2458 let end: u32 = edge.range.end().into();
2459 let range = parser_range_for_byte_span(
2460 target_source,
2461 ParserByteSpanV0 {
2462 start: start as usize,
2463 end: end as usize,
2464 },
2465 );
2466 let Some(resolved_style_path) = resolve_style_module_source(
2467 target_style_path,
2468 &edge.import_source,
2469 &available_style_paths,
2470 package_manifests,
2471 ) else {
2472 if reported_missing_value_modules.insert(edge.import_source.clone()) {
2473 diagnostics.push(OmenaQueryStyleDiagnosticV0 {
2474 code: "missingValueModule",
2475 severity: "warning",
2476 provenance: vec![
2477 "omena-parser.css-modules-value-facts",
2478 "omena-resolver.style-module-resolution",
2479 ],
2480 range,
2481 message: format!(
2482 "Cannot resolve imported @value module '{}'.",
2483 edge.import_source
2484 ),
2485 tags: Vec::new(),
2486 create_custom_property: None,
2487 });
2488 }
2489 continue;
2490 };
2491 let target_value_names = facts_by_path
2492 .get(resolved_style_path.as_str())
2493 .map(|facts| facts.css_module_value_definition_names.as_slice())
2494 .unwrap_or(&[])
2495 .iter()
2496 .map(String::as_str)
2497 .collect::<BTreeSet<_>>();
2498 if target_value_names.contains(edge.remote_name.as_str()) {
2499 continue;
2500 }
2501 let message = if edge.local_name == edge.remote_name {
2502 format!(
2503 "@value '{}' not found in '{}'.",
2504 edge.remote_name, edge.import_source
2505 )
2506 } else {
2507 format!(
2508 "@value '{}' not found in '{}' for local binding '{}'.",
2509 edge.remote_name, edge.import_source, edge.local_name
2510 )
2511 };
2512 diagnostics.push(OmenaQueryStyleDiagnosticV0 {
2513 code: "missingImportedValue",
2514 severity: "warning",
2515 provenance: vec![
2516 "omena-parser.css-modules-value-facts",
2517 "omena-query.css-modules-resolution-diagnostics",
2518 ],
2519 range,
2520 message,
2521 tags: Vec::new(),
2522 create_custom_property: None,
2523 });
2524 }
2525
2526 diagnostics
2527}
2528
2529pub fn summarize_omena_query_unused_selector_style_diagnostics(
2530 target_style_path: &str,
2531 target_source: &str,
2532 style_sources: &[OmenaQueryStyleSourceInputV0],
2533 source_documents: &[OmenaQuerySourceDocumentInputV0],
2534 package_manifests: &[OmenaQueryStylePackageManifestV0],
2535 classname_transform: Option<&str>,
2536) -> Vec<OmenaQueryStyleDiagnosticV0> {
2537 summarize_omena_query_unused_selector_style_diagnostics_with_path_mappings(
2538 target_style_path,
2539 target_source,
2540 style_sources,
2541 source_documents,
2542 package_manifests,
2543 classname_transform,
2544 &[],
2545 &[],
2546 )
2547}
2548
2549#[allow(clippy::too_many_arguments)]
2550pub fn summarize_omena_query_unused_selector_style_diagnostics_with_path_mappings(
2551 target_style_path: &str,
2552 target_source: &str,
2553 style_sources: &[OmenaQueryStyleSourceInputV0],
2554 source_documents: &[OmenaQuerySourceDocumentInputV0],
2555 package_manifests: &[OmenaQueryStylePackageManifestV0],
2556 classname_transform: Option<&str>,
2557 bundler_path_mappings: &[OmenaResolverBundlerPathAliasMappingV0],
2558 tsconfig_path_mappings: &[OmenaResolverTsconfigPathMappingV0],
2559) -> Vec<OmenaQueryStyleDiagnosticV0> {
2560 if source_documents.is_empty() {
2561 return Vec::new();
2562 }
2563
2564 let style_source_refs = style_sources
2565 .iter()
2566 .map(|source| (source.style_path.as_str(), source.style_source.as_str()))
2567 .collect::<Vec<_>>();
2568 let style_fact_entries = collect_omena_query_style_fact_entries(style_source_refs.as_slice());
2569 let available_style_paths = style_fact_entries
2570 .iter()
2571 .map(|entry| entry.style_path.as_str())
2572 .collect::<BTreeSet<_>>();
2573 let facts_by_path = style_fact_entries
2574 .iter()
2575 .map(|entry| (entry.style_path.as_str(), entry.facts.clone()))
2576 .collect::<BTreeMap<_, _>>();
2577 let aliases_by_path = collect_classname_transform_aliases(&facts_by_path, classname_transform);
2578 let (mut used_selectors, unresolved_dynamic_usage, has_unresolved_style_import) =
2579 collect_omena_query_source_selector_usage_by_style(
2580 &available_style_paths,
2581 source_documents,
2582 package_manifests,
2583 &aliases_by_path,
2584 bundler_path_mappings,
2585 tsconfig_path_mappings,
2586 );
2587 if unresolved_dynamic_usage.contains(target_style_path) {
2588 return Vec::new();
2589 }
2590 if has_unresolved_style_import {
2598 return Vec::new();
2599 }
2600
2601 let composes_graph = collect_css_modules_composes_adjacency(
2602 &facts_by_path,
2603 &available_style_paths,
2604 package_manifests,
2605 );
2606 propagate_omena_query_composes_usage(&composes_graph, &mut used_selectors);
2607
2608 let dialect = omena_parser_dialect_for_style_path(target_style_path);
2609 let target_facts = collect_omena_query_omena_parser_style_facts_raw(target_source, dialect);
2610 let used_in_target = used_selectors
2611 .get(target_style_path)
2612 .cloned()
2613 .unwrap_or_default();
2614 let mut emitted = BTreeSet::new();
2615
2616 target_facts
2617 .selectors
2618 .into_iter()
2619 .filter(|selector| selector.kind == ParsedSelectorFactKind::Class)
2620 .filter(|selector| !used_in_target.contains(selector.name.as_str()))
2621 .filter_map(|selector| {
2622 let start: u32 = selector.range.start().into();
2623 let end: u32 = selector.range.end().into();
2624 if !emitted.insert(selector.name.clone()) {
2625 return None;
2626 }
2627 Some(OmenaQueryStyleDiagnosticV0 {
2628 code: "unusedSelector",
2629 severity: "hint",
2630 provenance: vec![
2631 "omena-parser.selector-facts",
2632 "omena-query.source-selector-usage",
2633 ],
2634 range: parser_range_for_byte_span(
2635 target_source,
2636 ParserByteSpanV0 {
2637 start: start as usize,
2638 end: end as usize,
2639 },
2640 ),
2641 message: format!("Selector '.{}' is declared but never used.", selector.name),
2642 tags: vec![LSP_DIAGNOSTIC_TAG_UNNECESSARY],
2643 create_custom_property: None,
2644 })
2645 })
2646 .collect()
2647}
2648
2649fn collect_omena_query_source_selector_usage_by_style(
2650 available_style_paths: &BTreeSet<&str>,
2651 source_documents: &[OmenaQuerySourceDocumentInputV0],
2652 package_manifests: &[OmenaQueryStylePackageManifestV0],
2653 aliases_by_path: &BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
2654 bundler_path_mappings: &[OmenaResolverBundlerPathAliasMappingV0],
2655 tsconfig_path_mappings: &[OmenaResolverTsconfigPathMappingV0],
2656) -> (BTreeMap<String, BTreeSet<String>>, BTreeSet<String>, bool) {
2657 let mut used_selectors = BTreeMap::<String, BTreeSet<String>>::new();
2658 let mut unresolved_dynamic_usage = BTreeSet::<String>::new();
2659 let mut has_unresolved_style_import = false;
2664
2665 for document in source_documents {
2666 let imports = summarize_omena_query_source_import_declarations_for_source_language(
2667 document.source_path.as_str(),
2668 &document.source_source,
2669 None,
2670 );
2671 let mut imported_style_bindings = Vec::new();
2672 let mut classnames_bind_bindings = Vec::new();
2673 for import in imports.imports {
2674 if import.specifier == "classnames/bind" {
2675 classnames_bind_bindings.push(import.binding);
2676 continue;
2677 }
2678 let Some(style_path) = resolve_style_module_source_with_path_mappings(
2679 &document.source_path,
2680 &import.specifier,
2681 available_style_paths,
2682 package_manifests,
2683 bundler_path_mappings,
2684 tsconfig_path_mappings,
2685 ) else {
2686 if specifier_targets_style_module(&import.specifier) {
2687 has_unresolved_style_import = true;
2688 }
2689 continue;
2690 };
2691 imported_style_bindings.push(OmenaQuerySourceImportedStyleBindingV0 {
2692 binding: import.binding,
2693 style_uri: style_path,
2694 });
2695 }
2696 if imported_style_bindings.is_empty() {
2697 continue;
2698 }
2699
2700 let index = summarize_omena_query_source_syntax_index_for_source_language(
2701 document.source_path.as_str(),
2702 &document.source_source,
2703 None,
2704 imported_style_bindings,
2705 classnames_bind_bindings,
2706 );
2707 for reference in index.selector_references {
2708 let Some(target_style_path) = reference.target_style_uri else {
2709 continue;
2710 };
2711 let Some(selector_name) = reference.selector_name.or_else(|| {
2712 source_reference_text_selector_name(&document.source_source, reference.byte_span)
2713 }) else {
2714 unresolved_dynamic_usage.insert(target_style_path);
2715 continue;
2716 };
2717 let used_for_style = used_selectors.entry(target_style_path.clone()).or_default();
2718 if let Some(canonical_names) = aliases_by_path
2719 .get(target_style_path.as_str())
2720 .and_then(|aliases| aliases.get(selector_name.as_str()))
2721 {
2722 used_for_style.extend(canonical_names.iter().cloned());
2723 } else {
2724 used_for_style.insert(selector_name);
2725 }
2726 }
2727 }
2728
2729 (
2730 used_selectors,
2731 unresolved_dynamic_usage,
2732 has_unresolved_style_import,
2733 )
2734}
2735
2736fn specifier_targets_style_module(specifier: &str) -> bool {
2740 let path = specifier
2741 .split(['?', '#'])
2742 .next()
2743 .unwrap_or(specifier)
2744 .to_ascii_lowercase();
2745 path.ends_with(".css")
2746 || path.ends_with(".scss")
2747 || path.ends_with(".sass")
2748 || path.ends_with(".less")
2749}
2750
2751fn collect_classname_transform_aliases(
2752 facts_by_path: &BTreeMap<&str, OmenaQueryOmenaParserStyleFactsV0>,
2753 classname_transform: Option<&str>,
2754) -> BTreeMap<String, BTreeMap<String, BTreeSet<String>>> {
2755 let mut aliases_by_path = BTreeMap::<String, BTreeMap<String, BTreeSet<String>>>::new();
2756 for (style_path, facts) in facts_by_path {
2757 let aliases = aliases_by_path
2758 .entry((*style_path).to_string())
2759 .or_default();
2760 for selector_name in &facts.class_selector_names {
2761 for alias in classname_transform_aliases(selector_name.as_str(), classname_transform) {
2762 aliases
2763 .entry(alias)
2764 .or_default()
2765 .insert(selector_name.clone());
2766 }
2767 }
2768 }
2769 aliases_by_path
2770}
2771
2772fn classname_transform_aliases(name: &str, classname_transform: Option<&str>) -> Vec<String> {
2773 match classname_transform.unwrap_or("asIs") {
2774 "camelCase" => keep_original_plus_transformed(name, to_ascii_camel_case(name)),
2775 "camelCaseOnly" => vec![to_ascii_camel_case(name)],
2776 "dashes" => keep_original_plus_transformed(name, dashes_to_ascii_camel(name)),
2777 "dashesOnly" => vec![dashes_to_ascii_camel(name)],
2778 _ => vec![name.to_string()],
2779 }
2780}
2781
2782fn keep_original_plus_transformed(name: &str, transformed: String) -> Vec<String> {
2783 if transformed == name {
2784 vec![name.to_string()]
2785 } else {
2786 vec![name.to_string(), transformed]
2787 }
2788}
2789
2790fn dashes_to_ascii_camel(name: &str) -> String {
2791 transform_ascii_separated_name(name, |byte| byte == b'-')
2792}
2793
2794fn to_ascii_camel_case(name: &str) -> String {
2795 transform_ascii_separated_name(name, |byte| byte == b'-' || byte == b'_' || byte == b' ')
2796}
2797
2798fn transform_ascii_separated_name(name: &str, is_separator: impl Fn(u8) -> bool) -> String {
2799 let mut output = String::with_capacity(name.len());
2800 let mut capitalize_next = false;
2801 for byte in name.bytes() {
2802 if is_separator(byte) {
2803 capitalize_next = true;
2804 continue;
2805 }
2806 if capitalize_next {
2807 output.push((byte as char).to_ascii_uppercase());
2808 capitalize_next = false;
2809 continue;
2810 }
2811 output.push(byte as char);
2812 }
2813 output
2814}
2815
2816fn propagate_omena_query_composes_usage(
2817 composes_graph: &BTreeMap<CssModulesComposesNode, BTreeSet<CssModulesComposesNode>>,
2818 used_selectors: &mut BTreeMap<String, BTreeSet<String>>,
2819) {
2820 let mut used_nodes = used_selectors
2821 .iter()
2822 .flat_map(|(style_path, selectors)| {
2823 selectors
2824 .iter()
2825 .map(|selector_name| CssModulesComposesNode {
2826 style_path: style_path.clone(),
2827 selector_name: selector_name.clone(),
2828 })
2829 })
2830 .collect::<BTreeSet<_>>();
2831
2832 let mut changed = true;
2833 while changed {
2834 changed = false;
2835 for (owner, targets) in composes_graph {
2836 if !used_nodes.contains(owner) {
2837 continue;
2838 }
2839 for target in targets {
2840 if used_nodes.insert(target.clone()) {
2841 used_selectors
2842 .entry(target.style_path.clone())
2843 .or_default()
2844 .insert(target.selector_name.clone());
2845 changed = true;
2846 }
2847 }
2848 }
2849 }
2850}
2851
2852#[cfg(test)]
2853mod tests {
2854 use super::*;
2855
2856 #[test]
2857 fn sass_import_is_plain_css_classifies_css_forms() {
2858 assert!(sass_import_is_plain_css("url(theme.css)"));
2860 assert!(sass_import_is_plain_css("url(theme.scss)")); assert!(sass_import_is_plain_css("vendor.css"));
2862 assert!(sass_import_is_plain_css("VENDOR.CSS")); assert!(sass_import_is_plain_css("//cdn.example/x.css"));
2864 assert!(sass_import_is_plain_css("https://x.com/y.css"));
2865 assert!(sass_import_is_plain_css("http://x.com/y")); }
2867
2868 #[test]
2869 fn sass_import_is_plain_css_keeps_partials_flaggable() {
2870 assert!(!sass_import_is_plain_css("partial"));
2873 assert!(!sass_import_is_plain_css("./legacy"));
2874 assert!(!sass_import_is_plain_css("foundation/buttons"));
2875 assert!(!sass_import_is_plain_css("legacy")); }
2877
2878 #[test]
2879 fn media_qualified_import_is_not_deprecated() {
2880 let screen = summarize_omena_query_sass_import_deprecation_hints(
2883 "theme.scss",
2884 "@import \"foo\" screen;",
2885 );
2886 assert!(
2887 screen.is_empty(),
2888 "media-qualified (Ident) @import must NOT warn deprecatedSassImport, got {screen:?}"
2889 );
2890 let feature = summarize_omena_query_sass_import_deprecation_hints(
2891 "theme.scss",
2892 "@import \"foo\" (min-width: 100px);",
2893 );
2894 assert!(
2895 feature.is_empty(),
2896 "media-feature-qualified @import must NOT warn deprecatedSassImport, got {feature:?}"
2897 );
2898 }
2899
2900 #[test]
2901 fn bare_partial_import_still_deprecated() {
2902 let diagnostics =
2905 summarize_omena_query_sass_import_deprecation_hints("theme.scss", "@import 'partial';");
2906 assert_eq!(
2907 diagnostics.len(),
2908 1,
2909 "bare Sass partial @import must still warn, got {diagnostics:?}"
2910 );
2911 assert_eq!(diagnostics[0].code, "deprecatedSassImport");
2912 }
2913
2914 #[test]
2915 fn media_qualified_comma_peer_classifies_per_target() {
2916 let diagnostics = summarize_omena_query_sass_import_deprecation_hints(
2919 "theme.scss",
2920 "@import \"a\", \"b\" screen;",
2921 );
2922 assert_eq!(
2923 diagnostics.len(),
2924 1,
2925 "exactly the bare partial peer must warn, got {diagnostics:?}"
2926 );
2927 }
2928
2929 #[test]
2938 fn sass_meta_allowlist_matches_pinned_1_77_surface() {
2939 let pinned_functions: BTreeSet<&str> = [
2940 "accepts-content",
2941 "calc-args",
2942 "calc-name",
2943 "call",
2944 "content-exists",
2945 "feature-exists",
2946 "function-exists",
2947 "get-function",
2948 "get-mixin",
2949 "global-variable-exists",
2950 "inspect",
2951 "keywords",
2952 "mixin-exists",
2953 "module-functions",
2954 "module-mixins",
2955 "module-variables",
2956 "type-of",
2957 "variable-exists",
2958 ]
2959 .into_iter()
2960 .collect();
2961 let pinned_mixins: BTreeSet<&str> = ["apply", "load-css"].into_iter().collect();
2962
2963 let actual_functions: BTreeSet<&str> = sass_builtin_module_function_names("meta")
2964 .iter()
2965 .copied()
2966 .collect();
2967 let actual_mixins: BTreeSet<&str> = sass_builtin_module_mixin_names("meta")
2968 .iter()
2969 .copied()
2970 .collect();
2971
2972 assert_eq!(
2973 actual_functions, pinned_functions,
2974 "sass:meta function allowlist drifted from pinned Sass 1.77 surface; \
2975 update both the allowlist and this pinned set together"
2976 );
2977 assert_eq!(
2978 actual_mixins, pinned_mixins,
2979 "sass:meta mixin allowlist drifted from pinned Sass 1.77 surface; \
2980 update both the allowlist and this pinned set together"
2981 );
2982 }
2983
2984 #[test]
2987 fn local_composes_flags_real_same_file_typo() {
2988 let source = ".base { color: red; }\n.button { composes: missing; }\n";
2991 let diagnostics = summarize_omena_query_css_modules_local_composes_style_diagnostics(
2992 "/tmp/foo.module.scss",
2993 source,
2994 );
2995 assert_eq!(diagnostics.len(), 1, "expected one missingComposedSelector");
2996 assert_eq!(diagnostics[0].code, "missingComposedSelector");
2997 assert!(
2998 diagnostics[0].message.contains("not found in this file"),
2999 "message should reference same-file resolution, got: {}",
3000 diagnostics[0].message
3001 );
3002 }
3003
3004 #[test]
3005 fn local_composes_keeps_resolvable_target_silent() {
3006 let source = ".base { color: red; }\n.button { composes: base; }\n";
3008 let diagnostics = summarize_omena_query_css_modules_local_composes_style_diagnostics(
3009 "/tmp/foo.module.scss",
3010 source,
3011 );
3012 assert!(
3013 diagnostics.is_empty(),
3014 "resolvable local composes target should not be flagged, got: {diagnostics:?}"
3015 );
3016 }
3017
3018 #[test]
3019 fn local_composes_does_not_flag_external_target_without_source() {
3020 let source = ".button { composes: shared from './other.module.scss'; color: blue; }\n";
3024 let diagnostics = summarize_omena_query_css_modules_local_composes_style_diagnostics(
3025 "/tmp/foo.module.scss",
3026 source,
3027 );
3028 assert!(
3029 diagnostics.is_empty(),
3030 "external composes target must not be flagged without cross-file source, got: {diagnostics:?}"
3031 );
3032 }
3033
3034 #[test]
3035 fn local_composes_does_not_flag_global_target() {
3036 let source = ".button { composes: someGlobal from global; color: blue; }\n";
3038 let diagnostics = summarize_omena_query_css_modules_local_composes_style_diagnostics(
3039 "/tmp/foo.module.scss",
3040 source,
3041 );
3042 assert!(
3043 diagnostics.is_empty(),
3044 "global composes target must not be flagged, got: {diagnostics:?}"
3045 );
3046 }
3047
3048 #[test]
3049 fn single_file_summary_includes_local_composes_typo() -> Result<(), String> {
3050 let style_uri = "/tmp/foo.module.scss";
3054 let source = ".base { color: red; }\n.button { composes: missing; }\n";
3055 let candidates = summarize_omena_query_style_hover_candidates(style_uri, source)
3056 .ok_or("hover candidates")?;
3057 let summary = summarize_omena_query_style_diagnostics_for_file_with_local_composes(
3058 style_uri,
3059 source,
3060 candidates.candidates.as_slice(),
3061 );
3062 assert!(
3063 summary
3064 .diagnostics
3065 .iter()
3066 .any(|diagnostic| diagnostic.code == "missingComposedSelector"),
3067 "bare single-file summary should surface the local composes typo, got: {:?}",
3068 summary.diagnostics
3069 );
3070 assert_eq!(summary.diagnostic_count, summary.diagnostics.len());
3071 Ok(())
3072 }
3073
3074 #[test]
3075 fn single_file_summary_does_not_flag_external_composes() -> Result<(), String> {
3076 let style_uri = "/tmp/foo.module.scss";
3079 let source = ".button { composes: shared from './other.module.scss'; color: blue; }\n";
3080 let candidates = summarize_omena_query_style_hover_candidates(style_uri, source)
3081 .ok_or("hover candidates")?;
3082 let summary = summarize_omena_query_style_diagnostics_for_file_with_local_composes(
3083 style_uri,
3084 source,
3085 candidates.candidates.as_slice(),
3086 );
3087 assert!(
3088 !summary.diagnostics.iter().any(|diagnostic| {
3089 diagnostic.code == "missingComposedSelector"
3090 || diagnostic.code == "missingComposedModule"
3091 }),
3092 "external composes target must not be flagged in single-file mode, got: {:?}",
3093 summary.diagnostics
3094 );
3095 Ok(())
3096 }
3097}