Skip to main content

omena_bridge/
source_imports.rs

1use omena_parser::ParserByteSpanV0;
2use oxc_allocator::Allocator;
3use oxc_ast::ast::{ImportDeclaration, ImportDeclarationSpecifier, ImportOrExportKind, Statement};
4use oxc_parser::{Parser, ParserReturn};
5use serde::{Deserialize, Serialize};
6
7use crate::source_language::{
8    project_source_for_language, recover_panicked_editor_source, source_type_for_language,
9};
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
12#[serde(rename_all = "camelCase")]
13pub struct SourceImportDeclarationSummaryV0 {
14    pub schema_version: &'static str,
15    pub product: &'static str,
16    pub import_count: usize,
17    pub imports: Vec<SourceImportDeclarationV0>,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
21#[serde(rename_all = "camelCase")]
22pub struct SourceImportDeclarationV0 {
23    pub binding: String,
24    pub specifier: String,
25    pub specifier_byte_span: ParserByteSpanV0,
26    pub declaration_id: String,
27}
28
29impl SourceImportDeclarationV0 {
30    pub fn style_resolution(&self, style_uri: &str) -> SourceStyleImportResolutionV0 {
31        SourceStyleImportResolutionV0 {
32            declaration_id: self.declaration_id.clone(),
33            style_uri: style_uri.to_string(),
34        }
35    }
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)]
39#[serde(rename_all = "camelCase")]
40pub struct SourceStyleImportResolutionV0 {
41    pub declaration_id: String,
42    pub style_uri: String,
43}
44
45pub fn summarize_omena_bridge_source_import_declarations(
46    source: &str,
47) -> SourceImportDeclarationSummaryV0 {
48    summarize_omena_bridge_source_import_declarations_for_path("source.tsx", source)
49}
50
51pub fn summarize_omena_bridge_source_import_declarations_for_path(
52    source_path: &str,
53    source: &str,
54) -> SourceImportDeclarationSummaryV0 {
55    summarize_omena_bridge_source_import_declarations_for_source_language(source_path, source, None)
56}
57
58pub fn summarize_omena_bridge_source_import_declarations_for_source_language(
59    source_path: &str,
60    source: &str,
61    source_language: Option<&str>,
62) -> SourceImportDeclarationSummaryV0 {
63    let projected_source = project_source_for_language(source_path, source, source_language);
64    let source_type = source_type_for_language(source_path, source_language);
65    let allocator = Allocator::default();
66    let ParserReturn {
67        program,
68        panicked,
69        diagnostics,
70        ..
71    } = Parser::new(&allocator, projected_source.as_ref(), source_type).parse();
72
73    let mut imports = Vec::new();
74    if !panicked {
75        push_import_declarations_from_program(source, &program.body, source.len(), &mut imports);
76    } else if let Some(recovered) = recover_panicked_editor_source(
77        projected_source.as_ref(),
78        source_type,
79        diagnostics
80            .iter()
81            .flat_map(|diagnostic| diagnostic.labels.as_slice())
82            .map(|label| label.offset() as usize)
83            .collect::<Vec<_>>()
84            .as_slice(),
85    ) {
86        let recovered_allocator = Allocator::default();
87        let recovered_parse =
88            Parser::new(&recovered_allocator, recovered.source.as_str(), source_type).parse();
89        push_import_declarations_from_program(
90            source,
91            &recovered_parse.program.body,
92            recovered.trusted_byte_end,
93            &mut imports,
94        );
95    }
96    canonicalize_import_declarations(&mut imports);
97
98    SourceImportDeclarationSummaryV0 {
99        schema_version: "0",
100        product: "omena-bridge.source-import-declarations",
101        import_count: imports.len(),
102        imports,
103    }
104}
105
106fn push_import_declarations_from_program(
107    source: &str,
108    statements: &[Statement<'_>],
109    trusted_byte_end: usize,
110    imports: &mut Vec<SourceImportDeclarationV0>,
111) {
112    for statement in statements {
113        if let Statement::ImportDeclaration(import) = statement
114            && import.span.end as usize <= trusted_byte_end
115        {
116            push_import_declarations_from_ast(source, import, imports);
117        }
118    }
119}
120
121fn push_import_declarations_from_ast(
122    source: &str,
123    import: &ImportDeclaration<'_>,
124    imports: &mut Vec<SourceImportDeclarationV0>,
125) {
126    if import.import_kind != ImportOrExportKind::Value {
127        return;
128    }
129    let Some(specifiers) = import.specifiers.as_ref() else {
130        return;
131    };
132    let specifier = import.source.value.as_str();
133    let literal_start = import.source.span.start as usize;
134    let literal_end = import.source.span.end as usize;
135    let specifier_byte_span = source
136        .get(literal_start..literal_end)
137        .filter(|literal| {
138            literal.len() >= 2
139                && matches!(literal.as_bytes().first(), Some(b'\'' | b'"'))
140                && literal.as_bytes().first() == literal.as_bytes().last()
141        })
142        .map_or(
143            ParserByteSpanV0 {
144                start: literal_start,
145                end: literal_end,
146            },
147            |_| ParserByteSpanV0 {
148                start: literal_start + 1,
149                end: literal_end - 1,
150            },
151        );
152
153    for specifier_item in specifiers {
154        match specifier_item {
155            ImportDeclarationSpecifier::ImportDefaultSpecifier(default_specifier) => {
156                let binding = default_specifier.local.name.as_str();
157                imports.push(SourceImportDeclarationV0 {
158                    declaration_id: source_declaration_id(
159                        source,
160                        "import",
161                        binding,
162                        default_specifier.local.span.start as usize,
163                        default_specifier.local.span.end as usize,
164                        specifier,
165                    ),
166                    binding: binding.to_string(),
167                    specifier: specifier.to_string(),
168                    specifier_byte_span,
169                });
170            }
171            ImportDeclarationSpecifier::ImportNamespaceSpecifier(namespace_specifier) => {
172                let binding = namespace_specifier.local.name.as_str();
173                imports.push(SourceImportDeclarationV0 {
174                    declaration_id: source_declaration_id(
175                        source,
176                        "import",
177                        binding,
178                        namespace_specifier.local.span.start as usize,
179                        namespace_specifier.local.span.end as usize,
180                        specifier,
181                    ),
182                    binding: binding.to_string(),
183                    specifier: specifier.to_string(),
184                    specifier_byte_span,
185                });
186            }
187            ImportDeclarationSpecifier::ImportSpecifier(_) => {}
188        }
189    }
190}
191
192pub(crate) fn source_declaration_id(
193    source: &str,
194    kind: &str,
195    binding: &str,
196    byte_start: usize,
197    byte_end: usize,
198    specifier: &str,
199) -> String {
200    let utf16_start = source
201        .get(..byte_start)
202        .map_or(byte_start, |prefix| prefix.encode_utf16().count());
203    let utf16_end = source
204        .get(..byte_end)
205        .map_or(byte_end, |prefix| prefix.encode_utf16().count());
206    format!("rust-decl:{kind}:{binding}:{utf16_start}:{utf16_end}:{specifier}")
207}
208
209fn canonicalize_import_declarations(imports: &mut Vec<SourceImportDeclarationV0>) {
210    imports.sort_by(|left, right| {
211        left.binding
212            .cmp(&right.binding)
213            .then_with(|| left.specifier.cmp(&right.specifier))
214            .then_with(|| left.declaration_id.cmp(&right.declaration_id))
215    });
216    imports.dedup();
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn extracts_default_and_namespace_imports_from_oxc_ast() {
225        let summary = summarize_omena_bridge_source_import_declarations_for_path(
226            "Component.tsx",
227            r#"
228import bind from "classnames/bind";
229import styles from "./Button.module.scss";
230import * as tokens from "./tokens.module.css";
231import { type BadgeProps } from "./types";
232const lazy = import("./ignored.module.scss");
233"#,
234        );
235
236        assert_eq!(summary.product, "omena-bridge.source-import-declarations");
237        assert_eq!(
238            summary
239                .imports
240                .iter()
241                .map(|import| (import.binding.as_str(), import.specifier.as_str()))
242                .collect::<Vec<_>>(),
243            vec![
244                ("bind", "classnames/bind"),
245                ("styles", "./Button.module.scss"),
246                ("tokens", "./tokens.module.css"),
247            ],
248        );
249    }
250
251    #[test]
252    fn ignores_import_like_strings_and_type_only_default_imports() {
253        let summary = summarize_omena_bridge_source_import_declarations_for_path(
254            "Component.tsx",
255            r#"
256const text = "import fake from './Fake.module.scss'";
257import type styles from "./Typed.module.scss";
258import real from "./Real.module.scss";
259"#,
260        );
261
262        assert_eq!(
263            summary
264                .imports
265                .iter()
266                .map(|import| (import.binding.as_str(), import.specifier.as_str()))
267                .collect::<Vec<_>>(),
268            vec![("real", "./Real.module.scss")],
269        );
270    }
271
272    #[test]
273    fn declaration_id_reuses_projection_identity_with_utf16_offsets() {
274        let source = "const label = '한글';\nimport styles from \"./Card.module.scss\";\n";
275        let summary = summarize_omena_bridge_source_import_declarations(source);
276        let declaration = &summary.imports[0];
277        let byte_start = source.find("styles").unwrap_or(usize::MAX);
278        assert_ne!(byte_start, usize::MAX);
279        let utf16_start = source[..byte_start].encode_utf16().count();
280        assert_eq!(
281            declaration.declaration_id,
282            format!(
283                "rust-decl:import:styles:{utf16_start}:{}:./Card.module.scss",
284                utf16_start + "styles".len()
285            )
286        );
287        let specifier_start = source.find("./Card.module.scss").unwrap_or(usize::MAX);
288        assert_ne!(specifier_start, usize::MAX);
289        assert_eq!(
290            declaration.specifier_byte_span,
291            ParserByteSpanV0 {
292                start: specifier_start,
293                end: specifier_start + "./Card.module.scss".len(),
294            }
295        );
296    }
297
298    #[test]
299    fn incomplete_editor_buffer_recovers_only_oxc_import_declarations() {
300        let source = r#"// import phantom from "./Phantom.module.scss";
301import styles from "./Card.module.scss";
302const value = styles.
303"#;
304        let summary = summarize_omena_bridge_source_import_declarations(source);
305
306        assert_eq!(summary.import_count, 1);
307        assert_eq!(summary.imports[0].binding, "styles");
308        assert_eq!(summary.imports[0].specifier, "./Card.module.scss");
309        assert!(!summary.imports[0].declaration_id.contains("phantom"));
310    }
311
312    #[test]
313    fn trusted_byte_end_accepts_only_complete_import_declarations() -> Result<(), String> {
314        let source = r#"import first from "./First.module.scss";
315import second from "./Second.module.scss";
316"#;
317        let allocator = Allocator::default();
318        let parsed = Parser::new(
319            &allocator,
320            source,
321            source_type_for_language("Component.tsx", None),
322        )
323        .parse();
324        assert!(!parsed.panicked);
325        let first_end = match parsed.program.body.first() {
326            Some(Statement::ImportDeclaration(import)) => import.span.end as usize,
327            _ => return Err("fixture must start with an import declaration".to_string()),
328        };
329
330        let mut exact_boundary = Vec::new();
331        push_import_declarations_from_program(
332            source,
333            parsed.program.body.as_slice(),
334            first_end,
335            &mut exact_boundary,
336        );
337        assert_eq!(
338            exact_boundary
339                .iter()
340                .map(|import| import.binding.as_str())
341                .collect::<Vec<_>>(),
342            vec!["first"]
343        );
344
345        let mut before_boundary = Vec::new();
346        push_import_declarations_from_program(
347            source,
348            parsed.program.body.as_slice(),
349            first_end - 1,
350            &mut before_boundary,
351        );
352        assert!(before_boundary.is_empty());
353
354        let mut full_source = Vec::new();
355        push_import_declarations_from_program(
356            source,
357            parsed.program.body.as_slice(),
358            source.len(),
359            &mut full_source,
360        );
361        assert_eq!(full_source.len(), 2);
362        Ok(())
363    }
364
365    #[test]
366    fn extracts_imports_from_vue_sfc_script_projection() {
367        let source = r#"<template><button /></template>
368<script setup lang="ts">
369import styles from "./Card.module.scss";
370const local = "not a style import";
371</script>
372<style module>
373.root {}
374</style>
375"#;
376        let summary = summarize_omena_bridge_source_import_declarations_for_source_language(
377            "Card.vue",
378            source,
379            Some("vue"),
380        );
381
382        assert_eq!(
383            summary
384                .imports
385                .iter()
386                .map(|import| (import.binding.as_str(), import.specifier.as_str()))
387                .collect::<Vec<_>>(),
388            vec![("styles", "./Card.module.scss")],
389        );
390    }
391
392    #[test]
393    fn extracts_imports_from_html_script_projection() {
394        let source = r#"<main>not script</main>
395<script type="module">
396import styles from "./Page.module.scss";
397</script>
398"#;
399        let summary = summarize_omena_bridge_source_import_declarations_for_source_language(
400            "Page.html",
401            source,
402            Some("html"),
403        );
404
405        assert_eq!(
406            summary
407                .imports
408                .iter()
409                .map(|import| (import.binding.as_str(), import.specifier.as_str()))
410                .collect::<Vec<_>>(),
411            vec![("styles", "./Page.module.scss")],
412        );
413    }
414}