1use std::{
2 collections::BTreeSet,
3 ffi::OsString,
4 fs,
5 path::{Component, Path, PathBuf},
6};
7
8use crate::bundler_config_alias::load_omena_bridge_workspace_bundler_path_alias_mappings;
9use omena_resolver::{
10 OmenaResolverBundlerPathAliasMappingV0, OmenaResolverStylePackageManifestV0,
11 OmenaResolverTsconfigPathMappingV0,
12 collect_omena_resolver_style_module_source_candidates_with_path_mappings,
13};
14use omena_sif::{
15 OmenaSifSourceSyntaxV1, OmenaSifStaticGeneratorInputV1, OmenaSifV1,
16 generate_static_omena_sif_v1,
17};
18use serde::Serialize;
19use serde_json::Value;
20
21const WORKSPACE_PACKAGE_MANIFEST_SCAN_LIMIT: usize = 1024;
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
24#[serde(rename_all = "camelCase")]
25pub struct OmenaBridgeStyleResolutionSummaryV0 {
26 pub schema_version: &'static str,
27 pub product: &'static str,
28 pub owner_crate: &'static str,
29 pub resolver_name: &'static str,
30 pub supported_specifier_kinds: Vec<&'static str>,
31 pub candidate_extensions: Vec<&'static str>,
32 pub request_path_policy: Vec<&'static str>,
33}
34
35#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
36#[serde(rename_all = "camelCase")]
37pub struct OmenaBridgeStyleResolutionInputsV0 {
38 pub package_manifests: Vec<OmenaResolverStylePackageManifestV0>,
39 pub tsconfig_path_mappings: Vec<OmenaResolverTsconfigPathMappingV0>,
40 pub bundler_path_mappings: Vec<OmenaResolverBundlerPathAliasMappingV0>,
41}
42
43pub fn summarize_omena_bridge_style_resolution_boundary() -> OmenaBridgeStyleResolutionSummaryV0 {
44 OmenaBridgeStyleResolutionSummaryV0 {
45 schema_version: "0",
46 product: "omena-bridge.style-resolution",
47 owner_crate: "omena-bridge",
48 resolver_name: "style-import-specifier-resolver",
49 supported_specifier_kinds: vec![
50 "relative",
51 "tsconfigPaths",
52 "jsconfigPaths",
53 "bundlerAliases",
54 "npmPackages",
55 "packageImports",
56 ],
57 candidate_extensions: vec!["scss", "sass", "css", "less"],
58 request_path_policy: vec![
59 "resolverConsumesSourceUriWorkspaceUriAndRawSpecifier",
60 "relativeSpecifierExpandsStyleModuleCandidates",
61 "pathAliasResolutionUsesNearestWorkspaceTsconfigOrJsconfig",
62 "pathAliasResolutionFollowsRelativeTsconfigExtends",
63 "bundlerAliasResolutionUsesLiteralViteWebpackConfig",
64 "packageSpecifierResolutionUsesOmenaResolver",
65 "fileUriOutputIsPercentEncoded",
66 "lspServerOwnsOnlyDocumentRoutingAndUriRangeMapping",
67 ],
68 }
69}
70
71pub fn resolve_omena_bridge_style_uri_for_specifier(
72 source_uri: &str,
73 workspace_folder_uri: Option<&str>,
74 specifier: &str,
75) -> Option<String> {
76 resolve_omena_bridge_style_uri_for_specifier_with_package_manifests(
77 source_uri,
78 workspace_folder_uri,
79 specifier,
80 &[],
81 )
82}
83
84pub fn resolve_omena_bridge_style_uri_for_specifier_with_package_manifests(
85 source_uri: &str,
86 workspace_folder_uri: Option<&str>,
87 specifier: &str,
88 configured_package_manifests: &[OmenaResolverStylePackageManifestV0],
89) -> Option<String> {
90 let source_path = normalize_path(file_uri_to_path(source_uri)?);
91 let workspace_path = workspace_folder_uri
92 .and_then(file_uri_to_path)
93 .map(normalize_path);
94 let package_manifests = merged_package_manifests_for_request(
95 source_path.parent(),
96 workspace_path.as_deref(),
97 specifier,
98 configured_package_manifests,
99 );
100 let inputs = OmenaBridgeStyleResolutionInputsV0 {
101 package_manifests,
102 tsconfig_path_mappings: tsconfig_path_mappings_for_workspace(workspace_path.as_deref())
103 .unwrap_or_default(),
104 bundler_path_mappings: load_omena_bridge_workspace_bundler_path_alias_mappings(
105 workspace_path.as_deref(),
106 ),
107 };
108 resolve_omena_bridge_style_uri_for_specifier_with_resolution_inputs(
109 source_uri,
110 workspace_folder_uri,
111 specifier,
112 &inputs,
113 )
114}
115
116pub fn resolve_omena_bridge_style_uri_for_specifier_with_resolution_inputs(
117 source_uri: &str,
118 _workspace_folder_uri: Option<&str>,
119 specifier: &str,
120 resolution_inputs: &OmenaBridgeStyleResolutionInputsV0,
121) -> Option<String> {
122 let source_path = normalize_path(file_uri_to_path(source_uri)?);
123 let source_path_text = source_path.to_string_lossy().to_string();
124 let requires_existing_candidate = (package_name_from_specifier(specifier).is_some()
125 || is_package_import_specifier(specifier))
126 && !resolution_inputs
127 .tsconfig_path_mappings
128 .iter()
129 .any(|mapping| tsconfig_path_pattern_matches(mapping.pattern.as_str(), specifier))
130 && !resolution_inputs
131 .bundler_path_mappings
132 .iter()
133 .any(|mapping| bundler_path_alias_pattern_matches(mapping.pattern.as_str(), specifier));
134 let candidates = collect_omena_resolver_style_module_source_candidates_with_path_mappings(
135 source_path_text.as_str(),
136 specifier,
137 resolution_inputs.package_manifests.as_slice(),
138 resolution_inputs.bundler_path_mappings.as_slice(),
139 resolution_inputs.tsconfig_path_mappings.as_slice(),
140 );
141
142 style_uri_for_resolver_candidates(candidates.as_slice(), requires_existing_candidate)
143}
144
145pub fn generate_omena_bridge_sif_for_resolved_style_path(
158 resolved_path: &str,
159) -> Result<OmenaSifV1, String> {
160 let path = resolved_style_entry_path(resolved_path)
161 .ok_or_else(|| format!("unresolvable style module entry path: {resolved_path}"))?;
162 let canonical_url = path_to_file_uri(path.as_path());
163 let source = fs::read_to_string(path.as_path()).map_err(|error| {
164 format!(
165 "failed to read resolved style module {}: {error}",
166 path.to_string_lossy()
167 )
168 })?;
169 let syntax = infer_omena_bridge_sif_source_syntax(path.as_path());
170 generate_static_omena_sif_v1(OmenaSifStaticGeneratorInputV1 {
171 canonical_url: canonical_url.as_str(),
172 source: source.as_str(),
173 syntax,
174 })
175 .map_err(|error| format!("failed to generate SIF for {canonical_url}: {error}"))
176}
177
178fn resolved_style_entry_path(resolved_path: &str) -> Option<PathBuf> {
179 let path = if resolved_path.starts_with("file://") {
180 file_uri_to_path(resolved_path)?
181 } else if resolved_path.is_empty() {
182 return None;
183 } else {
184 PathBuf::from(resolved_path)
185 };
186 Some(normalize_path(path))
187}
188
189fn infer_omena_bridge_sif_source_syntax(path: &Path) -> OmenaSifSourceSyntaxV1 {
190 match path
191 .extension()
192 .and_then(|extension| extension.to_str())
193 .map(str::to_ascii_lowercase)
194 .as_deref()
195 {
196 Some("css") => OmenaSifSourceSyntaxV1::Css,
197 Some("sass") => OmenaSifSourceSyntaxV1::Sass,
198 _ => OmenaSifSourceSyntaxV1::Scss,
199 }
200}
201
202pub fn load_omena_bridge_workspace_style_resolution_inputs(
203 workspace_folder_uri: Option<&str>,
204 configured_package_manifests: &[OmenaResolverStylePackageManifestV0],
205) -> OmenaBridgeStyleResolutionInputsV0 {
206 let workspace_path = workspace_folder_uri
207 .and_then(file_uri_to_path)
208 .map(normalize_path);
209 load_omena_bridge_workspace_style_resolution_inputs_from_path(
210 workspace_path.as_deref(),
211 configured_package_manifests,
212 )
213}
214
215fn load_omena_bridge_workspace_style_resolution_inputs_from_path(
216 workspace_path: Option<&Path>,
217 configured_package_manifests: &[OmenaResolverStylePackageManifestV0],
218) -> OmenaBridgeStyleResolutionInputsV0 {
219 OmenaBridgeStyleResolutionInputsV0 {
220 package_manifests: merge_package_manifest_lists(
221 configured_package_manifests,
222 workspace_package_manifests(workspace_path).as_slice(),
223 ),
224 tsconfig_path_mappings: tsconfig_path_mappings_for_workspace(workspace_path)
225 .unwrap_or_default(),
226 bundler_path_mappings: load_omena_bridge_workspace_bundler_path_alias_mappings(
227 workspace_path,
228 ),
229 }
230}
231
232fn merge_package_manifest_lists(
233 primary: &[OmenaResolverStylePackageManifestV0],
234 secondary: &[OmenaResolverStylePackageManifestV0],
235) -> Vec<OmenaResolverStylePackageManifestV0> {
236 let mut manifests = primary.to_vec();
237 let mut seen = manifests
238 .iter()
239 .map(|manifest| manifest.package_json_path.clone())
240 .collect::<BTreeSet<_>>();
241 for manifest in secondary {
242 if seen.insert(manifest.package_json_path.clone()) {
243 manifests.push(manifest.clone());
244 }
245 }
246 manifests
247}
248
249fn merged_package_manifests_for_specifier(
250 source_dir: Option<&Path>,
251 specifier: &str,
252 configured_package_manifests: &[OmenaResolverStylePackageManifestV0],
253) -> Vec<OmenaResolverStylePackageManifestV0> {
254 merge_package_manifest_lists(
255 configured_package_manifests,
256 package_manifests_for_specifier(source_dir, specifier)
257 .unwrap_or_default()
258 .as_slice(),
259 )
260}
261
262fn merged_package_manifests_for_request(
263 source_dir: Option<&Path>,
264 workspace_path: Option<&Path>,
265 specifier: &str,
266 configured_package_manifests: &[OmenaResolverStylePackageManifestV0],
267) -> Vec<OmenaResolverStylePackageManifestV0> {
268 let source_manifests =
269 merged_package_manifests_for_specifier(source_dir, specifier, configured_package_manifests);
270 merge_package_manifest_lists(
271 source_manifests.as_slice(),
272 workspace_package_manifests(workspace_path).as_slice(),
273 )
274}
275
276fn tsconfig_path_mappings_for_workspace(
277 workspace_path: Option<&Path>,
278) -> Option<Vec<OmenaResolverTsconfigPathMappingV0>> {
279 let workspace_path = workspace_path?;
280 let mut mappings = Vec::new();
281 for config_path in [
282 workspace_path.join("tsconfig.json"),
283 workspace_path.join("jsconfig.json"),
284 ] {
285 mappings.extend(tsconfig_path_mappings_for_config(config_path.as_path()));
286 }
287 Some(mappings)
288}
289
290fn tsconfig_path_mappings_for_config(
291 config_path: &Path,
292) -> Vec<OmenaResolverTsconfigPathMappingV0> {
293 tsconfig_path_mappings_for_config_with_seen(config_path, &mut BTreeSet::new())
294}
295
296fn tsconfig_path_mappings_for_config_with_seen(
297 config_path: &Path,
298 seen: &mut BTreeSet<PathBuf>,
299) -> Vec<OmenaResolverTsconfigPathMappingV0> {
300 let normalized_config_path = normalize_path(config_path.to_path_buf());
301 if !seen.insert(normalized_config_path.clone()) {
302 return Vec::new();
303 }
304 let Some(config_text) = fs::read_to_string(config_path).ok() else {
305 return Vec::new();
306 };
307 let Some(config) = serde_json::from_str::<Value>(config_text.as_str()).ok() else {
308 return Vec::new();
309 };
310 let own_mappings = tsconfig_path_mappings_from_value(config_path, &config).unwrap_or_default();
311 if !own_mappings.is_empty() {
312 return own_mappings;
313 }
314 resolve_tsconfig_extends_path(config_path, &config)
315 .map(|extends_path| {
316 tsconfig_path_mappings_for_config_with_seen(extends_path.as_path(), seen)
317 })
318 .unwrap_or_default()
319}
320
321fn tsconfig_path_mappings_from_value(
322 config_path: &Path,
323 config: &Value,
324) -> Option<Vec<OmenaResolverTsconfigPathMappingV0>> {
325 let compiler_options = config.get("compilerOptions")?;
326 let paths = compiler_options.get("paths")?.as_object()?;
327 let config_dir = config_path.parent()?;
328 let base_url = compiler_options
329 .get("baseUrl")
330 .and_then(Value::as_str)
331 .unwrap_or(".");
332 let base_path = normalize_path(config_dir.join(base_url));
333 let mut mappings = Vec::new();
334 for (pattern, targets) in paths {
335 let Some(targets) = targets.as_array() else {
336 continue;
337 };
338 let target_patterns = targets
339 .iter()
340 .filter_map(Value::as_str)
341 .map(ToString::to_string)
342 .collect::<Vec<_>>();
343 if target_patterns.is_empty() {
344 continue;
345 }
346 mappings.push(OmenaResolverTsconfigPathMappingV0 {
347 base_path: base_path.to_string_lossy().to_string(),
348 pattern: pattern.to_string(),
349 target_patterns,
350 });
351 }
352 Some(mappings)
353}
354
355fn resolve_tsconfig_extends_path(config_path: &Path, config: &Value) -> Option<PathBuf> {
356 let extends = config.get("extends")?.as_str()?;
357 if !extends.starts_with('.') {
358 return None;
359 }
360 let config_dir = config_path.parent()?;
361 let raw_path = config_dir.join(extends);
362 tsconfig_extends_candidates(raw_path)
363 .into_iter()
364 .find(|candidate| candidate.exists())
365}
366
367fn tsconfig_extends_candidates(path: PathBuf) -> Vec<PathBuf> {
368 if path.extension().is_some() {
369 return vec![path];
370 }
371 vec![path.with_extension("json"), path.join("tsconfig.json")]
372}
373
374fn package_manifests_for_specifier(
375 source_dir: Option<&Path>,
376 specifier: &str,
377) -> Option<Vec<OmenaResolverStylePackageManifestV0>> {
378 if is_package_import_specifier(specifier) {
379 return Some(package_scope_manifests_for_source_dir(source_dir));
380 }
381 let package_name = package_name_from_specifier(specifier)?;
382 let mut manifests = Vec::new();
383 let mut seen = BTreeSet::new();
384 let mut current_dir = source_dir;
385 while let Some(dir) = current_dir {
386 let package_json_path = dir
387 .join("node_modules")
388 .join(package_name)
389 .join("package.json");
390 if seen.insert(package_json_path.clone())
391 && let Ok(package_json_source) = fs::read_to_string(package_json_path.as_path())
392 {
393 manifests.push(OmenaResolverStylePackageManifestV0 {
394 package_json_path: normalize_path(package_json_path)
395 .to_string_lossy()
396 .to_string(),
397 package_json_source,
398 });
399 }
400 current_dir = dir.parent();
401 }
402 Some(manifests)
403}
404
405fn package_scope_manifests_for_source_dir(
406 source_dir: Option<&Path>,
407) -> Vec<OmenaResolverStylePackageManifestV0> {
408 let mut manifests = Vec::new();
409 let mut current_dir = source_dir;
410 while let Some(dir) = current_dir {
411 push_workspace_package_manifest(dir.join("package.json"), &mut manifests);
412 current_dir = dir.parent();
413 }
414 manifests
415}
416
417fn workspace_package_manifests(
418 workspace_path: Option<&Path>,
419) -> Vec<OmenaResolverStylePackageManifestV0> {
420 let Some(workspace_path) = workspace_path else {
421 return Vec::new();
422 };
423 let mut manifests = Vec::new();
424 push_workspace_package_manifest(workspace_path.join("package.json"), &mut manifests);
425
426 let node_modules = workspace_path.join("node_modules");
427 let Ok(entries) = fs::read_dir(node_modules.as_path()) else {
428 return manifests;
429 };
430 for entry in entries.flatten() {
431 if manifests.len() >= WORKSPACE_PACKAGE_MANIFEST_SCAN_LIMIT {
432 break;
433 }
434 let path = entry.path();
435 let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
436 continue;
437 };
438 if file_name.starts_with('@') {
439 push_scoped_workspace_package_manifests(path.as_path(), &mut manifests);
440 } else {
441 push_workspace_package_manifest(path.join("package.json"), &mut manifests);
442 }
443 }
444 manifests.sort_by(|left, right| left.package_json_path.cmp(&right.package_json_path));
445 manifests.dedup_by(|left, right| left.package_json_path == right.package_json_path);
446 manifests
447}
448
449fn push_scoped_workspace_package_manifests(
450 scope_path: &Path,
451 manifests: &mut Vec<OmenaResolverStylePackageManifestV0>,
452) {
453 let Ok(entries) = fs::read_dir(scope_path) else {
454 return;
455 };
456 for entry in entries.flatten() {
457 if manifests.len() >= WORKSPACE_PACKAGE_MANIFEST_SCAN_LIMIT {
458 return;
459 }
460 push_workspace_package_manifest(entry.path().join("package.json"), manifests);
461 }
462}
463
464fn push_workspace_package_manifest(
465 package_json_path: PathBuf,
466 manifests: &mut Vec<OmenaResolverStylePackageManifestV0>,
467) {
468 if manifests.len() >= WORKSPACE_PACKAGE_MANIFEST_SCAN_LIMIT {
469 return;
470 }
471 let normalized_package_json_path = normalize_path(package_json_path);
472 let package_json_path_text = normalized_package_json_path.to_string_lossy().to_string();
473 if manifests
474 .iter()
475 .any(|manifest| manifest.package_json_path == package_json_path_text)
476 {
477 return;
478 }
479 let Ok(package_json_source) = fs::read_to_string(normalized_package_json_path.as_path()) else {
480 return;
481 };
482 manifests.push(OmenaResolverStylePackageManifestV0 {
483 package_json_path: package_json_path_text,
484 package_json_source,
485 });
486}
487
488fn package_name_from_specifier(specifier: &str) -> Option<&str> {
489 let specifier = specifier.strip_prefix("pkg:").unwrap_or(specifier);
490 if specifier.starts_with('.')
491 || specifier.starts_with('/')
492 || is_package_import_specifier(specifier)
493 || is_external_style_specifier(specifier)
494 {
495 return None;
496 }
497 if specifier.starts_with('@') {
498 let mut segments = specifier.splitn(3, '/');
499 let scope = segments.next()?;
500 let package = segments.next()?;
501 if scope.len() <= 1 || package.is_empty() {
502 return None;
503 }
504 return specifier.get(..scope.len() + 1 + package.len());
505 }
506 specifier.split('/').next().filter(|name| !name.is_empty())
507}
508
509fn is_package_import_specifier(specifier: &str) -> bool {
510 specifier
511 .strip_prefix("pkg:")
512 .unwrap_or(specifier)
513 .starts_with('#')
514}
515
516fn tsconfig_path_pattern_matches(pattern: &str, specifier: &str) -> bool {
517 if let Some((prefix, suffix)) = pattern.split_once('*') {
518 return !suffix.contains('*')
519 && specifier.starts_with(prefix)
520 && specifier.ends_with(suffix)
521 && specifier.len() >= prefix.len() + suffix.len();
522 }
523 pattern == specifier
524}
525
526fn bundler_path_alias_pattern_matches(pattern: &str, specifier: &str) -> bool {
527 if pattern.is_empty() {
528 return false;
529 }
530 if let Some(exact_pattern) = pattern.strip_suffix('$') {
531 return specifier == exact_pattern;
532 }
533 if pattern == specifier {
534 return true;
535 }
536 let prefix = if pattern.ends_with('/') {
537 pattern.to_string()
538 } else {
539 format!("{pattern}/")
540 };
541 specifier.starts_with(prefix.as_str())
542}
543
544fn is_external_style_specifier(specifier: &str) -> bool {
545 specifier.starts_with("sass:")
546 || specifier.starts_with("http://")
547 || specifier.starts_with("https://")
548}
549
550fn style_uri_for_resolver_candidates(
551 candidates: &[String],
552 requires_existing_candidate: bool,
553) -> Option<String> {
554 candidates
555 .iter()
556 .map(PathBuf::from)
557 .find(|path| path.exists() && is_indexable_style_path(path.as_path()))
558 .or_else(|| {
559 if requires_existing_candidate {
560 return None;
561 }
562 candidates
563 .iter()
564 .map(PathBuf::from)
565 .find(|path| is_indexable_style_path(path.as_path()))
566 })
567 .map(|path| path_to_file_uri(normalize_path(path).as_path()))
568}
569
570fn is_indexable_style_path(path: &Path) -> bool {
571 let path = path.to_string_lossy();
572 path.ends_with(".module.css")
573 || path.ends_with(".css")
574 || path.ends_with(".module.scss")
575 || path.ends_with(".scss")
576 || path.ends_with(".module.sass")
577 || path.ends_with(".sass")
578 || path.ends_with(".module.less")
579 || path.ends_with(".less")
580}
581
582fn file_uri_to_path(uri: &str) -> Option<PathBuf> {
583 let raw_path = uri.strip_prefix("file://")?;
584 Some(PathBuf::from(percent_decode_uri_path(raw_path)?))
585}
586
587fn percent_decode_uri_path(raw_path: &str) -> Option<String> {
588 let bytes = raw_path.as_bytes();
589 let mut decoded = Vec::with_capacity(bytes.len());
590 let mut index = 0usize;
591 while index < bytes.len() {
592 if bytes[index] == b'%' {
593 let high = bytes.get(index + 1).and_then(|byte| hex_value(*byte))?;
594 let low = bytes.get(index + 2).and_then(|byte| hex_value(*byte))?;
595 decoded.push((high << 4) | low);
596 index += 3;
597 } else {
598 decoded.push(bytes[index]);
599 index += 1;
600 }
601 }
602 String::from_utf8(decoded).ok()
603}
604
605fn hex_value(byte: u8) -> Option<u8> {
606 match byte {
607 b'0'..=b'9' => Some(byte - b'0'),
608 b'a'..=b'f' => Some(byte - b'a' + 10),
609 b'A'..=b'F' => Some(byte - b'A' + 10),
610 _ => None,
611 }
612}
613
614fn path_to_file_uri(path: &Path) -> String {
615 let path = normalize_path(path.to_path_buf());
616 format!(
617 "file://{}",
618 percent_encode_uri_path(path.to_string_lossy().as_ref())
619 )
620}
621
622fn percent_encode_uri_path(path: &str) -> String {
623 let mut encoded = String::with_capacity(path.len());
624 for byte in path.as_bytes() {
625 match *byte {
626 b'A'..=b'Z'
627 | b'a'..=b'z'
628 | b'0'..=b'9'
629 | b'-'
630 | b'.'
631 | b'_'
632 | b'~'
633 | b'/'
634 | b'@'
635 | b':'
636 | b'!'
637 | b'$'
638 | b'&'
639 | b'\''
640 | b'*'
641 | b'+'
642 | b','
643 | b';'
644 | b'=' => encoded.push(*byte as char),
645 _ => encoded.push_str(format!("%{byte:02X}").as_str()),
646 }
647 }
648 encoded
649}
650
651fn normalize_path(path: PathBuf) -> PathBuf {
652 if let Some(canonical) = canonicalize_existing_path_or_parent(path.as_path()) {
653 return normalize_path_lexical(canonical);
654 }
655 normalize_path_lexical(path)
656}
657
658fn canonicalize_existing_path_or_parent(path: &Path) -> Option<PathBuf> {
659 if let Ok(canonical) = fs::canonicalize(path) {
660 return Some(canonical);
661 }
662
663 let mut current = path.to_path_buf();
664 let mut suffix = Vec::<OsString>::new();
665 while let Some(parent) = current.parent() {
666 if let Some(file_name) = current.file_name() {
667 suffix.push(file_name.to_os_string());
668 }
669 if let Ok(mut canonical_parent) = fs::canonicalize(parent) {
670 for segment in suffix.iter().rev() {
671 canonical_parent.push(segment);
672 }
673 return Some(canonical_parent);
674 }
675 current = parent.to_path_buf();
676 }
677 None
678}
679
680fn normalize_path_lexical(path: PathBuf) -> PathBuf {
681 let mut normalized = PathBuf::new();
682 for component in path.components() {
683 match component {
684 Component::CurDir => {}
685 Component::ParentDir => {
686 normalized.pop();
687 }
688 Component::Normal(_) | Component::RootDir | Component::Prefix(_) => {
689 normalized.push(component.as_os_str());
690 }
691 }
692 }
693 normalized
694}
695
696#[cfg(test)]
697mod tests {
698 use std::{fs, time::SystemTime};
699
700 use super::*;
701
702 #[test]
703 fn resolves_relative_style_candidates() -> Result<(), Box<dyn std::error::Error>> {
704 let root = temp_dir("omena_bridge_style_relative")?;
705 let source = root.join("src/App.tsx");
706 let style = root.join("src/Button.module.scss");
707 fs::create_dir_all(
708 source
709 .parent()
710 .ok_or_else(|| std::io::Error::other("parent"))?,
711 )?;
712 fs::write(&source, "")?;
713 fs::write(&style, ".root {}")?;
714
715 let uri = resolve_omena_bridge_style_uri_for_specifier(
716 path_to_file_uri(source.as_path()).as_str(),
717 Some(path_to_file_uri(root.as_path()).as_str()),
718 "./Button.module.scss",
719 );
720
721 assert_eq!(
722 uri.as_deref(),
723 Some(path_to_file_uri(style.as_path()).as_str())
724 );
725 let _ = fs::remove_dir_all(root);
726 Ok(())
727 }
728
729 #[test]
730 fn generates_sif_for_resolved_relative_style_module() -> Result<(), Box<dyn std::error::Error>>
731 {
732 let root = temp_dir("omena_bridge_sif_resolved")?;
733 let source = root.join("src/App.tsx");
734 let style = root.join("src/theme.scss");
735 fs::create_dir_all(
736 style
737 .parent()
738 .ok_or_else(|| std::io::Error::other("parent"))?,
739 )?;
740 fs::write(&source, "")?;
741 fs::write(&style, "$brand: #0af;\n@mixin focus-ring {}\n")?;
742
743 let resolved = resolve_omena_bridge_style_uri_for_specifier(
744 path_to_file_uri(source.as_path()).as_str(),
745 Some(path_to_file_uri(root.as_path()).as_str()),
746 "./theme.scss",
747 )
748 .ok_or_else(|| std::io::Error::other("resolution failed"))?;
749
750 let sif = generate_omena_bridge_sif_for_resolved_style_path(resolved.as_str())?;
751
752 assert_eq!(sif.canonical_url, resolved);
753 assert_eq!(sif.source.syntax, OmenaSifSourceSyntaxV1::Scss);
754 assert!(
755 sif.exports
756 .variables
757 .iter()
758 .any(|variable| variable.name == "$brand"),
759 "expected $brand variable export, got {:?}",
760 sif.exports.variables
761 );
762 assert!(
763 sif.exports
764 .mixins
765 .iter()
766 .any(|mixin| mixin.name == "focus-ring"),
767 "expected focus-ring mixin export, got {:?}",
768 sif.exports.mixins
769 );
770 let json = omena_sif::write_omena_sif_json_v1(&sif)?;
773 let parsed = omena_sif::read_omena_sif_json_v1(json.as_str())?;
774 assert_eq!(parsed, sif);
775 let _ = fs::remove_dir_all(root);
776 Ok(())
777 }
778
779 #[test]
780 fn generates_sif_from_plain_resolved_path() -> Result<(), Box<dyn std::error::Error>> {
781 let root = temp_dir("omena_bridge_sif_plain")?;
782 let style = root.join("tokens.sass");
783 fs::write(&style, "$gap: 8px\n")?;
784
785 let sif =
786 generate_omena_bridge_sif_for_resolved_style_path(style.to_string_lossy().as_ref())?;
787
788 assert_eq!(sif.source.syntax, OmenaSifSourceSyntaxV1::Sass);
789 let _ = fs::remove_dir_all(root);
790 Ok(())
791 }
792
793 #[test]
794 fn errors_gracefully_for_missing_resolved_style_module() {
795 let missing = std::env::temp_dir().join("omena_bridge_sif_missing/does-not-exist.scss");
796 let result =
797 generate_omena_bridge_sif_for_resolved_style_path(missing.to_string_lossy().as_ref());
798 assert!(result.is_err(), "expected error for missing entry");
799 }
800
801 #[test]
802 fn errors_gracefully_for_empty_resolved_path() {
803 let result = generate_omena_bridge_sif_for_resolved_style_path("");
804 assert!(result.is_err(), "expected error for empty path");
805 }
806
807 #[test]
808 fn resolves_tsconfig_path_alias_style_candidates() -> Result<(), Box<dyn std::error::Error>> {
809 let root = temp_dir("omena_bridge_style_alias")?;
810 let source = root.join("src/App.tsx");
811 let style = root.join("src/styles/Button.module.scss");
812 fs::create_dir_all(
813 style
814 .parent()
815 .ok_or_else(|| std::io::Error::other("parent"))?,
816 )?;
817 fs::write(&source, "")?;
818 fs::write(&style, ".root {}")?;
819 fs::write(
820 root.join("tsconfig.json"),
821 r#"{"compilerOptions":{"baseUrl":".","paths":{"@styles/*":["src/styles/*"]}}}"#,
822 )?;
823
824 let uri = resolve_omena_bridge_style_uri_for_specifier(
825 path_to_file_uri(source.as_path()).as_str(),
826 Some(path_to_file_uri(root.as_path()).as_str()),
827 "@styles/Button.module.scss",
828 );
829
830 assert_eq!(
831 uri.as_deref(),
832 Some(path_to_file_uri(style.as_path()).as_str())
833 );
834 let _ = fs::remove_dir_all(root);
835 Ok(())
836 }
837
838 #[test]
839 fn resolves_tsconfig_extends_path_alias_style_candidates()
840 -> Result<(), Box<dyn std::error::Error>> {
841 let root = temp_dir("omena_bridge_style_alias_extends")?;
842 let source = root.join("src/App.tsx");
843 let style = root.join("src/shared/Button.module.scss");
844 let config_dir = root.join("config");
845 fs::create_dir_all(
846 style
847 .parent()
848 .ok_or_else(|| std::io::Error::other("parent"))?,
849 )?;
850 fs::create_dir_all(config_dir.as_path())?;
851 fs::write(&source, "")?;
852 fs::write(&style, ".root {}")?;
853 fs::write(
854 config_dir.join("base.json"),
855 r#"{"compilerOptions":{"baseUrl":"..","paths":{"$shared/*":["src/shared/*"]}}}"#,
856 )?;
857 fs::write(root.join("tsconfig.json"), r#"{"extends":"./config/base"}"#)?;
858
859 let uri = resolve_omena_bridge_style_uri_for_specifier(
860 path_to_file_uri(source.as_path()).as_str(),
861 Some(path_to_file_uri(root.as_path()).as_str()),
862 "$shared/Button.module.scss",
863 );
864
865 assert_eq!(
866 uri.as_deref(),
867 Some(path_to_file_uri(style.as_path()).as_str())
868 );
869 let _ = fs::remove_dir_all(root);
870 Ok(())
871 }
872
873 #[test]
874 fn tsconfig_extends_child_paths_override_parent_paths() -> Result<(), Box<dyn std::error::Error>>
875 {
876 let root = temp_dir("omena_bridge_style_alias_extends_override")?;
877 let source = root.join("src/App.tsx");
878 let parent_style = root.join("src/parent/Button.module.scss");
879 let child_style = root.join("src/child/Button.module.scss");
880 fs::create_dir_all(
881 parent_style
882 .parent()
883 .ok_or_else(|| std::io::Error::other("parent"))?,
884 )?;
885 fs::create_dir_all(
886 child_style
887 .parent()
888 .ok_or_else(|| std::io::Error::other("child"))?,
889 )?;
890 fs::write(&source, "")?;
891 fs::write(&parent_style, ".root { color: red; }")?;
892 fs::write(&child_style, ".root { color: green; }")?;
893 fs::write(
894 root.join("base.json"),
895 r#"{"compilerOptions":{"baseUrl":".","paths":{"$shared/*":["src/parent/*"]}}}"#,
896 )?;
897 fs::write(
898 root.join("tsconfig.json"),
899 r#"{"extends":"./base.json","compilerOptions":{"baseUrl":".","paths":{"$shared/*":["src/child/*"]}}}"#,
900 )?;
901
902 let uri = resolve_omena_bridge_style_uri_for_specifier(
903 path_to_file_uri(source.as_path()).as_str(),
904 Some(path_to_file_uri(root.as_path()).as_str()),
905 "$shared/Button.module.scss",
906 );
907
908 assert_eq!(
909 uri.as_deref(),
910 Some(path_to_file_uri(child_style.as_path()).as_str())
911 );
912 let _ = fs::remove_dir_all(root);
913 Ok(())
914 }
915
916 #[test]
917 fn resolves_vite_bundler_alias_style_candidates() -> Result<(), Box<dyn std::error::Error>> {
918 let root = temp_dir("omena_bridge_style_bundler_alias")?;
919 let source = root.join("src/App.tsx");
920 let style = root.join("src/styles/Button.module.scss");
921 fs::create_dir_all(
922 style
923 .parent()
924 .ok_or_else(|| std::io::Error::other("parent"))?,
925 )?;
926 fs::write(&source, "")?;
927 fs::write(&style, ".root {}")?;
928 fs::write(
929 root.join("vite.config.ts"),
930 r#"export default { resolve: { alias: { "@styles": "./src/styles" } } };"#,
931 )?;
932
933 let uri = resolve_omena_bridge_style_uri_for_specifier(
934 path_to_file_uri(source.as_path()).as_str(),
935 Some(path_to_file_uri(root.as_path()).as_str()),
936 "@styles/Button.module.scss",
937 );
938
939 assert_eq!(
940 uri.as_deref(),
941 Some(path_to_file_uri(style.as_path()).as_str())
942 );
943 let _ = fs::remove_dir_all(root);
944 Ok(())
945 }
946
947 #[test]
948 fn resolves_webpack_exact_bundler_alias_style_candidates()
949 -> Result<(), Box<dyn std::error::Error>> {
950 let root = temp_dir("omena_bridge_style_bundler_exact_alias")?;
951 let source = root.join("src/App.tsx");
952 let style = root.join("src/styles/index.module.scss");
953 fs::create_dir_all(
954 style
955 .parent()
956 .ok_or_else(|| std::io::Error::other("parent"))?,
957 )?;
958 fs::write(&source, "")?;
959 fs::write(&style, ".root {}")?;
960 fs::write(
961 root.join("webpack.config.js"),
962 r#"module.exports = { resolve: { alias: [{ find: "@theme$", replacement: "./src/styles/index.module.scss" }] } };"#,
963 )?;
964
965 let exact_uri = resolve_omena_bridge_style_uri_for_specifier(
966 path_to_file_uri(source.as_path()).as_str(),
967 Some(path_to_file_uri(root.as_path()).as_str()),
968 "@theme",
969 );
970 let prefix_uri = resolve_omena_bridge_style_uri_for_specifier(
971 path_to_file_uri(source.as_path()).as_str(),
972 Some(path_to_file_uri(root.as_path()).as_str()),
973 "@theme/Button.module.scss",
974 );
975
976 assert_eq!(
977 exact_uri.as_deref(),
978 Some(path_to_file_uri(style.as_path()).as_str())
979 );
980 assert!(prefix_uri.is_none());
981 let _ = fs::remove_dir_all(root);
982 Ok(())
983 }
984
985 #[test]
986 fn resolves_sass_style_candidates_without_legacy_language_filter()
987 -> Result<(), Box<dyn std::error::Error>> {
988 let root = temp_dir("omena_bridge_style_sass")?;
989 let source = root.join("src/App.tsx");
990 let style = root.join("src/Button.module.sass");
991 fs::create_dir_all(
992 source
993 .parent()
994 .ok_or_else(|| std::io::Error::other("parent"))?,
995 )?;
996 fs::write(&source, "")?;
997 fs::write(&style, ".root\n color: red\n")?;
998
999 let uri = resolve_omena_bridge_style_uri_for_specifier(
1000 path_to_file_uri(source.as_path()).as_str(),
1001 Some(path_to_file_uri(root.as_path()).as_str()),
1002 "./Button.module.sass",
1003 );
1004
1005 assert_eq!(
1006 uri.as_deref(),
1007 Some(path_to_file_uri(style.as_path()).as_str())
1008 );
1009 let _ = fs::remove_dir_all(root);
1010 Ok(())
1011 }
1012
1013 #[test]
1014 fn resolves_package_style_candidates_through_omena_resolver()
1015 -> Result<(), Box<dyn std::error::Error>> {
1016 let root = temp_dir("omena_bridge_style_package")?;
1017 let source = root.join("src/App.module.scss");
1018 let package_root = root.join("node_modules/@design/tokens");
1019 let style = package_root.join("src/index.scss");
1020 fs::create_dir_all(
1021 style
1022 .parent()
1023 .ok_or_else(|| std::io::Error::other("parent"))?,
1024 )?;
1025 fs::create_dir_all(
1026 source
1027 .parent()
1028 .ok_or_else(|| std::io::Error::other("source parent"))?,
1029 )?;
1030 fs::write(&source, "@use \"@design/tokens\";")?;
1031 fs::write(
1032 package_root.join("package.json"),
1033 r#"{"sass":"src/index.scss"}"#,
1034 )?;
1035 fs::write(&style, "$gap: 1rem;")?;
1036
1037 let uri = resolve_omena_bridge_style_uri_for_specifier(
1038 path_to_file_uri(source.as_path()).as_str(),
1039 Some(path_to_file_uri(root.as_path()).as_str()),
1040 "@design/tokens",
1041 );
1042
1043 assert_eq!(
1044 uri.as_deref(),
1045 Some(path_to_file_uri(style.as_path()).as_str())
1046 );
1047 let _ = fs::remove_dir_all(root);
1048 Ok(())
1049 }
1050
1051 #[test]
1052 fn resolves_sass_pkg_style_candidates_through_manifest_discovery()
1053 -> Result<(), Box<dyn std::error::Error>> {
1054 let root = temp_dir("omena_bridge_style_pkg_manifest")?;
1055 let source = root.join("src/App.module.scss");
1056 let package_root = root.join("node_modules/@design/tokens");
1057 let style = package_root.join("dist/theme.scss");
1058 fs::create_dir_all(
1059 style
1060 .parent()
1061 .ok_or_else(|| std::io::Error::other("style parent"))?,
1062 )?;
1063 fs::create_dir_all(
1064 source
1065 .parent()
1066 .ok_or_else(|| std::io::Error::other("source parent"))?,
1067 )?;
1068 fs::write(&source, "@use \"pkg:@design/tokens/theme\";")?;
1069 fs::write(
1070 package_root.join("package.json"),
1071 r#"{"exports":{"./theme":{"sass":"./dist/theme.scss"}}}"#,
1072 )?;
1073 fs::write(&style, "$gap: 1rem;")?;
1074
1075 let uri = resolve_omena_bridge_style_uri_for_specifier(
1076 path_to_file_uri(source.as_path()).as_str(),
1077 Some(path_to_file_uri(root.as_path()).as_str()),
1078 "pkg:@design/tokens/theme",
1079 );
1080
1081 assert_eq!(
1082 uri.as_deref(),
1083 Some(path_to_file_uri(style.as_path()).as_str())
1084 );
1085 let _ = fs::remove_dir_all(root);
1086 Ok(())
1087 }
1088
1089 #[test]
1090 fn resolves_package_import_style_candidates_through_workspace_manifests()
1091 -> Result<(), Box<dyn std::error::Error>> {
1092 let root = temp_dir("omena_bridge_style_package_import_manifest")?;
1093 let source = root.join("src/App.module.scss");
1094 let package_root = root.join("node_modules/@design/tokens");
1095 let style = package_root.join("dist/theme.scss");
1096 fs::create_dir_all(
1097 style
1098 .parent()
1099 .ok_or_else(|| std::io::Error::other("style parent"))?,
1100 )?;
1101 fs::create_dir_all(
1102 source
1103 .parent()
1104 .ok_or_else(|| std::io::Error::other("source parent"))?,
1105 )?;
1106 fs::write(&source, "@use \"#theme\" as tokens;")?;
1107 fs::write(
1108 root.join("package.json"),
1109 r##"{"imports":{"#theme":"@design/tokens/theme"}}"##,
1110 )?;
1111 fs::write(
1112 package_root.join("package.json"),
1113 r#"{"exports":{"./theme":{"sass":"./dist/theme.scss"}}}"#,
1114 )?;
1115 fs::write(&style, "$gap: 1rem;")?;
1116
1117 let uri = resolve_omena_bridge_style_uri_for_specifier(
1118 path_to_file_uri(source.as_path()).as_str(),
1119 Some(path_to_file_uri(root.as_path()).as_str()),
1120 "#theme",
1121 );
1122
1123 assert_eq!(
1124 uri.as_deref(),
1125 Some(path_to_file_uri(style.as_path()).as_str())
1126 );
1127 let _ = fs::remove_dir_all(root);
1128 Ok(())
1129 }
1130
1131 #[cfg(unix)]
1132 #[test]
1133 fn resolves_symlinked_package_style_candidates_to_canonical_uri()
1134 -> Result<(), Box<dyn std::error::Error>> {
1135 let root = temp_dir("omena_bridge_style_symlinked_package")?;
1136 let source = root.join("src/App.module.scss");
1137 let real_package = root.join(".pnpm/@design+tokens@1.0.0/node_modules/@design/tokens");
1138 let linked_scope = root.join("node_modules/@design");
1139 let linked_package = linked_scope.join("tokens");
1140 let style = real_package.join("src/index.scss");
1141 fs::create_dir_all(
1142 style
1143 .parent()
1144 .ok_or_else(|| std::io::Error::other("style parent"))?,
1145 )?;
1146 fs::create_dir_all(
1147 source
1148 .parent()
1149 .ok_or_else(|| std::io::Error::other("source parent"))?,
1150 )?;
1151 fs::create_dir_all(linked_scope.as_path())?;
1152 fs::write(&source, "@use \"@design/tokens\";")?;
1153 fs::write(
1154 real_package.join("package.json"),
1155 r#"{"sass":"src/index.scss"}"#,
1156 )?;
1157 fs::write(&style, "$gap: 1rem;")?;
1158 std::os::unix::fs::symlink(real_package.as_path(), linked_package.as_path())?;
1159
1160 let uri = resolve_omena_bridge_style_uri_for_specifier(
1161 path_to_file_uri(source.as_path()).as_str(),
1162 Some(path_to_file_uri(root.as_path()).as_str()),
1163 "@design/tokens",
1164 );
1165 let expected_uri = path_to_file_uri(fs::canonicalize(style)?.as_path());
1166
1167 assert_eq!(uri.as_deref(), Some(expected_uri.as_str()));
1168 let _ = fs::remove_dir_all(root);
1169 Ok(())
1170 }
1171
1172 #[test]
1173 fn does_not_fabricate_missing_package_style_candidates()
1174 -> Result<(), Box<dyn std::error::Error>> {
1175 let root = temp_dir("omena_bridge_style_missing_package")?;
1176 let source = root.join("src/App.tsx");
1177 fs::create_dir_all(
1178 source
1179 .parent()
1180 .ok_or_else(|| std::io::Error::other("parent"))?,
1181 )?;
1182 fs::write(&source, "")?;
1183
1184 let uri = resolve_omena_bridge_style_uri_for_specifier(
1185 path_to_file_uri(source.as_path()).as_str(),
1186 Some(path_to_file_uri(root.as_path()).as_str()),
1187 "@design/tokens",
1188 );
1189
1190 assert!(uri.is_none(), "{uri:?}");
1191 let _ = fs::remove_dir_all(root);
1192 Ok(())
1193 }
1194
1195 #[test]
1196 fn emits_percent_encoded_file_uris_for_route_group_paths()
1197 -> Result<(), Box<dyn std::error::Error>> {
1198 let root = temp_dir("omena_bridge_style_route_group")?;
1199 let source = root.join("app/(marketing)/page.tsx");
1200 let style = root.join("app/(marketing)/Card.module.scss");
1201 fs::create_dir_all(
1202 source
1203 .parent()
1204 .ok_or_else(|| std::io::Error::other("parent"))?,
1205 )?;
1206 fs::write(&source, "")?;
1207 fs::write(&style, ".card {}")?;
1208
1209 let uri = resolve_omena_bridge_style_uri_for_specifier(
1210 path_to_file_uri(source.as_path()).as_str(),
1211 Some(path_to_file_uri(root.as_path()).as_str()),
1212 "./Card.module.scss",
1213 )
1214 .ok_or_else(|| std::io::Error::other("route group style should resolve"))?;
1215
1216 assert!(uri.contains("%28marketing%29"), "{uri}");
1217 assert_eq!(uri, path_to_file_uri(style.as_path()));
1218 let _ = fs::remove_dir_all(root);
1219 Ok(())
1220 }
1221
1222 #[test]
1223 fn declares_bridge_owned_style_resolution_boundary() {
1224 let summary = summarize_omena_bridge_style_resolution_boundary();
1225
1226 assert_eq!(summary.product, "omena-bridge.style-resolution");
1227 assert_eq!(summary.owner_crate, "omena-bridge");
1228 assert!(summary.supported_specifier_kinds.contains(&"tsconfigPaths"));
1229 assert!(
1230 summary
1231 .supported_specifier_kinds
1232 .contains(&"bundlerAliases")
1233 );
1234 assert!(summary.supported_specifier_kinds.contains(&"npmPackages"));
1235 assert!(
1236 summary
1237 .request_path_policy
1238 .contains(&"pathAliasResolutionFollowsRelativeTsconfigExtends")
1239 );
1240 assert!(
1241 summary
1242 .request_path_policy
1243 .contains(&"bundlerAliasResolutionUsesLiteralViteWebpackConfig")
1244 );
1245 assert!(
1246 summary
1247 .request_path_policy
1248 .contains(&"lspServerOwnsOnlyDocumentRoutingAndUriRangeMapping")
1249 );
1250 }
1251
1252 fn temp_dir(prefix: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
1253 let suffix = SystemTime::now()
1254 .duration_since(SystemTime::UNIX_EPOCH)?
1255 .as_nanos();
1256 let path = std::env::temp_dir().join(format!("{prefix}_{suffix}"));
1257 fs::create_dir_all(path.as_path())?;
1258 Ok(path)
1259 }
1260}