Skip to main content

omena_bridge/
style_resolution.rs

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
145/// Bridges the resolver→generator hop in-process: takes a resolved external
146/// style module entry (the `file://` URI returned by
147/// `resolve_omena_bridge_style_uri_for_specifier*`, or a plain filesystem
148/// path) and produces an [`OmenaSifV1`] by reading the entry's source and
149/// running the static SIF generator.
150///
151/// The returned SIF's `canonical_url` matches the resolved entry's `file://`
152/// URI so the query layer can pair it against import targets. The CLI converts
153/// each result into an `OmenaQueryExternalSifInputV0` without a JSON round-trip.
154///
155/// Errors gracefully (never panics) when the path is unresolvable, missing, or
156/// unreadable.
157pub 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        // The produced SIF must round-trip through the exact JSON contract the
771        // CLI's `read_external_sifs` consumes, proving it is a valid artifact.
772        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}