1use std::{
2 collections::{BTreeMap, BTreeSet, VecDeque},
3 ffi::OsString,
4 fs,
5 path::{Component, Path, PathBuf},
6 sync::{Mutex, OnceLock},
7};
8
9use crate::bundler_config_alias::load_omena_bridge_workspace_bundler_path_alias_mappings;
10use omena_resolver::{
11 OmenaResolverBundlerPathAliasMappingV0, OmenaResolverStyleModuleConfirmationOptionsV0,
12 OmenaResolverStyleModuleDiskCandidateIdentityV0, OmenaResolverStylePackageManifestV0,
13 OmenaResolverTsconfigPathMappingV0,
14 collect_omena_resolver_style_module_source_candidates_with_path_mappings,
15 confirm_omena_resolver_style_module_candidate_with_options,
16 is_omena_resolver_indexable_style_module_path,
17 normalize_omena_resolver_style_module_source_for_routing,
18};
19use omena_sif::{
20 OmenaLifExportsV1, OmenaSifSourceSyntaxV1, OmenaSifStaticGeneratorInputV1, OmenaSifV1,
21 compute_omena_sif_leaf_hash_v1, generate_static_omena_lif_exports_v1,
22 generate_static_omena_sif_v1, read_omena_sif_json_v1, write_omena_canonical_json_bytes_v1,
23 write_omena_sif_json_v1,
24};
25use serde::Serialize;
26use serde_json::{Value, json};
27
28const WORKSPACE_PACKAGE_MANIFEST_SCAN_LIMIT: usize = 1024;
29const EXTERNAL_SIF_CACHE_SCHEMA_VERSION: &str = "0";
30const EXTERNAL_SIF_CACHE_PRODUCT: &str = "omena-bridge.external-sif-cache-shard";
31const EXTERNAL_SIF_CACHE_RELATIVE_DIR: &str = ".cache/omena/external-sif-v0";
32const EXTERNAL_SIF_CACHE_ENV_KILL_SWITCH: &str = "OMENA_BRIDGE_EXTERNAL_SIF_CACHE";
33const EXTERNAL_SIF_CACHE_MAX_MEMORY_ENTRIES: usize = 256;
34const EXTERNAL_SIF_CACHE_MAX_SHARDS: usize = 2048;
35const EXTERNAL_SIF_CACHE_MAX_TOTAL_BYTES: u64 = 256 * 1024 * 1024;
36const EXTERNAL_SIF_CACHE_MAX_SHARD_BYTES: u64 = 8 * 1024 * 1024;
37const WORKSPACE_STYLE_PATH_IDENTITY_SCAN_LIMIT: usize = 4096;
38const WORKSPACE_STYLE_PATH_IDENTITY_MAX_DEPTH: usize = 8;
39
40static EXTERNAL_SIF_MEMORY_CACHE: OnceLock<Mutex<BTreeMap<String, OmenaSifV1>>> = OnceLock::new();
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
43#[serde(rename_all = "camelCase")]
44pub struct OmenaBridgeStyleResolutionSummaryV0 {
45 pub schema_version: &'static str,
46 pub product: &'static str,
47 pub owner_crate: &'static str,
48 pub resolver_name: &'static str,
49 pub supported_specifier_kinds: Vec<&'static str>,
50 pub candidate_extensions: Vec<&'static str>,
51 pub request_path_policy: Vec<&'static str>,
52}
53
54#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
55#[serde(rename_all = "camelCase")]
56pub struct OmenaBridgeStyleResolutionInputsV0 {
57 pub package_manifests: Vec<OmenaResolverStylePackageManifestV0>,
58 pub tsconfig_path_mappings: Vec<OmenaResolverTsconfigPathMappingV0>,
59 pub bundler_path_mappings: Vec<OmenaResolverBundlerPathAliasMappingV0>,
60 #[serde(skip_serializing_if = "Vec::is_empty")]
61 pub disk_style_path_identities: Vec<OmenaResolverStyleModuleDiskCandidateIdentityV0>,
62}
63
64#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
65#[serde(rename_all = "camelCase")]
66pub struct OmenaBridgeExternalSifCacheContextV0 {
67 #[serde(skip_serializing_if = "Option::is_none")]
68 pub freshness_fingerprint: Option<String>,
69}
70
71pub fn summarize_omena_bridge_style_resolution_boundary() -> OmenaBridgeStyleResolutionSummaryV0 {
72 OmenaBridgeStyleResolutionSummaryV0 {
73 schema_version: "0",
74 product: "omena-bridge.style-resolution",
75 owner_crate: "omena-bridge",
76 resolver_name: "style-import-specifier-resolver",
77 supported_specifier_kinds: vec![
78 "relative",
79 "tsconfigPaths",
80 "jsconfigPaths",
81 "bundlerAliases",
82 "npmPackages",
83 "packageImports",
84 ],
85 candidate_extensions: vec!["scss", "sass", "css", "less"],
86 request_path_policy: vec![
87 "resolverConsumesSourceUriWorkspaceUriAndRawSpecifier",
88 "relativeSpecifierExpandsStyleModuleCandidates",
89 "pathAliasResolutionUsesNearestWorkspaceTsconfigOrJsconfig",
90 "pathAliasResolutionFollowsRelativeTsconfigExtends",
91 "bundlerAliasResolutionUsesLiteralViteWebpackConfig",
92 "packageSpecifierResolutionUsesOmenaResolver",
93 "fileUriOutputIsPercentEncoded",
94 "lspServerOwnsOnlyDocumentRoutingAndUriRangeMapping",
95 ],
96 }
97}
98
99pub fn resolve_omena_bridge_style_uri_for_specifier(
100 source_uri: &str,
101 workspace_folder_uri: Option<&str>,
102 specifier: &str,
103) -> Option<String> {
104 resolve_omena_bridge_style_uri_for_specifier_with_package_manifests(
105 source_uri,
106 workspace_folder_uri,
107 specifier,
108 &[],
109 )
110}
111
112pub fn resolve_omena_bridge_style_uri_for_specifier_with_package_manifests(
113 source_uri: &str,
114 workspace_folder_uri: Option<&str>,
115 specifier: &str,
116 configured_package_manifests: &[OmenaResolverStylePackageManifestV0],
117) -> Option<String> {
118 let source_path = normalize_path(file_uri_to_path(source_uri)?);
119 let workspace_path = workspace_folder_uri
120 .and_then(file_uri_to_path)
121 .map(normalize_path);
122 let package_manifests = merged_package_manifests_for_request(
123 source_path.parent(),
124 workspace_path.as_deref(),
125 specifier,
126 configured_package_manifests,
127 );
128 let inputs = OmenaBridgeStyleResolutionInputsV0 {
129 package_manifests,
130 tsconfig_path_mappings: tsconfig_path_mappings_for_workspace(workspace_path.as_deref())
131 .unwrap_or_default(),
132 bundler_path_mappings: load_omena_bridge_workspace_bundler_path_alias_mappings(
133 workspace_path.as_deref(),
134 ),
135 disk_style_path_identities: workspace_path
136 .as_deref()
137 .map(workspace_style_path_identities)
138 .unwrap_or_default(),
139 };
140 resolve_omena_bridge_style_uri_for_specifier_with_resolution_inputs(
141 source_uri,
142 workspace_folder_uri,
143 specifier,
144 &inputs,
145 )
146}
147
148pub fn resolve_omena_bridge_style_uri_for_specifier_with_resolution_inputs(
149 source_uri: &str,
150 _workspace_folder_uri: Option<&str>,
151 specifier: &str,
152 resolution_inputs: &OmenaBridgeStyleResolutionInputsV0,
153) -> Option<String> {
154 let source_path = normalize_path(file_uri_to_path(source_uri)?);
155 let source_path_text = source_path.to_string_lossy().to_string();
156 let routing_specifier = normalize_omena_resolver_style_module_source_for_routing(specifier);
157 let requires_existing_candidate = (package_name_from_specifier(routing_specifier).is_some()
158 || is_package_import_specifier(routing_specifier))
159 && !resolution_inputs
160 .tsconfig_path_mappings
161 .iter()
162 .any(|mapping| {
163 tsconfig_path_pattern_matches(mapping.pattern.as_str(), routing_specifier)
164 })
165 && !resolution_inputs
166 .bundler_path_mappings
167 .iter()
168 .any(|mapping| {
169 bundler_path_alias_pattern_matches(mapping.pattern.as_str(), routing_specifier)
170 });
171 let candidates = collect_omena_resolver_style_module_source_candidates_with_path_mappings(
172 source_path_text.as_str(),
173 specifier,
174 resolution_inputs.package_manifests.as_slice(),
175 resolution_inputs.bundler_path_mappings.as_slice(),
176 resolution_inputs.tsconfig_path_mappings.as_slice(),
177 );
178
179 style_uri_for_resolver_candidates(
180 candidates.as_slice(),
181 resolution_inputs.disk_style_path_identities.as_slice(),
182 requires_existing_candidate,
183 )
184}
185
186pub fn generate_omena_bridge_sif_for_resolved_style_path(
199 resolved_path: &str,
200) -> Result<OmenaSifV1, String> {
201 generate_omena_bridge_sif_for_resolved_style_path_with_cache_context(
202 resolved_path,
203 &OmenaBridgeExternalSifCacheContextV0::default(),
204 )
205}
206
207pub fn generate_omena_bridge_lif_exports_for_resolved_style_path(
208 resolved_path: &str,
209) -> Result<OmenaLifExportsV1, String> {
210 let raw_path = raw_resolved_style_entry_path(resolved_path)
211 .ok_or_else(|| format!("unresolvable style module entry path: {resolved_path}"))?;
212 let path = normalize_path(raw_path);
213 let source = fs::read_to_string(path.as_path()).map_err(|error| {
214 format!(
215 "failed to read resolved style module {}: {error}",
216 path.to_string_lossy()
217 )
218 })?;
219 let syntax = infer_omena_bridge_sif_source_syntax(path.as_path());
220 Ok(generate_static_omena_lif_exports_v1(
221 OmenaSifStaticGeneratorInputV1 {
222 canonical_url: path_to_file_uri(path.as_path()).as_str(),
223 source: source.as_str(),
224 syntax,
225 },
226 ))
227}
228
229pub fn generate_omena_bridge_sif_for_resolved_style_path_with_cache_context(
230 resolved_path: &str,
231 cache_context: &OmenaBridgeExternalSifCacheContextV0,
232) -> Result<OmenaSifV1, String> {
233 let raw_path = raw_resolved_style_entry_path(resolved_path)
234 .ok_or_else(|| format!("unresolvable style module entry path: {resolved_path}"))?;
235 let path = normalize_path(raw_path.clone());
236 let canonical_url = path_to_file_uri(path.as_path());
237 let source_bytes = fs::read(path.as_path()).map_err(|error| {
238 format!(
239 "failed to read resolved style module {}: {error}",
240 path.to_string_lossy()
241 )
242 })?;
243 let source_hash = compute_omena_sif_leaf_hash_v1(source_bytes.as_slice())
244 .as_str()
245 .to_string();
246 let resolved_base_dir = path
247 .parent()
248 .map(|base_dir| base_dir.to_string_lossy().to_string())
249 .unwrap_or_default();
250 let cache_key = external_sif_cache_key(
251 source_hash.as_str(),
252 resolved_base_dir.as_str(),
253 canonical_url.as_str(),
254 cache_context.freshness_fingerprint.as_deref(),
255 );
256 if !external_sif_cache_kill_switch_engaged() {
257 if let Some(sif) = load_external_sif_from_memory_cache(cache_key.as_str()) {
258 return Ok(sif);
259 }
260 if let Some(cache_dir) = external_sif_cache_dir_for_path(raw_path.as_path())
261 && let Some(sif) = load_external_sif_cache_shard(
262 cache_dir.as_path(),
263 cache_key.as_str(),
264 canonical_url.as_str(),
265 source_hash.as_str(),
266 resolved_base_dir.as_str(),
267 )
268 {
269 store_external_sif_in_memory_cache(cache_key.clone(), sif.clone());
270 return Ok(sif);
271 }
272 }
273 let source = String::from_utf8(source_bytes).map_err(|error| {
274 format!(
275 "failed to decode resolved style module {} as utf-8: {error}",
276 path.to_string_lossy()
277 )
278 })?;
279 let syntax = infer_omena_bridge_sif_source_syntax(path.as_path());
280 let sif = generate_static_omena_sif_v1(OmenaSifStaticGeneratorInputV1 {
281 canonical_url: canonical_url.as_str(),
282 source: source.as_str(),
283 syntax,
284 })
285 .map_err(|error| format!("failed to generate SIF for {canonical_url}: {error}"))?;
286 if !external_sif_cache_kill_switch_engaged() {
287 store_external_sif_in_memory_cache(cache_key.clone(), sif.clone());
288 if let Some(cache_dir) = external_sif_cache_dir_for_path(raw_path.as_path()) {
289 store_external_sif_cache_shard(
290 cache_dir.as_path(),
291 cache_key.as_str(),
292 canonical_url.as_str(),
293 source_hash.as_str(),
294 resolved_base_dir.as_str(),
295 &sif,
296 );
297 }
298 }
299 Ok(sif)
300}
301
302fn raw_resolved_style_entry_path(resolved_path: &str) -> Option<PathBuf> {
303 let path = if resolved_path.starts_with("file://") {
304 file_uri_to_path(resolved_path)?
305 } else if resolved_path.is_empty() {
306 return None;
307 } else {
308 PathBuf::from(resolved_path)
309 };
310 Some(normalize_path_lexical(path))
311}
312
313fn external_sif_cache_key(
314 source_hash: &str,
315 resolved_base_dir: &str,
316 canonical_url: &str,
317 freshness_fingerprint: Option<&str>,
318) -> String {
319 let input = json!({
320 "schemaVersion": EXTERNAL_SIF_CACHE_SCHEMA_VERSION,
321 "product": "omena-bridge.external-sif-cache-key",
322 "crateVersion": env!("CARGO_PKG_VERSION"),
323 "sourceHash": source_hash,
324 "resolvedBaseDir": resolved_base_dir,
325 "canonicalUrl": canonical_url,
326 "freshnessFingerprint": freshness_fingerprint,
327 });
328 write_omena_canonical_json_bytes_v1(&input)
329 .map(|bytes| {
330 compute_omena_sif_leaf_hash_v1(bytes.as_slice())
331 .as_str()
332 .to_string()
333 })
334 .unwrap_or_else(|_| {
335 compute_omena_sif_leaf_hash_v1(
336 format!(
337 "{source_hash}\0{resolved_base_dir}\0{canonical_url}\0{}",
338 freshness_fingerprint.unwrap_or("")
339 )
340 .as_bytes(),
341 )
342 .as_str()
343 .to_string()
344 })
345}
346
347fn load_external_sif_from_memory_cache(key: &str) -> Option<OmenaSifV1> {
348 EXTERNAL_SIF_MEMORY_CACHE
349 .get_or_init(|| Mutex::new(BTreeMap::new()))
350 .lock()
351 .ok()?
352 .get(key)
353 .cloned()
354}
355
356fn store_external_sif_in_memory_cache(key: String, sif: OmenaSifV1) {
357 let Ok(mut cache) = EXTERNAL_SIF_MEMORY_CACHE
358 .get_or_init(|| Mutex::new(BTreeMap::new()))
359 .lock()
360 else {
361 return;
362 };
363 cache.insert(key, sif);
364 while cache.len() > EXTERNAL_SIF_CACHE_MAX_MEMORY_ENTRIES {
365 let Some(first_key) = cache.keys().next().cloned() else {
366 break;
367 };
368 cache.remove(first_key.as_str());
369 }
370}
371
372fn external_sif_cache_dir_for_path(path: &Path) -> Option<PathBuf> {
373 external_sif_cache_workspace_root(path).map(|root| root.join(EXTERNAL_SIF_CACHE_RELATIVE_DIR))
374}
375
376fn external_sif_cache_workspace_root(path: &Path) -> Option<PathBuf> {
377 let mut current = path.parent();
378 while let Some(dir) = current {
379 if dir.file_name().and_then(|value| value.to_str()) == Some("node_modules") {
380 return dir.parent().map(Path::to_path_buf);
381 }
382 current = dir.parent();
383 }
384
385 let mut current = path.parent();
386 while let Some(dir) = current {
387 if dir.join("package.json").is_file() {
388 return Some(dir.to_path_buf());
389 }
390 current = dir.parent();
391 }
392
393 path.parent().map(Path::to_path_buf)
394}
395
396fn external_sif_cache_shard_file_path(dir: &Path, key: &str) -> Option<PathBuf> {
397 let hex = key.strip_prefix("blake3:")?;
398 if hex.is_empty() || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
399 return None;
400 }
401 Some(dir.join(format!("{hex}.json")))
402}
403
404fn load_external_sif_cache_shard(
405 dir: &Path,
406 key: &str,
407 canonical_url: &str,
408 source_hash: &str,
409 resolved_base_dir: &str,
410) -> Option<OmenaSifV1> {
411 let shard_path = external_sif_cache_shard_file_path(dir, key)?;
412 let metadata = fs::metadata(shard_path.as_path()).ok()?;
413 if !metadata.is_file() || metadata.len() > EXTERNAL_SIF_CACHE_MAX_SHARD_BYTES {
414 let _ = fs::remove_file(shard_path.as_path());
415 return None;
416 }
417 let bytes = fs::read(shard_path.as_path()).ok()?;
418 let shard = serde_json::from_slice::<Value>(bytes.as_slice()).ok()?;
419 if !external_sif_cache_shard_matches(&shard, key, canonical_url, source_hash, resolved_base_dir)
420 {
421 let _ = fs::remove_file(shard_path.as_path());
422 return None;
423 }
424 let sif_json = shard.get("sifJson")?.as_str()?;
425 read_omena_sif_json_v1(sif_json).ok()
426}
427
428fn external_sif_cache_shard_matches(
429 shard: &Value,
430 key: &str,
431 canonical_url: &str,
432 source_hash: &str,
433 resolved_base_dir: &str,
434) -> bool {
435 shard.get("schemaVersion").and_then(Value::as_str) == Some(EXTERNAL_SIF_CACHE_SCHEMA_VERSION)
436 && shard.get("product").and_then(Value::as_str) == Some(EXTERNAL_SIF_CACHE_PRODUCT)
437 && shard.get("key").and_then(Value::as_str) == Some(key)
438 && shard.get("canonicalUrl").and_then(Value::as_str) == Some(canonical_url)
439 && shard.get("sourceHash").and_then(Value::as_str) == Some(source_hash)
440 && shard.get("resolvedBaseDir").and_then(Value::as_str) == Some(resolved_base_dir)
441 && shard.get("payloadDigest").and_then(Value::as_str)
442 == shard
443 .get("sifJson")
444 .and_then(Value::as_str)
445 .map(|sif_json| compute_omena_sif_leaf_hash_v1(sif_json.as_bytes()))
446 .as_ref()
447 .map(|digest| digest.as_str())
448}
449
450fn store_external_sif_cache_shard(
451 dir: &Path,
452 key: &str,
453 canonical_url: &str,
454 source_hash: &str,
455 resolved_base_dir: &str,
456 sif: &OmenaSifV1,
457) {
458 let Ok(sif_json) = write_omena_sif_json_v1(sif) else {
459 return;
460 };
461 let payload_digest = compute_omena_sif_leaf_hash_v1(sif_json.as_bytes())
462 .as_str()
463 .to_string();
464 let shard = json!({
465 "schemaVersion": EXTERNAL_SIF_CACHE_SCHEMA_VERSION,
466 "product": EXTERNAL_SIF_CACHE_PRODUCT,
467 "key": key,
468 "canonicalUrl": canonical_url,
469 "sourceHash": source_hash,
470 "resolvedBaseDir": resolved_base_dir,
471 "payloadDigest": payload_digest,
472 "sifJson": sif_json,
473 });
474 let Ok(bytes) = write_omena_canonical_json_bytes_v1(&shard) else {
475 return;
476 };
477 if bytes.len() as u64 > EXTERNAL_SIF_CACHE_MAX_SHARD_BYTES {
478 return;
479 }
480 if write_external_sif_cache_shard_atomically(dir, key, bytes.as_slice()).is_ok() {
481 enforce_external_sif_cache_caps(dir);
482 }
483}
484
485fn write_external_sif_cache_shard_atomically(
486 dir: &Path,
487 key: &str,
488 bytes: &[u8],
489) -> std::io::Result<()> {
490 fs::create_dir_all(dir)?;
491 let final_path = external_sif_cache_shard_file_path(dir, key).ok_or_else(|| {
492 std::io::Error::new(
493 std::io::ErrorKind::InvalidInput,
494 "invalid external SIF cache key",
495 )
496 })?;
497 let temporary_path = final_path.with_extension(format!("tmp-{}", std::process::id()));
498 fs::write(temporary_path.as_path(), bytes)?;
499 let renamed = fs::rename(temporary_path.as_path(), final_path.as_path());
500 if renamed.is_err() {
501 let _ = fs::remove_file(temporary_path.as_path());
502 if final_path.is_file() {
503 return Ok(());
504 }
505 }
506 renamed
507}
508
509fn enforce_external_sif_cache_caps(dir: &Path) {
510 let Ok(entries) = fs::read_dir(dir) else {
511 return;
512 };
513 let mut shards = entries
514 .flatten()
515 .filter_map(|entry| {
516 let path = entry.path();
517 if path.extension().and_then(|extension| extension.to_str()) != Some("json") {
518 return None;
519 }
520 let metadata = entry.metadata().ok()?;
521 if !metadata.is_file() {
522 return None;
523 }
524 let modified = metadata.modified().ok()?;
525 Some((modified, metadata.len(), path))
526 })
527 .collect::<Vec<_>>();
528 shards.sort_by(|left, right| left.0.cmp(&right.0).then_with(|| left.2.cmp(&right.2)));
529 let mut total_bytes = shards.iter().map(|(_, bytes, _)| *bytes).sum::<u64>();
530 let mut shard_count = shards.len();
531 for (_, bytes, path) in shards {
532 if shard_count <= 1
533 || (shard_count <= EXTERNAL_SIF_CACHE_MAX_SHARDS
534 && total_bytes <= EXTERNAL_SIF_CACHE_MAX_TOTAL_BYTES)
535 {
536 break;
537 }
538 if fs::remove_file(path.as_path()).is_ok() {
539 shard_count -= 1;
540 total_bytes = total_bytes.saturating_sub(bytes);
541 }
542 }
543}
544
545fn external_sif_cache_kill_switch_engaged() -> bool {
546 std::env::var(EXTERNAL_SIF_CACHE_ENV_KILL_SWITCH)
547 .is_ok_and(|value| value.eq_ignore_ascii_case("off") || value == "0" || value == "false")
548}
549
550#[cfg(test)]
551fn clear_external_sif_memory_cache_for_test() {
552 if let Some(cache) = EXTERNAL_SIF_MEMORY_CACHE.get()
553 && let Ok(mut cache) = cache.lock()
554 {
555 cache.clear();
556 }
557}
558
559fn infer_omena_bridge_sif_source_syntax(path: &Path) -> OmenaSifSourceSyntaxV1 {
560 match path
561 .extension()
562 .and_then(|extension| extension.to_str())
563 .map(str::to_ascii_lowercase)
564 .as_deref()
565 {
566 Some("css") => OmenaSifSourceSyntaxV1::Css,
567 Some("sass") => OmenaSifSourceSyntaxV1::Sass,
568 Some("less") => OmenaSifSourceSyntaxV1::Less,
569 _ => OmenaSifSourceSyntaxV1::Scss,
570 }
571}
572
573pub fn load_omena_bridge_workspace_style_resolution_inputs(
574 workspace_folder_uri: Option<&str>,
575 configured_package_manifests: &[OmenaResolverStylePackageManifestV0],
576) -> OmenaBridgeStyleResolutionInputsV0 {
577 let workspace_path = workspace_folder_uri
578 .and_then(file_uri_to_path)
579 .map(normalize_path);
580 load_omena_bridge_workspace_style_resolution_inputs_from_path(
581 workspace_path.as_deref(),
582 configured_package_manifests,
583 )
584}
585
586fn load_omena_bridge_workspace_style_resolution_inputs_from_path(
587 workspace_path: Option<&Path>,
588 configured_package_manifests: &[OmenaResolverStylePackageManifestV0],
589) -> OmenaBridgeStyleResolutionInputsV0 {
590 OmenaBridgeStyleResolutionInputsV0 {
591 package_manifests: merge_package_manifest_lists(
592 configured_package_manifests,
593 workspace_package_manifests(workspace_path).as_slice(),
594 ),
595 tsconfig_path_mappings: tsconfig_path_mappings_for_workspace(workspace_path)
596 .unwrap_or_default(),
597 bundler_path_mappings: load_omena_bridge_workspace_bundler_path_alias_mappings(
598 workspace_path,
599 ),
600 disk_style_path_identities: workspace_path
601 .map(workspace_style_path_identities)
602 .unwrap_or_default(),
603 }
604}
605
606fn workspace_style_path_identities(
607 workspace_path: &Path,
608) -> Vec<OmenaResolverStyleModuleDiskCandidateIdentityV0> {
609 let mut identities = Vec::new();
610 let mut queue = VecDeque::from([workspace_path.to_path_buf()]);
611 while let Some(dir) = queue.pop_front() {
612 if identities.len() >= WORKSPACE_STYLE_PATH_IDENTITY_SCAN_LIMIT {
613 break;
614 }
615 let Ok(entries) = fs::read_dir(dir.as_path()) else {
616 continue;
617 };
618 for entry in entries.flatten() {
619 if identities.len() >= WORKSPACE_STYLE_PATH_IDENTITY_SCAN_LIMIT {
620 break;
621 }
622 let path = entry.path();
623 let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
624 continue;
625 };
626 if path.is_dir() {
627 let relative_depth = path
628 .strip_prefix(workspace_path)
629 .ok()
630 .map(|relative| relative.components().count())
631 .unwrap_or(usize::MAX);
632 if relative_depth > WORKSPACE_STYLE_PATH_IDENTITY_MAX_DEPTH {
633 continue;
634 }
635 if should_skip_style_identity_scan_dir(file_name) {
636 continue;
637 }
638 queue.push_back(path);
639 continue;
640 }
641 if !is_indexable_style_path(path.as_path()) {
642 continue;
643 }
644 let Some(metadata_identity) = file_metadata_identity(path.as_path()) else {
645 continue;
646 };
647 identities.push(OmenaResolverStyleModuleDiskCandidateIdentityV0 {
648 style_path: normalize_path(path).to_string_lossy().to_string(),
649 metadata_identity,
650 });
651 }
652 }
653 identities.sort_by(|left, right| left.style_path.cmp(&right.style_path));
654 identities.dedup_by(|left, right| left.style_path == right.style_path);
655 identities
656}
657
658fn should_skip_style_identity_scan_dir(name: &str) -> bool {
659 matches!(
660 name,
661 ".git" | ".next" | ".nuxt" | ".svelte-kit" | "coverage" | "target"
662 )
663}
664
665fn file_metadata_identity(path: &Path) -> Option<String> {
666 let metadata = fs::symlink_metadata(path).ok()?;
667 let modified = metadata
668 .modified()
669 .ok()
670 .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
671 .map(|duration| format!("{}.{:09}", duration.as_secs(), duration.subsec_nanos()))
672 .unwrap_or_else(|| "unknownMtime".to_string());
673 let file_type = if metadata.file_type().is_symlink() {
674 "symlink"
675 } else if metadata.is_file() {
676 "file"
677 } else {
678 "other"
679 };
680 Some(format!("{file_type}|len{}|mtime{modified}", metadata.len()))
681}
682
683fn merge_package_manifest_lists(
684 primary: &[OmenaResolverStylePackageManifestV0],
685 secondary: &[OmenaResolverStylePackageManifestV0],
686) -> Vec<OmenaResolverStylePackageManifestV0> {
687 let mut manifests = primary.to_vec();
688 let mut seen = manifests
689 .iter()
690 .map(|manifest| manifest.package_json_path.clone())
691 .collect::<BTreeSet<_>>();
692 for manifest in secondary {
693 if seen.insert(manifest.package_json_path.clone()) {
694 manifests.push(manifest.clone());
695 }
696 }
697 manifests
698}
699
700fn merged_package_manifests_for_specifier(
701 source_dir: Option<&Path>,
702 specifier: &str,
703 configured_package_manifests: &[OmenaResolverStylePackageManifestV0],
704) -> Vec<OmenaResolverStylePackageManifestV0> {
705 merge_package_manifest_lists(
706 configured_package_manifests,
707 package_manifests_for_specifier(source_dir, specifier)
708 .unwrap_or_default()
709 .as_slice(),
710 )
711}
712
713fn merged_package_manifests_for_request(
714 source_dir: Option<&Path>,
715 workspace_path: Option<&Path>,
716 specifier: &str,
717 configured_package_manifests: &[OmenaResolverStylePackageManifestV0],
718) -> Vec<OmenaResolverStylePackageManifestV0> {
719 let source_manifests =
720 merged_package_manifests_for_specifier(source_dir, specifier, configured_package_manifests);
721 merge_package_manifest_lists(
722 source_manifests.as_slice(),
723 workspace_package_manifests(workspace_path).as_slice(),
724 )
725}
726
727fn tsconfig_path_mappings_for_workspace(
728 workspace_path: Option<&Path>,
729) -> Option<Vec<OmenaResolverTsconfigPathMappingV0>> {
730 let workspace_path = workspace_path?;
731 let mut mappings = Vec::new();
732 for config_path in [
733 workspace_path.join("tsconfig.json"),
734 workspace_path.join("jsconfig.json"),
735 ] {
736 mappings.extend(tsconfig_path_mappings_for_config(config_path.as_path()));
737 }
738 Some(mappings)
739}
740
741fn tsconfig_path_mappings_for_config(
742 config_path: &Path,
743) -> Vec<OmenaResolverTsconfigPathMappingV0> {
744 tsconfig_path_mappings_for_config_with_seen(config_path, &mut BTreeSet::new())
745}
746
747fn tsconfig_path_mappings_for_config_with_seen(
748 config_path: &Path,
749 seen: &mut BTreeSet<PathBuf>,
750) -> Vec<OmenaResolverTsconfigPathMappingV0> {
751 let normalized_config_path = normalize_path(config_path.to_path_buf());
752 if !seen.insert(normalized_config_path.clone()) {
753 return Vec::new();
754 }
755 let Some(config_text) = fs::read_to_string(config_path).ok() else {
756 return Vec::new();
757 };
758 let Some(config) = serde_json::from_str::<Value>(config_text.as_str()).ok() else {
759 return Vec::new();
760 };
761 let own_mappings = tsconfig_path_mappings_from_value(config_path, &config).unwrap_or_default();
762 if !own_mappings.is_empty() {
763 return own_mappings;
764 }
765 resolve_tsconfig_extends_path(config_path, &config)
766 .map(|extends_path| {
767 tsconfig_path_mappings_for_config_with_seen(extends_path.as_path(), seen)
768 })
769 .unwrap_or_default()
770}
771
772fn tsconfig_path_mappings_from_value(
773 config_path: &Path,
774 config: &Value,
775) -> Option<Vec<OmenaResolverTsconfigPathMappingV0>> {
776 let compiler_options = config.get("compilerOptions")?;
777 let paths = compiler_options.get("paths")?.as_object()?;
778 let config_dir = config_path.parent()?;
779 let base_url = compiler_options
780 .get("baseUrl")
781 .and_then(Value::as_str)
782 .unwrap_or(".");
783 let base_path = normalize_path(config_dir.join(base_url));
784 let mut mappings = Vec::new();
785 for (pattern, targets) in paths {
786 let Some(targets) = targets.as_array() else {
787 continue;
788 };
789 let target_patterns = targets
790 .iter()
791 .filter_map(Value::as_str)
792 .map(ToString::to_string)
793 .collect::<Vec<_>>();
794 if target_patterns.is_empty() {
795 continue;
796 }
797 mappings.push(OmenaResolverTsconfigPathMappingV0 {
798 base_path: base_path.to_string_lossy().to_string(),
799 pattern: pattern.to_string(),
800 target_patterns,
801 });
802 }
803 Some(mappings)
804}
805
806fn resolve_tsconfig_extends_path(config_path: &Path, config: &Value) -> Option<PathBuf> {
807 let extends = config.get("extends")?.as_str()?;
808 if !extends.starts_with('.') {
809 return None;
810 }
811 let config_dir = config_path.parent()?;
812 let raw_path = config_dir.join(extends);
813 tsconfig_extends_candidates(raw_path)
814 .into_iter()
815 .find(|candidate| candidate.exists())
816}
817
818fn tsconfig_extends_candidates(path: PathBuf) -> Vec<PathBuf> {
819 if path.extension().is_some() {
820 return vec![path];
821 }
822 vec![path.with_extension("json"), path.join("tsconfig.json")]
823}
824
825fn package_manifests_for_specifier(
826 source_dir: Option<&Path>,
827 specifier: &str,
828) -> Option<Vec<OmenaResolverStylePackageManifestV0>> {
829 if is_package_import_specifier(specifier) {
830 return Some(package_scope_manifests_for_source_dir(source_dir));
831 }
832 let package_name = package_name_from_specifier(specifier)?;
833 let mut manifests = Vec::new();
834 let mut seen = BTreeSet::new();
835 let mut current_dir = source_dir;
836 while let Some(dir) = current_dir {
837 let package_json_path = dir
838 .join("node_modules")
839 .join(package_name)
840 .join("package.json");
841 if seen.insert(package_json_path.clone())
842 && let Ok(package_json_source) = fs::read_to_string(package_json_path.as_path())
843 {
844 manifests.push(OmenaResolverStylePackageManifestV0 {
845 package_json_path: normalize_path(package_json_path)
846 .to_string_lossy()
847 .to_string(),
848 package_json_source,
849 });
850 }
851 current_dir = dir.parent();
852 }
853 Some(manifests)
854}
855
856fn package_scope_manifests_for_source_dir(
857 source_dir: Option<&Path>,
858) -> Vec<OmenaResolverStylePackageManifestV0> {
859 let mut manifests = Vec::new();
860 let mut current_dir = source_dir;
861 while let Some(dir) = current_dir {
862 push_workspace_package_manifest(dir.join("package.json"), &mut manifests);
863 current_dir = dir.parent();
864 }
865 manifests
866}
867
868fn workspace_package_manifests(
869 workspace_path: Option<&Path>,
870) -> Vec<OmenaResolverStylePackageManifestV0> {
871 let Some(workspace_path) = workspace_path else {
872 return Vec::new();
873 };
874 let mut manifests = Vec::new();
875 push_workspace_package_manifest(workspace_path.join("package.json"), &mut manifests);
876
877 let node_modules = workspace_path.join("node_modules");
878 let Ok(entries) = fs::read_dir(node_modules.as_path()) else {
879 return manifests;
880 };
881 for entry in entries.flatten() {
882 if manifests.len() >= WORKSPACE_PACKAGE_MANIFEST_SCAN_LIMIT {
883 break;
884 }
885 let path = entry.path();
886 let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
887 continue;
888 };
889 if file_name.starts_with('@') {
890 push_scoped_workspace_package_manifests(path.as_path(), &mut manifests);
891 } else {
892 push_workspace_package_manifest(path.join("package.json"), &mut manifests);
893 }
894 }
895 manifests.sort_by(|left, right| left.package_json_path.cmp(&right.package_json_path));
896 manifests.dedup_by(|left, right| left.package_json_path == right.package_json_path);
897 manifests
898}
899
900fn push_scoped_workspace_package_manifests(
901 scope_path: &Path,
902 manifests: &mut Vec<OmenaResolverStylePackageManifestV0>,
903) {
904 let Ok(entries) = fs::read_dir(scope_path) else {
905 return;
906 };
907 for entry in entries.flatten() {
908 if manifests.len() >= WORKSPACE_PACKAGE_MANIFEST_SCAN_LIMIT {
909 return;
910 }
911 push_workspace_package_manifest(entry.path().join("package.json"), manifests);
912 }
913}
914
915fn push_workspace_package_manifest(
916 package_json_path: PathBuf,
917 manifests: &mut Vec<OmenaResolverStylePackageManifestV0>,
918) {
919 if manifests.len() >= WORKSPACE_PACKAGE_MANIFEST_SCAN_LIMIT {
920 return;
921 }
922 let normalized_package_json_path = normalize_path(package_json_path);
923 let package_json_path_text = normalized_package_json_path.to_string_lossy().to_string();
924 if manifests
925 .iter()
926 .any(|manifest| manifest.package_json_path == package_json_path_text)
927 {
928 return;
929 }
930 let Ok(package_json_source) = fs::read_to_string(normalized_package_json_path.as_path()) else {
931 return;
932 };
933 manifests.push(OmenaResolverStylePackageManifestV0 {
934 package_json_path: package_json_path_text,
935 package_json_source,
936 });
937}
938
939fn package_name_from_specifier(specifier: &str) -> Option<&str> {
940 let specifier = specifier.strip_prefix("pkg:").unwrap_or(specifier);
941 if specifier.starts_with('.')
942 || specifier.starts_with('/')
943 || is_package_import_specifier(specifier)
944 || is_external_style_specifier(specifier)
945 {
946 return None;
947 }
948 if specifier.starts_with('@') {
949 let mut segments = specifier.splitn(3, '/');
950 let scope = segments.next()?;
951 let package = segments.next()?;
952 if scope.len() <= 1 || package.is_empty() {
953 return None;
954 }
955 return specifier.get(..scope.len() + 1 + package.len());
956 }
957 specifier.split('/').next().filter(|name| !name.is_empty())
958}
959
960fn is_package_import_specifier(specifier: &str) -> bool {
961 specifier
962 .strip_prefix("pkg:")
963 .unwrap_or(specifier)
964 .starts_with('#')
965}
966
967fn tsconfig_path_pattern_matches(pattern: &str, specifier: &str) -> bool {
968 if let Some((prefix, suffix)) = pattern.split_once('*') {
969 return !suffix.contains('*')
970 && specifier.starts_with(prefix)
971 && specifier.ends_with(suffix)
972 && specifier.len() >= prefix.len() + suffix.len();
973 }
974 pattern == specifier
975}
976
977fn bundler_path_alias_pattern_matches(pattern: &str, specifier: &str) -> bool {
978 if pattern.is_empty() {
979 return false;
980 }
981 if let Some(exact_pattern) = pattern.strip_suffix('$') {
982 return specifier == exact_pattern;
983 }
984 if pattern == specifier {
985 return true;
986 }
987 let prefix = if pattern.ends_with('/') {
988 pattern.to_string()
989 } else {
990 format!("{pattern}/")
991 };
992 specifier.starts_with(prefix.as_str())
993}
994
995fn is_external_style_specifier(specifier: &str) -> bool {
996 specifier.starts_with("sass:")
997 || specifier.starts_with("http://")
998 || specifier.starts_with("https://")
999}
1000
1001fn style_uri_for_resolver_candidates(
1002 candidates: &[String],
1003 disk_style_path_identities: &[OmenaResolverStyleModuleDiskCandidateIdentityV0],
1004 requires_existing_candidate: bool,
1005) -> Option<String> {
1006 let empty_available = BTreeSet::new();
1007 let confirmation = confirm_omena_resolver_style_module_candidate_with_options(
1008 candidates,
1009 &empty_available,
1010 disk_style_path_identities,
1011 OmenaResolverStyleModuleConfirmationOptionsV0 {
1012 allow_disk_confirmation: true,
1013 allow_live_disk_confirmation: true,
1014 allow_unconfirmed_indexable_candidate: !requires_existing_candidate,
1015 ..OmenaResolverStyleModuleConfirmationOptionsV0::default()
1016 },
1017 );
1018 confirmation
1019 .resolved_style_path
1020 .map(PathBuf::from)
1021 .map(|path| path_to_file_uri(normalize_path(path).as_path()))
1022}
1023
1024fn is_indexable_style_path(path: &Path) -> bool {
1025 is_omena_resolver_indexable_style_module_path(path.to_string_lossy().as_ref())
1026}
1027
1028fn file_uri_to_path(uri: &str) -> Option<PathBuf> {
1029 let raw_path = uri.strip_prefix("file://")?;
1030 Some(PathBuf::from(percent_decode_uri_path(raw_path)?))
1031}
1032
1033fn percent_decode_uri_path(raw_path: &str) -> Option<String> {
1034 let bytes = raw_path.as_bytes();
1035 let mut decoded = Vec::with_capacity(bytes.len());
1036 let mut index = 0usize;
1037 while index < bytes.len() {
1038 if bytes[index] == b'%' {
1039 let high = bytes.get(index + 1).and_then(|byte| hex_value(*byte))?;
1040 let low = bytes.get(index + 2).and_then(|byte| hex_value(*byte))?;
1041 decoded.push((high << 4) | low);
1042 index += 3;
1043 } else {
1044 decoded.push(bytes[index]);
1045 index += 1;
1046 }
1047 }
1048 String::from_utf8(decoded).ok()
1049}
1050
1051fn hex_value(byte: u8) -> Option<u8> {
1052 match byte {
1053 b'0'..=b'9' => Some(byte - b'0'),
1054 b'a'..=b'f' => Some(byte - b'a' + 10),
1055 b'A'..=b'F' => Some(byte - b'A' + 10),
1056 _ => None,
1057 }
1058}
1059
1060fn path_to_file_uri(path: &Path) -> String {
1061 let path = normalize_path(path.to_path_buf());
1062 format!(
1063 "file://{}",
1064 percent_encode_uri_path(path.to_string_lossy().as_ref())
1065 )
1066}
1067
1068fn percent_encode_uri_path(path: &str) -> String {
1069 let mut encoded = String::with_capacity(path.len());
1070 for byte in path.as_bytes() {
1071 match *byte {
1072 b'A'..=b'Z'
1073 | b'a'..=b'z'
1074 | b'0'..=b'9'
1075 | b'-'
1076 | b'.'
1077 | b'_'
1078 | b'~'
1079 | b'/'
1080 | b'@'
1081 | b':'
1082 | b'!'
1083 | b'$'
1084 | b'&'
1085 | b'\''
1086 | b'*'
1087 | b'+'
1088 | b','
1089 | b';'
1090 | b'=' => encoded.push(*byte as char),
1091 _ => encoded.push_str(format!("%{byte:02X}").as_str()),
1092 }
1093 }
1094 encoded
1095}
1096
1097fn normalize_path(path: PathBuf) -> PathBuf {
1098 if let Some(canonical) = canonicalize_existing_path_or_parent(path.as_path()) {
1099 return normalize_path_lexical(canonical);
1100 }
1101 normalize_path_lexical(path)
1102}
1103
1104fn canonicalize_existing_path_or_parent(path: &Path) -> Option<PathBuf> {
1105 if let Ok(canonical) = fs::canonicalize(path) {
1106 return Some(canonical);
1107 }
1108
1109 let mut current = path.to_path_buf();
1110 let mut suffix = Vec::<OsString>::new();
1111 while let Some(parent) = current.parent() {
1112 if let Some(file_name) = current.file_name() {
1113 suffix.push(file_name.to_os_string());
1114 }
1115 if let Ok(mut canonical_parent) = fs::canonicalize(parent) {
1116 for segment in suffix.iter().rev() {
1117 canonical_parent.push(segment);
1118 }
1119 return Some(canonical_parent);
1120 }
1121 current = parent.to_path_buf();
1122 }
1123 None
1124}
1125
1126fn normalize_path_lexical(path: PathBuf) -> PathBuf {
1127 let mut normalized = PathBuf::new();
1128 for component in path.components() {
1129 match component {
1130 Component::CurDir => {}
1131 Component::ParentDir => {
1132 normalized.pop();
1133 }
1134 Component::Normal(_) | Component::RootDir | Component::Prefix(_) => {
1135 normalized.push(component.as_os_str());
1136 }
1137 }
1138 }
1139 normalized
1140}
1141
1142#[cfg(test)]
1143mod tests {
1144 use std::{fs, time::SystemTime};
1145
1146 use super::*;
1147
1148 #[test]
1149 fn resolves_relative_style_candidates() -> Result<(), Box<dyn std::error::Error>> {
1150 let root = temp_dir("omena_bridge_style_relative")?;
1151 let source = root.join("src/App.tsx");
1152 let style = root.join("src/Button.module.scss");
1153 fs::create_dir_all(
1154 source
1155 .parent()
1156 .ok_or_else(|| std::io::Error::other("parent"))?,
1157 )?;
1158 fs::write(&source, "")?;
1159 fs::write(&style, ".root {}")?;
1160
1161 let uri = resolve_omena_bridge_style_uri_for_specifier(
1162 path_to_file_uri(source.as_path()).as_str(),
1163 Some(path_to_file_uri(root.as_path()).as_str()),
1164 "./Button.module.scss",
1165 );
1166
1167 assert_eq!(
1168 uri.as_deref(),
1169 Some(path_to_file_uri(style.as_path()).as_str())
1170 );
1171 let _ = fs::remove_dir_all(root);
1172 Ok(())
1173 }
1174
1175 #[test]
1176 fn generates_sif_for_resolved_relative_style_module() -> Result<(), Box<dyn std::error::Error>>
1177 {
1178 let root = temp_dir("omena_bridge_sif_resolved")?;
1179 let source = root.join("src/App.tsx");
1180 let style = root.join("src/theme.scss");
1181 fs::create_dir_all(
1182 style
1183 .parent()
1184 .ok_or_else(|| std::io::Error::other("parent"))?,
1185 )?;
1186 fs::write(&source, "")?;
1187 fs::write(&style, "$brand: #0af;\n@mixin focus-ring {}\n")?;
1188
1189 let resolved = resolve_omena_bridge_style_uri_for_specifier(
1190 path_to_file_uri(source.as_path()).as_str(),
1191 Some(path_to_file_uri(root.as_path()).as_str()),
1192 "./theme.scss",
1193 )
1194 .ok_or_else(|| std::io::Error::other("resolution failed"))?;
1195
1196 let sif = generate_omena_bridge_sif_for_resolved_style_path(resolved.as_str())?;
1197
1198 assert_eq!(sif.canonical_url, resolved);
1199 assert_eq!(sif.source.syntax, OmenaSifSourceSyntaxV1::Scss);
1200 assert!(
1201 sif.exports
1202 .variables
1203 .iter()
1204 .any(|variable| variable.name == "$brand"),
1205 "expected $brand variable export, got {:?}",
1206 sif.exports.variables
1207 );
1208 assert!(
1209 sif.exports
1210 .mixins
1211 .iter()
1212 .any(|mixin| mixin.name == "focus-ring"),
1213 "expected focus-ring mixin export, got {:?}",
1214 sif.exports.mixins
1215 );
1216 let json = omena_sif::write_omena_sif_json_v1(&sif)?;
1219 let parsed = omena_sif::read_omena_sif_json_v1(json.as_str())?;
1220 assert_eq!(parsed, sif);
1221 let _ = fs::remove_dir_all(root);
1222 Ok(())
1223 }
1224
1225 #[test]
1226 fn generates_lif_exports_for_resolved_less_specifier() -> Result<(), Box<dyn std::error::Error>>
1227 {
1228 let root = temp_dir("omena_bridge_lif_resolved_less")?;
1229 let source = root.join("src/App.tsx");
1230 let style = root.join("src/tokens.less");
1231 fs::create_dir_all(
1232 style
1233 .parent()
1234 .ok_or_else(|| std::io::Error::other("parent"))?,
1235 )?;
1236 fs::write(&source, "")?;
1237 fs::write(
1238 &style,
1239 "@brand: #fff;\n@tokens: { primary: @brand; @gap: 2px; };\n.button(@gap: 1rem) when (@gap > 0) { color: @brand; }\n",
1240 )?;
1241
1242 let resolved = resolve_omena_bridge_style_uri_for_specifier(
1243 path_to_file_uri(source.as_path()).as_str(),
1244 Some(path_to_file_uri(root.as_path()).as_str()),
1245 "./tokens.less",
1246 )
1247 .ok_or_else(|| std::io::Error::other("Less resolution failed"))?;
1248
1249 let exports = generate_omena_bridge_lif_exports_for_resolved_style_path(resolved.as_str())?;
1250
1251 assert_eq!(exports.less_variables[0].name, "@brand");
1252 assert_eq!(
1253 exports.less_variables[0].value_repr.as_deref(),
1254 Some("#fff")
1255 );
1256 assert_eq!(exports.less_mixins[0].name, ".button");
1257 assert!(exports.less_mixins[0].guarded);
1258 assert_eq!(exports.less_detached_rulesets[0].name, "@tokens");
1259 assert_eq!(
1260 exports.less_detached_rulesets[0].member_names,
1261 vec!["@gap", "primary"]
1262 );
1263 let _ = fs::remove_dir_all(root);
1264 Ok(())
1265 }
1266
1267 #[test]
1268 fn generates_sif_from_plain_resolved_path() -> Result<(), Box<dyn std::error::Error>> {
1269 let root = temp_dir("omena_bridge_sif_plain")?;
1270 let style = root.join("tokens.sass");
1271 fs::write(&style, "$gap: 8px\n")?;
1272
1273 let sif =
1274 generate_omena_bridge_sif_for_resolved_style_path(style.to_string_lossy().as_ref())?;
1275
1276 assert_eq!(sif.source.syntax, OmenaSifSourceSyntaxV1::Sass);
1277 let _ = fs::remove_dir_all(root);
1278 Ok(())
1279 }
1280
1281 #[test]
1282 fn generates_sif_with_less_source_syntax() -> Result<(), Box<dyn std::error::Error>> {
1283 let root = temp_dir("omena_bridge_sif_less")?;
1284 let style = root.join("tokens.less");
1285 fs::write(&style, "@gap: 8px;\n.button { margin: @gap; }\n")?;
1286
1287 let sif =
1288 generate_omena_bridge_sif_for_resolved_style_path(style.to_string_lossy().as_ref())?;
1289
1290 assert_eq!(sif.source.syntax, OmenaSifSourceSyntaxV1::Less);
1291 let json = omena_sif::write_omena_sif_json_v1(&sif)?;
1292 assert!(json.contains(r#""syntax":"less""#));
1293 let _ = fs::remove_dir_all(root);
1294 Ok(())
1295 }
1296
1297 #[test]
1298 fn generates_lif_exports_for_resolved_less_path() -> Result<(), Box<dyn std::error::Error>> {
1299 let root = temp_dir("omena_bridge_lif_less")?;
1300 let style = root.join("tokens.less");
1301 fs::write(
1302 &style,
1303 "@brand: red;\n@tokens: { primary: @brand; };\n.button(@gap: 1rem) { color: @brand; }\n",
1304 )?;
1305
1306 let exports = generate_omena_bridge_lif_exports_for_resolved_style_path(
1307 style.to_string_lossy().as_ref(),
1308 )?;
1309
1310 assert_eq!(exports.less_variables[0].name, "@brand");
1311 assert_eq!(exports.less_mixins[0].name, ".button");
1312 assert_eq!(exports.less_detached_rulesets[0].name, "@tokens");
1313 assert_eq!(
1314 exports.less_detached_rulesets[0].member_names,
1315 vec!["primary"]
1316 );
1317 let _ = fs::remove_dir_all(root);
1318 Ok(())
1319 }
1320
1321 #[test]
1322 fn external_sif_cache_key_is_base_dir_sensitive_and_serves_fresh_sif()
1323 -> Result<(), Box<dyn std::error::Error>> {
1324 let root = temp_dir("omena_bridge_external_sif_cache")?;
1325 let first_dir = root.join("node_modules/design-a");
1326 let second_dir = root.join("node_modules/design-b");
1327 fs::create_dir_all(first_dir.as_path())?;
1328 fs::create_dir_all(second_dir.as_path())?;
1329 fs::write(root.join("package.json"), r#"{"name":"workspace"}"#)?;
1330 let first_style = first_dir.join("tokens.scss");
1331 let second_style = second_dir.join("tokens.scss");
1332 let source = "$brand: #0af;\n";
1333 fs::write(first_style.as_path(), source)?;
1334 fs::write(second_style.as_path(), source)?;
1335
1336 let first_path = normalize_path(first_style.clone());
1337 let second_path = normalize_path(second_style.clone());
1338 let source_hash = compute_omena_sif_leaf_hash_v1(source.as_bytes())
1339 .as_str()
1340 .to_string();
1341 let first_base_dir = first_path
1342 .parent()
1343 .ok_or_else(|| std::io::Error::other("first parent"))?
1344 .to_string_lossy()
1345 .to_string();
1346 let second_base_dir = second_path
1347 .parent()
1348 .ok_or_else(|| std::io::Error::other("second parent"))?
1349 .to_string_lossy()
1350 .to_string();
1351 let first_key = external_sif_cache_key(
1352 source_hash.as_str(),
1353 first_base_dir.as_str(),
1354 path_to_file_uri(first_path.as_path()).as_str(),
1355 None,
1356 );
1357 let second_key = external_sif_cache_key(
1358 source_hash.as_str(),
1359 second_base_dir.as_str(),
1360 path_to_file_uri(second_path.as_path()).as_str(),
1361 None,
1362 );
1363 assert_ne!(
1364 first_key, second_key,
1365 "same bytes under different resolved bases must not share an external SIF cache key"
1366 );
1367 let old_fingerprint_key = external_sif_cache_key(
1368 source_hash.as_str(),
1369 first_base_dir.as_str(),
1370 path_to_file_uri(first_path.as_path()).as_str(),
1371 Some("lockfile:old"),
1372 );
1373 let new_fingerprint_key = external_sif_cache_key(
1374 source_hash.as_str(),
1375 first_base_dir.as_str(),
1376 path_to_file_uri(first_path.as_path()).as_str(),
1377 Some("lockfile:new"),
1378 );
1379 assert_ne!(
1380 old_fingerprint_key, new_fingerprint_key,
1381 "lockfile or package-manager freshness changes must invalidate external SIF cache keys"
1382 );
1383
1384 let first_uri = path_to_file_uri(first_style.as_path());
1385 let fresh = generate_omena_bridge_sif_for_resolved_style_path(first_uri.as_str())?;
1386 clear_external_sif_memory_cache_for_test();
1387 let cached = generate_omena_bridge_sif_for_resolved_style_path(first_uri.as_str())?;
1388 assert_eq!(cached, fresh);
1389 let cache_dir = external_sif_cache_dir_for_path(first_style.as_path())
1390 .ok_or_else(|| std::io::Error::other("cache dir"))?;
1391 assert!(
1392 cache_dir.read_dir()?.flatten().any(|entry| entry
1393 .path()
1394 .extension()
1395 .and_then(|ext| ext.to_str())
1396 == Some("json")),
1397 "expected a disk external SIF cache shard in {}",
1398 cache_dir.display()
1399 );
1400 let _ = fs::remove_dir_all(root);
1401 Ok(())
1402 }
1403
1404 #[test]
1405 fn errors_gracefully_for_missing_resolved_style_module() {
1406 let missing = std::env::temp_dir().join("omena_bridge_sif_missing/does-not-exist.scss");
1407 let result =
1408 generate_omena_bridge_sif_for_resolved_style_path(missing.to_string_lossy().as_ref());
1409 assert!(result.is_err(), "expected error for missing entry");
1410 }
1411
1412 #[test]
1413 fn errors_gracefully_for_empty_resolved_path() {
1414 let result = generate_omena_bridge_sif_for_resolved_style_path("");
1415 assert!(result.is_err(), "expected error for empty path");
1416 }
1417
1418 #[test]
1419 fn resolves_tsconfig_path_alias_style_candidates() -> Result<(), Box<dyn std::error::Error>> {
1420 let root = temp_dir("omena_bridge_style_alias")?;
1421 let source = root.join("src/App.tsx");
1422 let style = root.join("src/styles/Button.module.scss");
1423 fs::create_dir_all(
1424 style
1425 .parent()
1426 .ok_or_else(|| std::io::Error::other("parent"))?,
1427 )?;
1428 fs::write(&source, "")?;
1429 fs::write(&style, ".root {}")?;
1430 fs::write(
1431 root.join("tsconfig.json"),
1432 r#"{"compilerOptions":{"baseUrl":".","paths":{"@styles/*":["src/styles/*"]}}}"#,
1433 )?;
1434
1435 let uri = resolve_omena_bridge_style_uri_for_specifier(
1436 path_to_file_uri(source.as_path()).as_str(),
1437 Some(path_to_file_uri(root.as_path()).as_str()),
1438 "@styles/Button.module.scss",
1439 );
1440
1441 assert_eq!(
1442 uri.as_deref(),
1443 Some(path_to_file_uri(style.as_path()).as_str())
1444 );
1445 let _ = fs::remove_dir_all(root);
1446 Ok(())
1447 }
1448
1449 #[test]
1450 fn resolves_tsconfig_extends_path_alias_style_candidates()
1451 -> Result<(), Box<dyn std::error::Error>> {
1452 let root = temp_dir("omena_bridge_style_alias_extends")?;
1453 let source = root.join("src/App.tsx");
1454 let style = root.join("src/shared/Button.module.scss");
1455 let config_dir = root.join("config");
1456 fs::create_dir_all(
1457 style
1458 .parent()
1459 .ok_or_else(|| std::io::Error::other("parent"))?,
1460 )?;
1461 fs::create_dir_all(config_dir.as_path())?;
1462 fs::write(&source, "")?;
1463 fs::write(&style, ".root {}")?;
1464 fs::write(
1465 config_dir.join("base.json"),
1466 r#"{"compilerOptions":{"baseUrl":"..","paths":{"$shared/*":["src/shared/*"]}}}"#,
1467 )?;
1468 fs::write(root.join("tsconfig.json"), r#"{"extends":"./config/base"}"#)?;
1469
1470 let uri = resolve_omena_bridge_style_uri_for_specifier(
1471 path_to_file_uri(source.as_path()).as_str(),
1472 Some(path_to_file_uri(root.as_path()).as_str()),
1473 "$shared/Button.module.scss",
1474 );
1475
1476 assert_eq!(
1477 uri.as_deref(),
1478 Some(path_to_file_uri(style.as_path()).as_str())
1479 );
1480 let _ = fs::remove_dir_all(root);
1481 Ok(())
1482 }
1483
1484 #[test]
1485 fn tsconfig_extends_child_paths_override_parent_paths() -> Result<(), Box<dyn std::error::Error>>
1486 {
1487 let root = temp_dir("omena_bridge_style_alias_extends_override")?;
1488 let source = root.join("src/App.tsx");
1489 let parent_style = root.join("src/parent/Button.module.scss");
1490 let child_style = root.join("src/child/Button.module.scss");
1491 fs::create_dir_all(
1492 parent_style
1493 .parent()
1494 .ok_or_else(|| std::io::Error::other("parent"))?,
1495 )?;
1496 fs::create_dir_all(
1497 child_style
1498 .parent()
1499 .ok_or_else(|| std::io::Error::other("child"))?,
1500 )?;
1501 fs::write(&source, "")?;
1502 fs::write(&parent_style, ".root { color: red; }")?;
1503 fs::write(&child_style, ".root { color: green; }")?;
1504 fs::write(
1505 root.join("base.json"),
1506 r#"{"compilerOptions":{"baseUrl":".","paths":{"$shared/*":["src/parent/*"]}}}"#,
1507 )?;
1508 fs::write(
1509 root.join("tsconfig.json"),
1510 r#"{"extends":"./base.json","compilerOptions":{"baseUrl":".","paths":{"$shared/*":["src/child/*"]}}}"#,
1511 )?;
1512
1513 let uri = resolve_omena_bridge_style_uri_for_specifier(
1514 path_to_file_uri(source.as_path()).as_str(),
1515 Some(path_to_file_uri(root.as_path()).as_str()),
1516 "$shared/Button.module.scss",
1517 );
1518
1519 assert_eq!(
1520 uri.as_deref(),
1521 Some(path_to_file_uri(child_style.as_path()).as_str())
1522 );
1523 let _ = fs::remove_dir_all(root);
1524 Ok(())
1525 }
1526
1527 #[test]
1528 fn resolves_vite_bundler_alias_style_candidates() -> Result<(), Box<dyn std::error::Error>> {
1529 let root = temp_dir("omena_bridge_style_bundler_alias")?;
1530 let source = root.join("src/App.tsx");
1531 let style = root.join("src/styles/Button.module.scss");
1532 fs::create_dir_all(
1533 style
1534 .parent()
1535 .ok_or_else(|| std::io::Error::other("parent"))?,
1536 )?;
1537 fs::write(&source, "")?;
1538 fs::write(&style, ".root {}")?;
1539 fs::write(
1540 root.join("vite.config.ts"),
1541 r#"export default { resolve: { alias: { "@styles": "./src/styles" } } };"#,
1542 )?;
1543
1544 let uri = resolve_omena_bridge_style_uri_for_specifier(
1545 path_to_file_uri(source.as_path()).as_str(),
1546 Some(path_to_file_uri(root.as_path()).as_str()),
1547 "@styles/Button.module.scss",
1548 );
1549
1550 assert_eq!(
1551 uri.as_deref(),
1552 Some(path_to_file_uri(style.as_path()).as_str())
1553 );
1554 let _ = fs::remove_dir_all(root);
1555 Ok(())
1556 }
1557
1558 #[test]
1559 fn resolves_webpack_exact_bundler_alias_style_candidates()
1560 -> Result<(), Box<dyn std::error::Error>> {
1561 let root = temp_dir("omena_bridge_style_bundler_exact_alias")?;
1562 let source = root.join("src/App.tsx");
1563 let style = root.join("src/styles/index.module.scss");
1564 fs::create_dir_all(
1565 style
1566 .parent()
1567 .ok_or_else(|| std::io::Error::other("parent"))?,
1568 )?;
1569 fs::write(&source, "")?;
1570 fs::write(&style, ".root {}")?;
1571 fs::write(
1572 root.join("webpack.config.js"),
1573 r#"module.exports = { resolve: { alias: [{ find: "@theme$", replacement: "./src/styles/index.module.scss" }] } };"#,
1574 )?;
1575
1576 let exact_uri = resolve_omena_bridge_style_uri_for_specifier(
1577 path_to_file_uri(source.as_path()).as_str(),
1578 Some(path_to_file_uri(root.as_path()).as_str()),
1579 "@theme",
1580 );
1581 let prefix_uri = resolve_omena_bridge_style_uri_for_specifier(
1582 path_to_file_uri(source.as_path()).as_str(),
1583 Some(path_to_file_uri(root.as_path()).as_str()),
1584 "@theme/Button.module.scss",
1585 );
1586
1587 assert_eq!(
1588 exact_uri.as_deref(),
1589 Some(path_to_file_uri(style.as_path()).as_str())
1590 );
1591 assert!(prefix_uri.is_none());
1592 let _ = fs::remove_dir_all(root);
1593 Ok(())
1594 }
1595
1596 #[test]
1597 fn resolves_sass_style_candidates_without_legacy_language_filter()
1598 -> Result<(), Box<dyn std::error::Error>> {
1599 let root = temp_dir("omena_bridge_style_sass")?;
1600 let source = root.join("src/App.tsx");
1601 let style = root.join("src/Button.module.sass");
1602 fs::create_dir_all(
1603 source
1604 .parent()
1605 .ok_or_else(|| std::io::Error::other("parent"))?,
1606 )?;
1607 fs::write(&source, "")?;
1608 fs::write(&style, ".root\n color: red\n")?;
1609
1610 let uri = resolve_omena_bridge_style_uri_for_specifier(
1611 path_to_file_uri(source.as_path()).as_str(),
1612 Some(path_to_file_uri(root.as_path()).as_str()),
1613 "./Button.module.sass",
1614 );
1615
1616 assert_eq!(
1617 uri.as_deref(),
1618 Some(path_to_file_uri(style.as_path()).as_str())
1619 );
1620 let _ = fs::remove_dir_all(root);
1621 Ok(())
1622 }
1623
1624 #[test]
1625 fn resolves_package_style_candidates_through_omena_resolver()
1626 -> Result<(), Box<dyn std::error::Error>> {
1627 let root = temp_dir("omena_bridge_style_package")?;
1628 let source = root.join("src/App.module.scss");
1629 let package_root = root.join("node_modules/@design/tokens");
1630 let style = package_root.join("src/index.scss");
1631 fs::create_dir_all(
1632 style
1633 .parent()
1634 .ok_or_else(|| std::io::Error::other("parent"))?,
1635 )?;
1636 fs::create_dir_all(
1637 source
1638 .parent()
1639 .ok_or_else(|| std::io::Error::other("source parent"))?,
1640 )?;
1641 fs::write(&source, "@use \"@design/tokens\";")?;
1642 fs::write(
1643 package_root.join("package.json"),
1644 r#"{"sass":"src/index.scss"}"#,
1645 )?;
1646 fs::write(&style, "$gap: 1rem;")?;
1647
1648 let uri = resolve_omena_bridge_style_uri_for_specifier(
1649 path_to_file_uri(source.as_path()).as_str(),
1650 Some(path_to_file_uri(root.as_path()).as_str()),
1651 "@design/tokens",
1652 );
1653
1654 assert_eq!(
1655 uri.as_deref(),
1656 Some(path_to_file_uri(style.as_path()).as_str())
1657 );
1658 let _ = fs::remove_dir_all(root);
1659 Ok(())
1660 }
1661
1662 #[test]
1663 fn resolves_sass_pkg_style_candidates_through_manifest_discovery()
1664 -> Result<(), Box<dyn std::error::Error>> {
1665 let root = temp_dir("omena_bridge_style_pkg_manifest")?;
1666 let source = root.join("src/App.module.scss");
1667 let package_root = root.join("node_modules/@design/tokens");
1668 let style = package_root.join("dist/theme.scss");
1669 fs::create_dir_all(
1670 style
1671 .parent()
1672 .ok_or_else(|| std::io::Error::other("style parent"))?,
1673 )?;
1674 fs::create_dir_all(
1675 source
1676 .parent()
1677 .ok_or_else(|| std::io::Error::other("source parent"))?,
1678 )?;
1679 fs::write(&source, "@use \"pkg:@design/tokens/theme\";")?;
1680 fs::write(
1681 package_root.join("package.json"),
1682 r#"{"exports":{"./theme":{"sass":"./dist/theme.scss"}}}"#,
1683 )?;
1684 fs::write(&style, "$gap: 1rem;")?;
1685
1686 let uri = resolve_omena_bridge_style_uri_for_specifier(
1687 path_to_file_uri(source.as_path()).as_str(),
1688 Some(path_to_file_uri(root.as_path()).as_str()),
1689 "pkg:@design/tokens/theme",
1690 );
1691
1692 assert_eq!(
1693 uri.as_deref(),
1694 Some(path_to_file_uri(style.as_path()).as_str())
1695 );
1696 let _ = fs::remove_dir_all(root);
1697 Ok(())
1698 }
1699
1700 #[test]
1701 fn resolves_package_import_style_candidates_through_workspace_manifests()
1702 -> Result<(), Box<dyn std::error::Error>> {
1703 let root = temp_dir("omena_bridge_style_package_import_manifest")?;
1704 let source = root.join("src/App.module.scss");
1705 let package_root = root.join("node_modules/@design/tokens");
1706 let style = package_root.join("dist/theme.scss");
1707 fs::create_dir_all(
1708 style
1709 .parent()
1710 .ok_or_else(|| std::io::Error::other("style parent"))?,
1711 )?;
1712 fs::create_dir_all(
1713 source
1714 .parent()
1715 .ok_or_else(|| std::io::Error::other("source parent"))?,
1716 )?;
1717 fs::write(&source, "@use \"#theme\" as tokens;")?;
1718 fs::write(
1719 root.join("package.json"),
1720 r##"{"imports":{"#theme":"@design/tokens/theme"}}"##,
1721 )?;
1722 fs::write(
1723 package_root.join("package.json"),
1724 r#"{"exports":{"./theme":{"sass":"./dist/theme.scss"}}}"#,
1725 )?;
1726 fs::write(&style, "$gap: 1rem;")?;
1727
1728 let uri = resolve_omena_bridge_style_uri_for_specifier(
1729 path_to_file_uri(source.as_path()).as_str(),
1730 Some(path_to_file_uri(root.as_path()).as_str()),
1731 "#theme",
1732 );
1733
1734 assert_eq!(
1735 uri.as_deref(),
1736 Some(path_to_file_uri(style.as_path()).as_str())
1737 );
1738 let _ = fs::remove_dir_all(root);
1739 Ok(())
1740 }
1741
1742 #[cfg(unix)]
1743 #[test]
1744 fn resolves_symlinked_package_style_candidates_to_canonical_uri()
1745 -> Result<(), Box<dyn std::error::Error>> {
1746 let root = temp_dir("omena_bridge_style_symlinked_package")?;
1747 let source = root.join("src/App.module.scss");
1748 let real_package = root.join(".pnpm/@design+tokens@1.0.0/node_modules/@design/tokens");
1749 let linked_scope = root.join("node_modules/@design");
1750 let linked_package = linked_scope.join("tokens");
1751 let style = real_package.join("src/index.scss");
1752 fs::create_dir_all(
1753 style
1754 .parent()
1755 .ok_or_else(|| std::io::Error::other("style parent"))?,
1756 )?;
1757 fs::create_dir_all(
1758 source
1759 .parent()
1760 .ok_or_else(|| std::io::Error::other("source parent"))?,
1761 )?;
1762 fs::create_dir_all(linked_scope.as_path())?;
1763 fs::write(&source, "@use \"@design/tokens\";")?;
1764 fs::write(
1765 real_package.join("package.json"),
1766 r#"{"sass":"src/index.scss"}"#,
1767 )?;
1768 fs::write(&style, "$gap: 1rem;")?;
1769 std::os::unix::fs::symlink(real_package.as_path(), linked_package.as_path())?;
1770
1771 let uri = resolve_omena_bridge_style_uri_for_specifier(
1772 path_to_file_uri(source.as_path()).as_str(),
1773 Some(path_to_file_uri(root.as_path()).as_str()),
1774 "@design/tokens",
1775 );
1776 let expected_uri = path_to_file_uri(fs::canonicalize(style)?.as_path());
1777
1778 assert_eq!(uri.as_deref(), Some(expected_uri.as_str()));
1779 let _ = fs::remove_dir_all(root);
1780 Ok(())
1781 }
1782
1783 #[test]
1784 fn does_not_fabricate_missing_package_style_candidates()
1785 -> Result<(), Box<dyn std::error::Error>> {
1786 let root = temp_dir("omena_bridge_style_missing_package")?;
1787 let source = root.join("src/App.tsx");
1788 fs::create_dir_all(
1789 source
1790 .parent()
1791 .ok_or_else(|| std::io::Error::other("parent"))?,
1792 )?;
1793 fs::write(&source, "")?;
1794
1795 let uri = resolve_omena_bridge_style_uri_for_specifier(
1796 path_to_file_uri(source.as_path()).as_str(),
1797 Some(path_to_file_uri(root.as_path()).as_str()),
1798 "@design/tokens",
1799 );
1800
1801 assert!(uri.is_none(), "{uri:?}");
1802 let _ = fs::remove_dir_all(root);
1803 Ok(())
1804 }
1805
1806 #[test]
1807 fn emits_percent_encoded_file_uris_for_route_group_paths()
1808 -> Result<(), Box<dyn std::error::Error>> {
1809 let root = temp_dir("omena_bridge_style_route_group")?;
1810 let source = root.join("app/(marketing)/page.tsx");
1811 let style = root.join("app/(marketing)/Card.module.scss");
1812 fs::create_dir_all(
1813 source
1814 .parent()
1815 .ok_or_else(|| std::io::Error::other("parent"))?,
1816 )?;
1817 fs::write(&source, "")?;
1818 fs::write(&style, ".card {}")?;
1819
1820 let uri = resolve_omena_bridge_style_uri_for_specifier(
1821 path_to_file_uri(source.as_path()).as_str(),
1822 Some(path_to_file_uri(root.as_path()).as_str()),
1823 "./Card.module.scss",
1824 )
1825 .ok_or_else(|| std::io::Error::other("route group style should resolve"))?;
1826
1827 assert!(uri.contains("%28marketing%29"), "{uri}");
1828 assert_eq!(uri, path_to_file_uri(style.as_path()));
1829 let _ = fs::remove_dir_all(root);
1830 Ok(())
1831 }
1832
1833 #[test]
1834 fn declares_bridge_owned_style_resolution_boundary() {
1835 let summary = summarize_omena_bridge_style_resolution_boundary();
1836
1837 assert_eq!(summary.product, "omena-bridge.style-resolution");
1838 assert_eq!(summary.owner_crate, "omena-bridge");
1839 assert!(summary.supported_specifier_kinds.contains(&"tsconfigPaths"));
1840 assert!(
1841 summary
1842 .supported_specifier_kinds
1843 .contains(&"bundlerAliases")
1844 );
1845 assert!(summary.supported_specifier_kinds.contains(&"npmPackages"));
1846 assert!(
1847 summary
1848 .request_path_policy
1849 .contains(&"pathAliasResolutionFollowsRelativeTsconfigExtends")
1850 );
1851 assert!(
1852 summary
1853 .request_path_policy
1854 .contains(&"bundlerAliasResolutionUsesLiteralViteWebpackConfig")
1855 );
1856 assert!(
1857 summary
1858 .request_path_policy
1859 .contains(&"lspServerOwnsOnlyDocumentRoutingAndUriRangeMapping")
1860 );
1861 }
1862
1863 #[test]
1864 fn resolves_nested_next_config_alias_style_candidates() -> Result<(), Box<dyn std::error::Error>>
1865 {
1866 let root = temp_dir("omena_bridge_style_next_nested")?;
1867 let app_dir = root.join("apps/web");
1868 let source = app_dir.join("src/App.tsx");
1869 let style = app_dir.join("src/styles/Button.module.scss");
1870 fs::create_dir_all(
1871 style
1872 .parent()
1873 .ok_or_else(|| std::io::Error::other("style parent"))?,
1874 )?;
1875 fs::write(&source, "")?;
1876 fs::write(&style, ".root {}")?;
1877 fs::write(
1878 app_dir.join("next.config.mjs"),
1879 r#"export default { resolve: { alias: { "@styles": "./src/styles" } } };"#,
1880 )?;
1881
1882 let uri = resolve_omena_bridge_style_uri_for_specifier(
1883 path_to_file_uri(source.as_path()).as_str(),
1884 Some(path_to_file_uri(root.as_path()).as_str()),
1885 "@styles/Button.module.scss",
1886 );
1887
1888 assert_eq!(
1889 uri.as_deref(),
1890 Some(path_to_file_uri(style.as_path()).as_str())
1891 );
1892 let _ = fs::remove_dir_all(root);
1893 Ok(())
1894 }
1895
1896 #[test]
1897 fn resolves_tilde_package_style_candidates() -> Result<(), Box<dyn std::error::Error>> {
1898 let root = temp_dir("omena_bridge_style_tilde_package")?;
1899 let source = root.join("src/App.module.scss");
1900 let package_root = root.join("node_modules/@scope/theme");
1901 let style = package_root.join("index.scss");
1902 fs::create_dir_all(
1903 source
1904 .parent()
1905 .ok_or_else(|| std::io::Error::other("source parent"))?,
1906 )?;
1907 fs::create_dir_all(package_root.as_path())?;
1908 fs::write(&source, "@use \"~@scope/theme\";")?;
1909 fs::write(
1910 package_root.join("package.json"),
1911 r#"{"sass":"./index.scss"}"#,
1912 )?;
1913 fs::write(&style, "$brand: red;")?;
1914
1915 let uri = resolve_omena_bridge_style_uri_for_specifier(
1916 path_to_file_uri(source.as_path()).as_str(),
1917 Some(path_to_file_uri(root.as_path()).as_str()),
1918 "~@scope/theme",
1919 );
1920
1921 assert_eq!(
1922 uri.as_deref(),
1923 Some(path_to_file_uri(style.as_path()).as_str())
1924 );
1925 let _ = fs::remove_dir_all(root);
1926 Ok(())
1927 }
1928
1929 fn temp_dir(prefix: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
1930 let suffix = SystemTime::now()
1931 .duration_since(SystemTime::UNIX_EPOCH)?
1932 .as_nanos();
1933 let path = std::env::temp_dir().join(format!("{prefix}_{suffix}"));
1934 fs::create_dir_all(path.as_path())?;
1935 Ok(path)
1936 }
1937}