Skip to main content

react_compiler_swc/
lib.rs

1// Copyright (c) Meta Platforms, Inc. and affiliates.
2//
3// This source code is licensed under the MIT license found in the
4// LICENSE file in the root directory of this source tree.
5
6pub mod convert_ast;
7pub mod convert_ast_reverse;
8pub mod convert_scope;
9pub mod apply_renames;
10pub mod diagnostics;
11pub mod prefilter;
12pub(crate) mod ts_namespace_export_fixup;
13
14use apply_renames::apply_renames;
15use convert_ast::convert_module_with_source_type;
16use convert_ast_reverse::convert_program_to_swc_with_source;
17use convert_scope::build_scope_info;
18use diagnostics::{compile_result_to_diagnostics, DiagnosticMessage};
19use prefilter::has_react_like_functions;
20use react_compiler::entrypoint::compile_result::LoggerEvent;
21use react_compiler::entrypoint::plugin_options::PluginOptions;
22use std::cell::RefCell;
23use swc_common::comments::Comments;
24
25/// Describes where a blank line should be inserted relative to a body item.
26#[derive(Clone, Debug)]
27pub enum BlankLinePosition {
28    /// Insert blank line before the item (including its leading comments).
29    /// The `first_code_line` is the item's first code line (without comments)
30    /// used as a search anchor in the output.
31    BeforeItem { first_code_line: String },
32    /// Insert blank line between the item's leading comments and its code.
33    /// The `first_code_line` is used to find where the code starts.
34    BeforeCode { first_code_line: String },
35}
36
37thread_local! {
38    /// Thread-local storage for comments from the last compilation.
39    /// Used by `emit` to include comments without API changes.
40    static LAST_COMMENTS: RefCell<Option<swc_common::comments::SingleThreadedComments>> = RefCell::new(None);
41
42    /// Thread-local storage for blank line positions.
43    /// Contains information about where to insert blank lines during emit.
44    static BLANK_LINE_POSITIONS: RefCell<Vec<BlankLinePosition>> = RefCell::new(Vec::new());
45}
46
47/// Result of compiling a program via the SWC frontend.
48pub struct TransformResult {
49    /// The compiled program as an SWC Module (None if no changes needed).
50    pub module: Option<swc_ecma_ast::Module>,
51    /// Comments extracted from the compiled AST (for use with `emit_with_comments`).
52    pub comments: Option<swc_common::comments::SingleThreadedComments>,
53    pub diagnostics: Vec<DiagnosticMessage>,
54    pub events: Vec<LoggerEvent>,
55}
56
57/// Result of linting a program via the SWC frontend.
58pub struct LintResult {
59    pub diagnostics: Vec<DiagnosticMessage>,
60}
61
62/// Primary transform API — accepts pre-parsed SWC Module.
63pub fn transform(
64    module: &swc_ecma_ast::Module,
65    source_text: &str,
66    options: PluginOptions,
67) -> TransformResult {
68    if options.compilation_mode != "all" && !has_react_like_functions(module) {
69        return TransformResult {
70            module: None,
71            comments: None,
72            diagnostics: vec![],
73            events: vec![],
74        };
75    }
76
77    // Detect source type from pragma. The @script pragma indicates
78    // CommonJS (script) mode, which affects how imports are emitted.
79    let source_type = if source_text
80        .lines()
81        .next()
82        .map_or(false, |line| line.contains("@script"))
83    {
84        react_compiler_ast::SourceType::Script
85    } else {
86        react_compiler_ast::SourceType::Module
87    };
88    let file = convert_module_with_source_type(module, source_text, source_type);
89    let scope_info = build_scope_info(module);
90    let result =
91        react_compiler::entrypoint::program::compile_program(file, scope_info, options);
92
93    let diagnostics = compile_result_to_diagnostics(&result);
94    let (program_ast, events, renames) = match result {
95        react_compiler::entrypoint::compile_result::CompileResult::Success {
96            ast,
97            events,
98            renames,
99            ..
100        } => (ast, events, renames),
101        react_compiler::entrypoint::compile_result::CompileResult::Error {
102            events, ..
103        } => (None, events, Vec::new()),
104    };
105
106    let conversion_result = program_ast.map(|file| {
107        convert_program_to_swc_with_source(&file, Some(source_text))
108    });
109
110    let (mut swc_module, mut comments) = match conversion_result {
111        Some(result) => (Some(result.module), Some(result.comments)),
112        None if !renames.is_empty() => (Some(module.clone()), None),
113        None => (None, None),
114    };
115
116    // If we have a compiled module, extract comments from the original source
117    // and merge them into the comment map. The Rust compiler does not preserve
118    // comments in its output, so we re-extract them from the source text.
119    if let Some(ref mut swc_mod) = swc_module {
120        use swc_common::Spanned;
121
122        // Compute blank line positions BEFORE span fixup, while spans still
123        // reflect original source positions. Babel's generator adds blank
124        // lines between consecutive items when the original source had blank
125        // lines between them (i.e., endLine(prev) + 1 < startLine(next)).
126        let blank_line_positions =
127            compute_blank_line_positions(&swc_mod.body, source_text);
128
129        // Fix up dummy spans on compiler-generated items: SWC codegen skips
130        // comments at BytePos(0) (DUMMY), so we give generated items a real
131        // span before the original module's first item.
132        let first_source_lo = module.body.first().map(|item| item.span().lo);
133        let mut top_level_comment_target = None;
134        if first_source_lo.is_some() {
135            let mut next_synthetic_pos = swc_common::BytePos(1);
136            for item in &mut swc_mod.body {
137                if item.span().lo.is_dummy() {
138                    let synthetic_span =
139                        swc_common::Span::new(next_synthetic_pos, next_synthetic_pos);
140                    next_synthetic_pos = next_synthetic_pos + swc_common::BytePos(1);
141                    match item {
142                        swc_ecma_ast::ModuleItem::ModuleDecl(
143                            swc_ecma_ast::ModuleDecl::Import(import),
144                        ) => {
145                            import.span = synthetic_span;
146                            top_level_comment_target = Some(import.span.hi);
147                        }
148                        swc_ecma_ast::ModuleItem::Stmt(
149                            swc_ecma_ast::Stmt::Decl(swc_ecma_ast::Decl::Var(var)),
150                        ) => {
151                            var.span = synthetic_span;
152                        }
153                        _ => {}
154                    }
155                }
156            }
157        }
158
159        apply_renames(swc_mod, &renames);
160
161        let (source_leading_comments, source_trailing_comments) =
162            extract_source_comments(source_text);
163        if !source_leading_comments.is_empty() || !source_trailing_comments.is_empty() {
164            let merged = comments.unwrap_or_default();
165
166            let source_bytes = source_text.as_bytes();
167            for (orig_pos, comment_list) in source_leading_comments {
168                // Pragma comments (e.g. `// @gating`) before the first source
169                // item need to attach AFTER any compiler-inserted imports so
170                // the gated output preserves the directive. Other leading
171                // comments (copyright, JSDoc, etc.) stay at their original
172                // position so SWC emits them before the original item.
173                let is_pragma = Some(orig_pos) == first_source_lo
174                    && comment_list
175                        .iter()
176                        .all(|c| c.text.trim_start().starts_with('@'));
177                if is_pragma {
178                    if let Some(pos) = top_level_comment_target {
179                        merged.add_trailing_comments(pos, comment_list);
180                        continue;
181                    }
182                }
183                merged.add_leading_comments(orig_pos, comment_list);
184            }
185            // Trailing comments after a `,` separator are stored by the SWC
186            // parser at the position past the comma, but codegen looks them
187            // up at the previous element's `span.hi`, which is before the
188            // comma. Shift those back by one. Trailing comments after other
189            // tokens (e.g. `;`) are already at the matching `span.hi`, so
190            // pass them through unchanged.
191            for (orig_pos, comment_list) in source_trailing_comments {
192                let idx = orig_pos.0 as usize;
193                let pos = if idx >= 2 && source_bytes.get(idx - 2) == Some(&b',') {
194                    swc_common::BytePos(orig_pos.0 - 1)
195                } else {
196                    orig_pos
197                };
198                merged.add_trailing_comments(pos, comment_list);
199            }
200            comments = Some(merged);
201        }
202
203        // Store blank line positions in thread-local for `emit` to use
204        BLANK_LINE_POSITIONS.with(|cell| {
205            *cell.borrow_mut() = blank_line_positions;
206        });
207    }
208
209    // Store comments in thread-local for `emit` to use
210    LAST_COMMENTS.with(|cell| {
211        *cell.borrow_mut() = comments.clone();
212    });
213
214    TransformResult {
215        module: swc_module,
216        comments,
217        diagnostics,
218        events,
219    }
220}
221
222/// Convenience wrapper — parses source text, then transforms.
223pub fn transform_source(source_text: &str, options: PluginOptions) -> TransformResult {
224    let cm = swc_common::sync::Lrc::new(swc_common::SourceMap::default());
225    let fm = cm.new_source_file(
226        swc_common::sync::Lrc::new(swc_common::FileName::Anon),
227        source_text.to_string(),
228    );
229
230    let mut errors = vec![];
231    let module = swc_ecma_parser::parse_file_as_module(
232        &fm,
233        swc_ecma_parser::Syntax::Es(swc_ecma_parser::EsSyntax {
234            jsx: true,
235            ..Default::default()
236        }),
237        swc_ecma_ast::EsVersion::latest(),
238        None,
239        &mut errors,
240    );
241
242    match module {
243        Ok(module) => transform(&module, source_text, options),
244        Err(_) => TransformResult {
245            module: None,
246            comments: None,
247            diagnostics: vec![],
248            events: vec![],
249        },
250    }
251}
252
253/// Lint API — same as transform but only collects diagnostics, no AST output.
254pub fn lint(
255    module: &swc_ecma_ast::Module,
256    source_text: &str,
257    options: PluginOptions,
258) -> LintResult {
259    let mut opts = options;
260    opts.no_emit = true;
261
262    let result = transform(module, source_text, opts);
263    LintResult {
264        diagnostics: result.diagnostics,
265    }
266}
267
268/// Emit an SWC Module to a string via swc_ecma_codegen.
269/// If `transform` was called on the same thread, any comments from the
270/// compiled AST are automatically included.
271pub fn emit(module: &swc_ecma_ast::Module) -> String {
272    LAST_COMMENTS.with(|cell| {
273        let borrowed = cell.borrow();
274        let positions = BLANK_LINE_POSITIONS.with(|bl| bl.borrow().clone());
275        emit_with_comments(module, borrowed.as_ref(), &positions)
276    })
277}
278
279/// Emit an SWC Module to a string, optionally including comments.
280/// `blank_line_positions` describes where blank lines should be inserted
281/// to match Babel's blank line behavior.
282pub fn emit_with_comments(
283    module: &swc_ecma_ast::Module,
284    comments: Option<&swc_common::comments::SingleThreadedComments>,
285    blank_line_positions: &[BlankLinePosition],
286) -> String {
287    // Standard emit path
288    let code = emit_module_to_string(module, comments);
289    let code = fix_block_comment_newlines(&code);
290
291    // Add blank lines after directives to match Babel's codegen behavior.
292    // Babel always emits a blank line after the last directive in a
293    // program/function body.
294    let code = add_blank_lines_after_directives(&code);
295
296    // Reposition blank lines that SWC places before comment blocks:
297    // SWC emits blank lines before leading comments, but Babel places
298    // them after the comments (between comments and the declaration).
299    // Move blank lines from before comment blocks to after them when
300    // the comment block is followed by a top-level declaration.
301    let code = reposition_comment_blank_lines(&code);
302
303    // Expand single-line object literals to multi-line format in
304    // FIXTURE_ENTRYPOINT-style structures. SWC codegen emits small objects
305    // on single lines while Babel puts them on multiple lines. Prettier
306    // preserves this choice, causing formatting differences.
307    let code = expand_fixture_entrypoint_objects(&code);
308
309    if blank_line_positions.is_empty() || module.body.is_empty() {
310        return code;
311    }
312
313    // Insert blank lines between top-level declarations to match Babel's
314    // output. Babel's generator preserves blank lines from the original
315    // source between consecutive top-level items.
316    insert_blank_lines_in_output(&code, blank_line_positions)
317}
318
319/// Emit a full module to a string.
320///
321/// Records a source map during emission so the namespace-export fixup can
322/// anchor its line rewrites to the module items that produced them (see
323/// `ts_namespace_export_fixup`).
324fn emit_module_to_string(
325    module: &swc_ecma_ast::Module,
326    comments: Option<&swc_common::comments::SingleThreadedComments>,
327) -> String {
328    let cm = swc_common::sync::Lrc::new(swc_common::SourceMap::default());
329    let mut buf = vec![];
330    let mut srcmap: Vec<(swc_common::BytePos, swc_common::LineCol)> = Vec::new();
331    {
332        let wr = swc_ecma_codegen::text_writer::JsWriter::new(
333            cm.clone(),
334            "\n",
335            &mut buf,
336            Some(&mut srcmap),
337        );
338        let mut emitter = swc_ecma_codegen::Emitter {
339            cfg: swc_ecma_codegen::Config::default().with_minify(false),
340            cm,
341            comments: comments.map(|c| c as &dyn swc_common::comments::Comments),
342            wr: Box::new(wr),
343        };
344        swc_ecma_codegen::Node::emit_with(module, &mut emitter).unwrap();
345    }
346    let code = String::from_utf8(buf).unwrap();
347    ts_namespace_export_fixup::fix_ts_namespace_export_decls(&module.body, &code, &srcmap)
348}
349
350/// Insert blank lines into the emitted output at positions specified by
351/// `blank_line_positions`. Each position includes a `first_code_line` that
352/// identifies the item's first line of code (without comments), used as
353/// a search anchor in the output.
354fn insert_blank_lines_in_output(
355    code: &str,
356    positions: &[BlankLinePosition],
357) -> String {
358    if positions.is_empty() {
359        return code.to_string();
360    }
361
362    let lines: Vec<&str> = code.lines().collect();
363
364    // Phase 1: Find which output line indices need a blank line inserted
365    // BEFORE them. We do this by finding each target's first_code_line in
366    // the output, then computing the actual insert line.
367    let mut insert_before: Vec<usize> = Vec::new();
368    let mut used_lines: Vec<bool> = vec![false; lines.len()];
369
370    for pos in positions {
371        let (first_code_line, before_comments) = match pos {
372            BlankLinePosition::BeforeItem { first_code_line } => {
373                (first_code_line.as_str(), true)
374            }
375            BlankLinePosition::BeforeCode { first_code_line } => {
376                (first_code_line.as_str(), false)
377            }
378        };
379
380        // Find this code line in the output (first unused match).
381        // For BeforeCode positions, also allow matching already-used lines
382        // since BeforeItem and BeforeCode may target the same code line.
383        let mut found_idx = None;
384        for (i, &line) in lines.iter().enumerate() {
385            if line == first_code_line && (!used_lines[i] || !before_comments) {
386                found_idx = Some(i);
387                if !used_lines[i] {
388                    used_lines[i] = true;
389                }
390                break;
391            }
392        }
393
394        let code_line_idx = match found_idx {
395            Some(idx) => idx,
396            None => continue,
397        };
398
399        let insert_line = if before_comments {
400            // BeforeItem: insert before the comment block that precedes
401            // this code line
402            find_comment_block_start(&lines, code_line_idx)
403        } else {
404            // BeforeCode: insert right before the code line itself
405            code_line_idx
406        };
407
408        // Only insert if the previous line is not already blank
409        if insert_line > 0 && !lines[insert_line - 1].trim().is_empty() {
410            insert_before.push(insert_line);
411        }
412    }
413
414    if insert_before.is_empty() {
415        return code.to_string();
416    }
417
418    insert_before.sort_unstable();
419    insert_before.dedup();
420
421    // Phase 2: Build the result with blank lines inserted
422    let mut result = String::with_capacity(code.len() + insert_before.len() * 2);
423    let mut insert_idx = 0;
424
425    for (line_idx, &line) in lines.iter().enumerate() {
426        // Check if we need to insert a blank line before this line
427        if insert_idx < insert_before.len() && insert_before[insert_idx] == line_idx {
428            result.push('\n');
429            insert_idx += 1;
430        }
431
432        result.push_str(line);
433        if line_idx < lines.len() - 1 || code.ends_with('\n') {
434            result.push('\n');
435        }
436    }
437
438    result
439}
440
441/// Find the start of a comment block that precedes the line at `code_line_idx`.
442/// Walks backwards from `code_line_idx - 1` as long as lines are comment
443/// lines (starting with `//`, `/*`, ` *`, `*/`, or `/**`).
444fn find_comment_block_start(lines: &[&str], code_line_idx: usize) -> usize {
445    let mut start = code_line_idx;
446    let mut i = code_line_idx;
447    while i > 0 {
448        i -= 1;
449        let trimmed = lines[i].trim();
450        if trimmed.is_empty() {
451            break; // blank line, stop
452        }
453        if trimmed.starts_with("//")
454            || trimmed.starts_with("/*")
455            || trimmed.starts_with("* ")
456            || trimmed.starts_with("*/")
457            || trimmed == "*"
458        {
459            start = i;
460        } else {
461            break;
462        }
463    }
464    start
465}
466
467/// Add blank lines after directive sequences in function/program bodies.
468///
469/// Babel's codegen emits a blank line after the last directive in a body
470/// (e.g., after `"use strict";` or `"use no memo";`). SWC's codegen
471/// does not. This function adds those blank lines to match Babel's output.
472fn add_blank_lines_after_directives(code: &str) -> String {
473    let lines: Vec<&str> = code.lines().collect();
474    if lines.is_empty() {
475        return code.to_string();
476    }
477
478    let mut result: Vec<&str> = Vec::with_capacity(lines.len() + 8);
479    let mut i = 0;
480
481    while i < lines.len() {
482        result.push(lines[i]);
483
484        // Check if this line is a directive (string literal expression statement)
485        if is_directive_line(lines[i]) {
486            // Check if the next line is NOT a directive and NOT blank
487            if i + 1 < lines.len()
488                && !is_directive_line(lines[i + 1])
489                && !lines[i + 1].trim().is_empty()
490            {
491                result.push("");
492            }
493        }
494
495        i += 1;
496    }
497
498    // Rejoin, preserving trailing newline if present
499    let mut output = result.join("\n");
500    if code.ends_with('\n') && !output.ends_with('\n') {
501        output.push('\n');
502    }
503    output
504}
505
506/// Check if a line is a directive (a string literal expression statement).
507/// Directives look like: `"use strict";` or `'use no memo';` possibly with
508/// leading whitespace (indentation for function body directives).
509fn is_directive_line(line: &str) -> bool {
510    let trimmed = line.trim();
511    // Must start with a quote and end with the matching quote + semicolon
512    if let Some(rest) = trimmed.strip_prefix('"') {
513        rest.ends_with("\";")
514    } else if let Some(rest) = trimmed.strip_prefix('\'') {
515        rest.ends_with("';")
516    } else {
517        false
518    }
519}
520
521/// Insert newlines after `*/` when followed by code on the same line.
522/// Only applies to multiline block comments (JSDoc-style), not inline ones.
523fn fix_block_comment_newlines(code: &str) -> String {
524    let mut result = String::with_capacity(code.len());
525    let mut chars = code.char_indices().peekable();
526    let bytes = code.as_bytes();
527    let mut in_block_comment = false;
528    let mut block_comment_multiline = false;
529
530    while let Some((i, c)) = chars.next() {
531        // Track block comment state
532        if !in_block_comment && c == '/' && bytes.get(i + 1) == Some(&b'*') {
533            in_block_comment = true;
534            block_comment_multiline = false;
535            result.push(c);
536            continue;
537        }
538
539        if in_block_comment {
540            if c == '\n' {
541                block_comment_multiline = true;
542            }
543            result.push(c);
544
545            // Check for end of block comment
546            if c == '*' && bytes.get(i + 1) == Some(&b'/') {
547                chars.next();
548                result.push('/');
549                in_block_comment = false;
550
551                if block_comment_multiline {
552                    // Skip spaces after `*/`
553                    let mut spaces = String::new();
554                    while let Some(&(_, next_c)) = chars.peek() {
555                        if next_c == ' ' || next_c == '\t' {
556                            spaces.push(next_c);
557                            chars.next();
558                        } else {
559                            break;
560                        }
561                    }
562
563                    // If followed by code on the same line, insert newline
564                    if let Some(&(_, next_c)) = chars.peek() {
565                        if next_c != '\n' && next_c != '\r' {
566                            result.push('\n');
567                        } else {
568                            result.push_str(&spaces);
569                        }
570                    } else {
571                        result.push_str(&spaces);
572                    }
573                }
574            }
575            continue;
576        }
577
578        result.push(c);
579    }
580    result
581}
582
583/// Reposition blank lines from before comment blocks to after them.
584///
585/// SWC's codegen sometimes places blank lines before leading comment blocks,
586/// but Babel's generator places them after the comments (between the comment
587/// block and the declaration). This function detects the pattern:
588///
589///   <non-comment line>
590///   <blank line>
591///   <comment lines...>
592///   <declaration line>
593///
594/// And transforms it to:
595///
596///   <non-comment line>
597///   <comment lines...>
598///   <blank line>
599///   <declaration line>
600///
601/// This only applies to top-level (non-indented) comment blocks.
602fn reposition_comment_blank_lines(code: &str) -> String {
603    let lines: Vec<&str> = code.lines().collect();
604    if lines.len() < 3 {
605        return code.to_string();
606    }
607
608    let mut result: Vec<&str> = Vec::with_capacity(lines.len());
609    let mut i = 0;
610
611    while i < lines.len() {
612        // Look for pattern: blank line followed by comment block followed by declaration
613        if lines[i].trim().is_empty() && i + 1 < lines.len() {
614            let comment_start = i + 1;
615            let first_comment = lines[comment_start].trim();
616
617            // Check if the next line is a top-level comment (not indented)
618            let is_top_level_comment = (first_comment.starts_with("//")
619                || first_comment.starts_with("/*")
620                || first_comment.starts_with("/**"))
621                && !lines[comment_start].starts_with(' ')
622                && !lines[comment_start].starts_with('\t');
623
624            if is_top_level_comment {
625                // Find the end of the comment block
626                let mut comment_end = comment_start;
627                while comment_end < lines.len() {
628                    let trimmed = lines[comment_end].trim();
629                    if trimmed.starts_with("//")
630                        || trimmed.starts_with("/*")
631                        || trimmed.starts_with("* ")
632                        || trimmed.starts_with("*/")
633                        || trimmed == "*"
634                        || trimmed.starts_with("/**")
635                    {
636                        comment_end += 1;
637                    } else {
638                        break;
639                    }
640                }
641
642                // Check if the line after the comment block is a top-level
643                // declaration (function, class, export, const, let, var).
644                // This is specifically for Babel's codegen which places blank
645                // lines after comment blocks before declarations, not before.
646                if comment_end < lines.len() && comment_end > comment_start {
647                    let after_comment = lines[comment_end].trim();
648                    let is_declaration = after_comment.starts_with("function ")
649                        || after_comment.starts_with("export ")
650                        || after_comment.starts_with("class ")
651                        || after_comment.starts_with("const ")
652                        || after_comment.starts_with("let ")
653                        || after_comment.starts_with("var ")
654                        || after_comment.starts_with("import ")
655                        || after_comment.starts_with("async function ")
656                        || after_comment.starts_with("async function*");
657
658                    if is_declaration {
659                        // Also check that the line before the blank line is
660                        // non-empty (end of import or end of function)
661                        let prev_non_empty = i > 0 && !lines[i - 1].trim().is_empty();
662
663                        if prev_non_empty {
664                            // Move the blank line: emit comment block first,
665                            // then blank line, then continue
666                            for j in comment_start..comment_end {
667                                result.push(lines[j]);
668                            }
669                            result.push(""); // blank line after comments
670                            i = comment_end;
671                            continue;
672                        }
673                    }
674                }
675            }
676        }
677
678        result.push(lines[i]);
679        i += 1;
680    }
681
682    // Rejoin, preserving trailing newline if present
683    let mut output = result.join("\n");
684    if code.ends_with('\n') && !output.ends_with('\n') {
685        output.push('\n');
686    }
687    output
688}
689
690/// Compute where blank lines should be inserted in the emitted output.
691///
692/// This replicates Babel's `@babel/generator` behavior: when consecutive
693/// top-level items had blank lines between them in the original source,
694/// the generator preserves those blank lines.
695///
696/// We check the item spans (byte positions into the original source) and
697/// determine if there was a blank line gap between consecutive items.
698/// We also determine WHERE the blank line should go: before the item's
699/// leading comments (BeforeItem) or between the comments and code (BeforeCode).
700fn compute_blank_line_positions(
701    body: &[swc_ecma_ast::ModuleItem],
702    source_text: &str,
703) -> Vec<BlankLinePosition> {
704    use swc_common::Spanned;
705
706    let mut result = Vec::new();
707
708    // Check for blank lines between leading comments and the first
709    // non-DUMMY item. This handles the case where comments from the
710    // source (e.g., pragma comments) are attached as leading comments
711    // to an import, with a blank line gap in the original source.
712    for item in body {
713        let lo = item.span().lo;
714        if lo.is_dummy() {
715            continue;
716        }
717        let lo_u = (lo.0 as usize).saturating_sub(1);
718        if lo_u > source_text.len() || lo_u == 0 {
719            break;
720        }
721        // Check the source text before this item for comments followed by blank lines
722        let before = &source_text[..lo_u];
723        if has_blank_line(before) && (before.contains("//") || before.contains("/*")) {
724            // There are comments and blank lines before this item.
725            // Check if the blank line is between the comments and this item
726            // (i.e., "BeforeCode" pattern)
727            if !is_blank_line_before_comments(before) {
728                let first_code_line = get_first_code_line(item);
729                result.push(BlankLinePosition::BeforeCode { first_code_line });
730            }
731        }
732        break; // Only check the first non-DUMMY item
733    }
734
735    for i in 1..body.len() {
736        let prev = &body[i - 1];
737        let curr = &body[i];
738
739        let prev_hi = prev.span().hi;
740        let curr_lo = curr.span().lo;
741
742        // Skip items with dummy/synthetic spans (BytePos(0))
743        if prev_hi.is_dummy() || curr_lo.is_dummy() {
744            continue;
745        }
746
747        // SWC BytePos is 1-based (BytePos(0) is DUMMY/reserved). Convert
748        // to 0-based source text indices by subtracting 1.
749        let prev_hi_u = (prev_hi.0 as usize).saturating_sub(1);
750        let curr_lo_u = (curr_lo.0 as usize).saturating_sub(1);
751
752        if prev_hi_u >= curr_lo_u || prev_hi_u > source_text.len() || curr_lo_u > source_text.len() {
753            continue;
754        }
755
756        // Check the text between the two items for blank lines.
757        // Babel's generator preserves blank lines from the original source
758        // between consecutive top-level items.
759        let between = &source_text[prev_hi_u..curr_lo_u];
760        if !has_blank_line(between) {
761            continue;
762        }
763
764        // Only preserve blank lines when there are comments between the
765        // items. This matches Babel's behavior: the TS compiler's
766        // replaceWith() creates fresh nodes without position info, so
767        // Babel's generator only sees position gaps when comments with
768        // original positions are present between items. Without comments,
769        // the generated code and the next item end up close together,
770        // so Babel sees no gap and doesn't insert a blank line.
771        if !between.contains("//") && !between.contains("/*") {
772            continue;
773        }
774
775        // Determine the first code line of the current item (emitted
776        // without comments) for use as a search anchor.
777        let first_code_line = get_first_code_line(curr);
778
779        // Determine whether blank lines exist before and/or after comments.
780        let (blank_before, blank_after) = blank_line_positions_around_comments(between);
781
782        if blank_before && blank_after {
783            // Both: add blank lines before AND after comments
784            result.push(BlankLinePosition::BeforeItem { first_code_line: first_code_line.clone() });
785            result.push(BlankLinePosition::BeforeCode { first_code_line });
786        } else if blank_after {
787            result.push(BlankLinePosition::BeforeCode { first_code_line });
788        } else {
789            // blank_before only, or no specific position → default to BeforeItem
790            result.push(BlankLinePosition::BeforeItem { first_code_line });
791        }
792    }
793
794    result
795}
796
797/// Check if a string contains a blank line (two consecutive newlines
798/// with only whitespace between them).
799fn has_blank_line(s: &str) -> bool {
800    let mut prev_newline = false;
801    for c in s.chars() {
802        if c == '\n' {
803            if prev_newline {
804                return true;
805            }
806            prev_newline = true;
807        } else if c == ' ' || c == '\t' || c == '\r' {
808            // whitespace between newlines is ok
809        } else {
810            prev_newline = false;
811        }
812    }
813    false
814}
815
816/// Determine where blank lines exist relative to comments in the between-text.
817///
818/// Returns (blank_before_comments, blank_after_comments):
819/// - blank_before: there's a blank line before any comment content
820/// - blank_after: there's a blank line after comment content
821fn blank_line_positions_around_comments(between: &str) -> (bool, bool) {
822    let mut found_comment = false;
823    let mut prev_newline = false;
824    let mut blank_before = false;
825    let mut blank_after = false;
826
827    for (i, c) in between.char_indices() {
828        if c == '\n' {
829            if prev_newline {
830                if found_comment {
831                    blank_after = true;
832                } else {
833                    blank_before = true;
834                }
835            }
836            prev_newline = true;
837        } else if c == ' ' || c == '\t' || c == '\r' {
838            // whitespace between newlines is ok
839        } else {
840            prev_newline = false;
841            if c == '/' {
842                let next = between.as_bytes().get(i + 1);
843                if next == Some(&b'*') || next == Some(&b'/') {
844                    found_comment = true;
845                }
846            }
847        }
848    }
849
850    (blank_before, blank_after)
851}
852
853/// Check if the blank line in the between-text should be placed before
854/// comments. Used for the first-item leading comment check.
855fn is_blank_line_before_comments(between: &str) -> bool {
856    let (blank_before, blank_after) = blank_line_positions_around_comments(between);
857    // If blank lines exist after comments, prefer BeforeCode (return false)
858    if blank_after {
859        return false;
860    }
861    blank_before
862}
863
864/// Get the first non-empty line of a ModuleItem when emitted without
865/// comments. Goes through `emit_module_to_string` so the text matches the
866/// final emitted output (including the namespace-export fixup).
867fn get_first_code_line(item: &swc_ecma_ast::ModuleItem) -> String {
868    let single_module = swc_ecma_ast::Module {
869        span: swc_common::DUMMY_SP,
870        body: vec![item.clone()],
871        shebang: None,
872    };
873
874    let code = emit_module_to_string(&single_module, None);
875    code.lines()
876        .find(|l| !l.trim().is_empty())
877        .unwrap_or("")
878        .to_string()
879}
880
881/// Extract comments from source text using SWC's parser.
882/// Returns a list of (BytePos, Vec<Comment>) pairs where the BytePos is the
883/// position of the token following the comment(s).
884fn extract_source_comments(
885    source_text: &str,
886) -> (
887    Vec<(swc_common::BytePos, Vec<swc_common::comments::Comment>)>,
888    Vec<(swc_common::BytePos, Vec<swc_common::comments::Comment>)>,
889) {
890    let cm = swc_common::sync::Lrc::new(swc_common::SourceMap::default());
891    let fm = cm.new_source_file(
892        swc_common::sync::Lrc::new(swc_common::FileName::Anon),
893        source_text.to_string(),
894    );
895
896    let comments = swc_common::comments::SingleThreadedComments::default();
897    let mut errors = vec![];
898    // Try parsing as JSX+TS to handle maximum syntax variety
899    let _ = swc_ecma_parser::parse_file_as_module(
900        &fm,
901        swc_ecma_parser::Syntax::Typescript(swc_ecma_parser::TsSyntax {
902            tsx: true,
903            ..Default::default()
904        }),
905        swc_ecma_ast::EsVersion::latest(),
906        Some(&comments),
907        &mut errors,
908    );
909
910    let mut leading_result = Vec::new();
911    let mut trailing_result = Vec::new();
912    let (leading, trailing) = comments.borrow_all();
913    for (pos, cmts) in leading.iter() {
914        if !cmts.is_empty() {
915            leading_result.push((*pos, cmts.clone()));
916        }
917    }
918    for (pos, cmts) in trailing.iter() {
919        if !cmts.is_empty() {
920            trailing_result.push((*pos, cmts.clone()));
921        }
922    }
923
924    (leading_result, trailing_result)
925}
926
927/// Normalize source code formatting to match Babel's codegen behavior.
928/// Applied to source text that was not modified by the compiler.
929/// Currently adds blank lines after directive sequences, matching
930/// Babel's generator which always emits a blank line after the last
931/// directive in a function/program body.
932pub fn normalize_source(source: &str) -> String {
933    let code = add_blank_lines_after_directives(source);
934    let code = remove_blank_lines_after_last_import(&code);
935    let code = remove_blank_lines_before_fixture_entrypoint(&code);
936    expand_fixture_entrypoint_objects(&code)
937}
938
939/// Remove blank lines immediately before `export const FIXTURE_ENTRYPOINT`.
940/// Babel's codegen doesn't preserve blank lines between function declarations
941/// and the FIXTURE_ENTRYPOINT export.
942fn remove_blank_lines_before_fixture_entrypoint(code: &str) -> String {
943    let lines: Vec<&str> = code.lines().collect();
944    if lines.is_empty() {
945        return code.to_string();
946    }
947
948    // Find the FIXTURE_ENTRYPOINT line
949    let mut entrypoint_idx: Option<usize> = None;
950    for (i, &line) in lines.iter().enumerate() {
951        if line.trim().starts_with("export const FIXTURE_ENTRYPOINT")
952            || line.trim().starts_with("export const FIXTURE_ENTRYPOINT")
953        {
954            entrypoint_idx = Some(i);
955            break;
956        }
957    }
958
959    let entrypoint_idx = match entrypoint_idx {
960        Some(idx) if idx > 0 => idx,
961        _ => return code.to_string(),
962    };
963
964    // Check if the line before FIXTURE_ENTRYPOINT is blank
965    if !lines[entrypoint_idx - 1].trim().is_empty() {
966        return code.to_string();
967    }
968
969    // Remove the blank line
970    let mut result: Vec<&str> = Vec::with_capacity(lines.len());
971    for (i, &line) in lines.iter().enumerate() {
972        if i == entrypoint_idx - 1 {
973            continue;
974        }
975        result.push(line);
976    }
977
978    let mut output = result.join("\n");
979    if code.ends_with('\n') && !output.ends_with('\n') {
980        output.push('\n');
981    }
982    output
983}
984
985/// Remove blank lines between the last import declaration and the first
986/// non-import statement. Babel's codegen doesn't preserve these blank lines.
987///
988/// Only removes blank lines that immediately follow the LAST import line
989/// (not blank lines between comments or between import groups).
990fn remove_blank_lines_after_last_import(code: &str) -> String {
991    let lines: Vec<&str> = code.lines().collect();
992    if lines.is_empty() {
993        return code.to_string();
994    }
995
996    // Find the index of the last import statement
997    let mut last_import_idx: Option<usize> = None;
998    for (i, &line) in lines.iter().enumerate() {
999        let trimmed = line.trim();
1000        if trimmed.starts_with("import ") || trimmed.starts_with("import{") {
1001            last_import_idx = Some(i);
1002        }
1003    }
1004
1005    let last_import_idx = match last_import_idx {
1006        Some(idx) => idx,
1007        None => return code.to_string(),
1008    };
1009
1010    // Check if there's a blank line immediately after the last import
1011    let blank_idx = last_import_idx + 1;
1012    if blank_idx >= lines.len() || !lines[blank_idx].trim().is_empty() {
1013        return code.to_string();
1014    }
1015
1016    // Remove this blank line
1017    let mut result: Vec<&str> = Vec::with_capacity(lines.len());
1018    for (i, &line) in lines.iter().enumerate() {
1019        if i == blank_idx {
1020            continue; // skip the blank line
1021        }
1022        result.push(line);
1023    }
1024
1025    let mut output = result.join("\n");
1026    if code.ends_with('\n') && !output.ends_with('\n') {
1027        output.push('\n');
1028    }
1029    output
1030}
1031
1032/// Expand single-line object literals to multi-line format within
1033/// FIXTURE_ENTRYPOINT structures only.
1034///
1035/// SWC's codegen emits small objects on a single line (e.g.,
1036/// `params: [{ value: "test" }]`), while Babel's codegen puts them on
1037/// multiple lines. Since prettier preserves the single-line vs multi-line
1038/// choice, we need to expand them before prettier runs.
1039///
1040/// This function ONLY operates within FIXTURE_ENTRYPOINT blocks to avoid
1041/// affecting compiled code.
1042fn expand_fixture_entrypoint_objects(code: &str) -> String {
1043    // Find the start of FIXTURE_ENTRYPOINT block
1044    let entrypoint_marker = "FIXTURE_ENTRYPOINT";
1045    if !code.contains(entrypoint_marker) {
1046        return code.to_string();
1047    }
1048
1049    // Find the byte position of FIXTURE_ENTRYPOINT
1050    let entrypoint_pos = match code.find(entrypoint_marker) {
1051        Some(pos) => pos,
1052        None => return code.to_string(),
1053    };
1054
1055    // Only process lines after FIXTURE_ENTRYPOINT
1056    let (before, after) = code.split_at(entrypoint_pos);
1057    let expanded = expand_single_line_objects_in_block(after);
1058    format!("{}{}", before, expanded)
1059}
1060
1061fn expand_single_line_objects_in_block(code: &str) -> String {
1062    let mut result = String::with_capacity(code.len() + 256);
1063    let lines: Vec<&str> = code.lines().collect();
1064
1065    for (idx, &line) in lines.iter().enumerate() {
1066        if let Some(expanded) = try_expand_object_line(line) {
1067            result.push_str(&expanded);
1068        } else {
1069            result.push_str(line);
1070        }
1071        if idx < lines.len() - 1 || code.ends_with('\n') {
1072            result.push('\n');
1073        }
1074    }
1075
1076    result
1077}
1078
1079/// Try to expand a single-line object literal to multi-line.
1080/// Returns Some(expanded) if the line contains an expandable object, None otherwise.
1081fn try_expand_object_line(line: &str) -> Option<String> {
1082    let trimmed = line.trim();
1083
1084    // Calculate indentation
1085    let indent = &line[..line.len() - line.trim_start().len()];
1086
1087    // Pattern 1: `key: [{ prop: val, prop2: val2 }],` or `key: [{ ... }, { ... }],`
1088    // Pattern 2: `[{ prop: val }, { prop: val }]` (array of objects)
1089    // We need to find `[` containing `{...}` entries
1090
1091    // Check if this line has a [ ... ] with { ... } objects inside
1092    if !trimmed.contains("[{") && !trimmed.contains("{ ") {
1093        return None;
1094    }
1095
1096    // Find the bracket-enclosed array content
1097    let bracket_start = trimmed.find('[')?;
1098    let bracket_end = trimmed.rfind(']')?;
1099    if bracket_start >= bracket_end {
1100        return None;
1101    }
1102
1103    let array_content = &trimmed[bracket_start + 1..bracket_end];
1104    let inner_trimmed = array_content.trim();
1105
1106    // Check if this contains objects: at least one `{ ... }`
1107    if !inner_trimmed.starts_with('{') || !inner_trimmed.contains(':') {
1108        return None;
1109    }
1110
1111    // We need at least one property with a colon to expand
1112    if !inner_trimmed.contains(':') {
1113        return None;
1114    }
1115
1116    // Split the array content into individual elements
1117    let prefix = &trimmed[..bracket_start + 1];
1118    let suffix = &trimmed[bracket_end..];
1119
1120    // Parse the objects - split at `}, {` boundaries
1121    let elements = split_array_elements(inner_trimmed);
1122
1123    let inner_indent = format!("{}  ", indent);
1124    let prop_indent = format!("{}    ", indent);
1125
1126    let mut result = String::new();
1127    result.push_str(indent);
1128    result.push_str(prefix);
1129    result.push('\n');
1130
1131    for (i, elem) in elements.iter().enumerate() {
1132        let elem = elem.trim();
1133        if elem.starts_with('{') && elem.ends_with('}') {
1134            // Expand this object
1135            let obj_content = &elem[1..elem.len() - 1].trim();
1136            let props = split_object_properties(obj_content);
1137
1138            result.push_str(&inner_indent);
1139            result.push_str("{\n");
1140            for (_j, prop) in props.iter().enumerate() {
1141                result.push_str(&prop_indent);
1142                result.push_str(prop.trim());
1143                result.push_str(",\n");
1144            }
1145            result.push_str(&inner_indent);
1146            result.push('}');
1147        } else {
1148            result.push_str(&inner_indent);
1149            result.push_str(elem);
1150        }
1151        if i < elements.len() - 1 {
1152            result.push(',');
1153        }
1154        result.push('\n');
1155    }
1156
1157    result.push_str(indent);
1158    result.push_str(suffix);
1159
1160    Some(result)
1161}
1162
1163/// Split array content into individual elements, respecting nested braces/brackets.
1164fn split_array_elements(s: &str) -> Vec<String> {
1165    let mut elements = Vec::new();
1166    let mut current = String::new();
1167    let mut depth = 0;
1168
1169    for ch in s.chars() {
1170        match ch {
1171            '{' | '[' | '(' => {
1172                depth += 1;
1173                current.push(ch);
1174            }
1175            '}' | ']' | ')' => {
1176                depth -= 1;
1177                current.push(ch);
1178            }
1179            ',' if depth == 0 => {
1180                let trimmed = current.trim().to_string();
1181                if !trimmed.is_empty() {
1182                    elements.push(trimmed);
1183                }
1184                current.clear();
1185            }
1186            _ => {
1187                current.push(ch);
1188            }
1189        }
1190    }
1191    let trimmed = current.trim().to_string();
1192    if !trimmed.is_empty() {
1193        elements.push(trimmed);
1194    }
1195    elements
1196}
1197
1198/// Split object properties, respecting nested structures.
1199fn split_object_properties(s: &str) -> Vec<String> {
1200    let mut props = Vec::new();
1201    let mut current = String::new();
1202    let mut depth = 0;
1203
1204    for ch in s.chars() {
1205        match ch {
1206            '{' | '[' | '(' => {
1207                depth += 1;
1208                current.push(ch);
1209            }
1210            '}' | ']' | ')' => {
1211                depth -= 1;
1212                current.push(ch);
1213            }
1214            ',' if depth == 0 => {
1215                let trimmed = current.trim().to_string();
1216                if !trimmed.is_empty() {
1217                    props.push(trimmed);
1218                }
1219                current.clear();
1220            }
1221            _ => {
1222                current.push(ch);
1223            }
1224        }
1225    }
1226    let trimmed = current.trim().to_string();
1227    if !trimmed.is_empty() {
1228        props.push(trimmed);
1229    }
1230    props
1231}
1232
1233/// Convenience wrapper — parses source text, then lints.
1234pub fn lint_source(source_text: &str, options: PluginOptions) -> LintResult {
1235    let cm = swc_common::sync::Lrc::new(swc_common::SourceMap::default());
1236    let fm = cm.new_source_file(
1237        swc_common::sync::Lrc::new(swc_common::FileName::Anon),
1238        source_text.to_string(),
1239    );
1240
1241    let mut errors = vec![];
1242    let module = swc_ecma_parser::parse_file_as_module(
1243        &fm,
1244        swc_ecma_parser::Syntax::Es(swc_ecma_parser::EsSyntax {
1245            jsx: true,
1246            ..Default::default()
1247        }),
1248        swc_ecma_ast::EsVersion::latest(),
1249        None,
1250        &mut errors,
1251    );
1252
1253    match module {
1254        Ok(module) => lint(&module, source_text, options),
1255        Err(_) => LintResult {
1256            diagnostics: vec![],
1257        },
1258    }
1259}