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}