macroforge_ts/lib.rs
1//! # Macroforge TypeScript Macro Engine
2//!
3//! This crate provides a TypeScript macro expansion engine that brings Rust-like derive macros
4//! to TypeScript. It is designed to be used via NAPI bindings from Node.js, enabling compile-time
5//! code generation for TypeScript projects.
6//!
7//! ## Overview
8//!
9//! Macroforge processes TypeScript source files containing `@derive` decorators and expands them
10//! into concrete implementations. For example, a class decorated with `@derive(Debug, Clone)`
11//! will have `toString()` and `clone()` methods automatically generated.
12//!
13//! ## Architecture
14//!
15//! The crate is organized into several key components:
16//!
17//! - **NAPI Bindings** (`NativePlugin`, `expand_sync`, `transform_sync`): Entry points for Node.js
18//! - **Position Mapping** (`NativePositionMapper`, `NativeMapper`): Bidirectional source mapping
19//! for IDE integration
20//! - **Macro Host** (`host` module): Core expansion engine with registry and dispatcher
21//! - **Built-in Macros** (`builtin` module): Standard derive macros (Debug, Clone, Serialize, etc.)
22//!
23//! ## Performance Considerations
24//!
25//! - Uses a 32MB thread stack to prevent stack overflow during deep SWC AST recursion
26//! - Implements early bailout for files without `@derive` decorators
27//! - Caches expansion results keyed by filepath and version
28//! - Uses binary search for O(log n) position mapping lookups
29//!
30//! ## Usage from Node.js
31//!
32//! ```javascript
33//! const { NativePlugin, expand_sync } = require('macroforge-ts');
34//!
35//! // Create a plugin instance with caching
36//! const plugin = new NativePlugin();
37//!
38//! // Process a file (uses cache if version matches)
39//! const result = plugin.process_file(filepath, code, { version: '1.0' });
40//!
41//! // Or use the sync function directly
42//! const result = expand_sync(code, filepath, { keep_decorators: false });
43//! ```
44//!
45//! ## Re-exports for Macro Authors
46//!
47//! This crate re-exports several dependencies for convenience when writing custom macros:
48//! - `ts_syn`: TypeScript syntax types for AST manipulation
49//! - `macros`: Macro attributes and quote templates
50//! - `swc_core`, `swc_common`, `swc_ecma_ast`: SWC compiler infrastructure
51
52use napi::bindgen_prelude::*;
53use napi_derive::napi;
54use swc_core::{
55 common::{FileName, GLOBALS, Globals, SourceMap, errors::Handler, sync::Lrc},
56 ecma::{
57 ast::{EsVersion, Program},
58 codegen::{Emitter, text_writer::JsWriter},
59 parser::{Parser, StringInput, Syntax, TsSyntax, lexer::Lexer},
60 },
61};
62
63// Allow the crate to reference itself as `macroforge_ts`.
64// This self-reference is required for the macroforge_ts_macros generated code
65// to correctly resolve paths when the macro expansion happens within this crate.
66extern crate self as macroforge_ts;
67
68// ============================================================================
69// Re-exports for Macro Authors
70// ============================================================================
71// These re-exports allow users to only depend on `macroforge_ts` in their
72// Cargo.toml instead of needing to add multiple dependencies.
73
74// Re-export internal crates (needed for generated code)
75pub extern crate inventory;
76pub extern crate macroforge_ts_macros;
77pub extern crate macroforge_ts_quote;
78pub extern crate macroforge_ts_syn;
79pub extern crate napi;
80pub extern crate napi_derive;
81pub extern crate serde_json;
82
83/// TypeScript syntax types for macro development
84/// Use: `use macroforge_ts::ts_syn::*;`
85pub use macroforge_ts_syn as ts_syn;
86
87/// Macro attributes and quote templates
88/// Use: `use macroforge_ts::macros::*;`
89pub mod macros {
90 // Re-export the ts_macro_derive attribute
91 pub use macroforge_ts_macros::ts_macro_derive;
92
93 // Re-export all quote macros
94 pub use macroforge_ts_quote::{above, below, body, signature, ts_template};
95}
96
97// Re-export swc_core and common modules (via ts_syn for version consistency)
98pub use macroforge_ts_syn::swc_common;
99pub use macroforge_ts_syn::swc_core;
100pub use macroforge_ts_syn::swc_ecma_ast;
101
102// ============================================================================
103// Internal modules
104// ============================================================================
105pub mod host;
106
107// Build script utilities (enabled with "build" feature)
108#[cfg(feature = "build")]
109pub mod build;
110
111// Re-export abi types from ts_syn
112pub use ts_syn::abi;
113
114use host::CONFIG_CACHE;
115use host::derived;
116use ts_syn::{Diagnostic, DiagnosticLevel};
117
118pub mod builtin;
119
120#[cfg(test)]
121mod test;
122
123use crate::host::MacroExpander;
124
125// ============================================================================
126// Data Structures
127// ============================================================================
128
129/// Result of transforming TypeScript code through the macro system.
130///
131/// This struct is returned by [`transform_sync`] and contains the transformed code
132/// along with optional source maps, type declarations, and metadata about processed classes.
133///
134/// # Fields
135///
136/// * `code` - The transformed TypeScript/JavaScript code with macros expanded
137/// * `map` - Optional source map for debugging (currently not implemented)
138/// * `types` - Optional TypeScript type declarations for generated methods
139/// * `metadata` - Optional JSON metadata about processed classes
140#[napi(object)]
141#[derive(Clone)]
142pub struct TransformResult {
143 /// The transformed TypeScript/JavaScript code with all macros expanded.
144 pub code: String,
145 /// Source map for mapping transformed positions back to original.
146 /// Currently always `None` - source mapping is handled separately via `SourceMappingResult`.
147 pub map: Option<String>,
148 /// TypeScript type declarations (`.d.ts` content) for generated methods.
149 /// Used by IDEs to provide type information for macro-generated code.
150 pub types: Option<String>,
151 /// JSON-serialized metadata about processed classes.
152 /// Contains information about which classes were processed and what was generated.
153 pub metadata: Option<String>,
154}
155
156/// A diagnostic message produced during macro expansion.
157///
158/// Diagnostics can represent errors, warnings, or informational messages
159/// that occurred during the macro expansion process.
160///
161/// # Fields
162///
163/// * `level` - Severity level: "error", "warning", or "info"
164/// * `message` - Human-readable description of the issue
165/// * `start` - Optional byte offset where the issue starts in the source
166/// * `end` - Optional byte offset where the issue ends in the source
167///
168/// # Example
169///
170/// ```rust
171/// use macroforge_ts::MacroDiagnostic;
172///
173/// let _diag = MacroDiagnostic {
174/// level: "error".to_string(),
175/// message: "Unknown macro 'Foo'".to_string(),
176/// start: Some(42),
177/// end: Some(45),
178/// };
179/// ```
180#[napi(object)]
181#[derive(Clone)]
182pub struct MacroDiagnostic {
183 /// Severity level of the diagnostic.
184 /// One of: "error", "warning", "info".
185 pub level: String,
186 /// Human-readable message describing the diagnostic.
187 pub message: String,
188 /// Byte offset in the original source where the issue starts.
189 /// `None` if the diagnostic is not associated with a specific location.
190 pub start: Option<u32>,
191 /// Byte offset in the original source where the issue ends.
192 /// `None` if the diagnostic is not associated with a specific location.
193 pub end: Option<u32>,
194}
195
196/// A segment mapping a range in the original source to a range in the expanded source.
197///
198/// These segments form the core of the bidirectional source mapping system,
199/// enabling IDE features like "go to definition" and error reporting to work
200/// correctly with macro-expanded code.
201///
202/// # Invariants
203///
204/// - `original_start < original_end`
205/// - `expanded_start < expanded_end`
206/// - Segments are non-overlapping and sorted by position
207#[napi(object)]
208#[derive(Clone)]
209pub struct MappingSegmentResult {
210 /// Byte offset where this segment starts in the original source.
211 pub original_start: u32,
212 /// Byte offset where this segment ends in the original source.
213 pub original_end: u32,
214 /// Byte offset where this segment starts in the expanded source.
215 pub expanded_start: u32,
216 /// Byte offset where this segment ends in the expanded source.
217 pub expanded_end: u32,
218}
219
220/// A region in the expanded source that was generated by a macro.
221///
222/// These regions identify code that has no corresponding location in the
223/// original source because it was synthesized by a macro.
224///
225/// # Example
226///
227/// For a `@derive(Debug)` macro that generates a `toString()` method,
228/// a `GeneratedRegionResult` would mark the entire method body as generated
229/// with `source_macro = "Debug"`.
230#[napi(object)]
231#[derive(Clone)]
232pub struct GeneratedRegionResult {
233 /// Byte offset where the generated region starts in the expanded source.
234 pub start: u32,
235 /// Byte offset where the generated region ends in the expanded source.
236 pub end: u32,
237 /// Name of the macro that generated this region (e.g., "Debug", "Clone").
238 pub source_macro: String,
239}
240
241/// Complete source mapping information for a macro expansion.
242///
243/// Contains both preserved segments (original code that wasn't modified)
244/// and generated regions (new code synthesized by macros).
245///
246/// # Usage
247///
248/// This mapping enables:
249/// - Converting positions from original source to expanded source and vice versa
250/// - Identifying which macro generated a given piece of code
251/// - Mapping IDE diagnostics from expanded code back to original source
252#[napi(object)]
253#[derive(Clone)]
254pub struct SourceMappingResult {
255 /// Segments mapping preserved regions between original and expanded source.
256 /// Sorted by position for efficient binary search lookups.
257 pub segments: Vec<MappingSegmentResult>,
258 /// Regions in the expanded source that were generated by macros.
259 /// Used to identify synthetic code with no original source location.
260 pub generated_regions: Vec<GeneratedRegionResult>,
261}
262
263/// Result of expanding macros in TypeScript source code.
264///
265/// This is the primary return type for macro expansion operations,
266/// containing the expanded code, diagnostics, and source mapping.
267///
268/// # Example
269///
270/// ```rust
271/// use macroforge_ts::{ExpandResult, MacroDiagnostic};
272///
273/// // Create an ExpandResult programmatically
274/// let result = ExpandResult {
275/// code: "class User {}".to_string(),
276/// types: None,
277/// metadata: None,
278/// diagnostics: vec![],
279/// source_mapping: None,
280/// };
281///
282/// // Check for errors
283/// if result.diagnostics.iter().any(|d| d.level == "error") {
284/// // Handle errors
285/// }
286/// ```
287#[napi(object)]
288#[derive(Clone)]
289pub struct ExpandResult {
290 /// The expanded TypeScript code with all macros processed.
291 pub code: String,
292 /// Optional TypeScript type declarations for generated methods.
293 pub types: Option<String>,
294 /// Optional JSON metadata about processed classes.
295 pub metadata: Option<String>,
296 /// Diagnostics (errors, warnings, info) from the expansion process.
297 pub diagnostics: Vec<MacroDiagnostic>,
298 /// Source mapping for position translation between original and expanded code.
299 pub source_mapping: Option<SourceMappingResult>,
300}
301
302impl ExpandResult {
303 /// Creates a no-op result with the original code unchanged.
304 ///
305 /// Used for early bailout when no macros need processing, avoiding
306 /// unnecessary parsing and expansion overhead.
307 ///
308 /// # Arguments
309 ///
310 /// * `code` - The original source code to return unchanged
311 ///
312 /// # Returns
313 ///
314 /// An `ExpandResult` with:
315 /// - `code` set to the input
316 /// - All other fields empty/None
317 /// - No source mapping (identity mapping implied)
318 pub fn unchanged(code: &str) -> Self {
319 Self {
320 code: code.to_string(),
321 types: None,
322 metadata: None,
323 diagnostics: vec![],
324 source_mapping: None,
325 }
326 }
327}
328
329/// Information about an imported identifier from a TypeScript module.
330///
331/// Used to track where decorators and macro-related imports come from.
332#[napi(object)]
333#[derive(Clone)]
334pub struct ImportSourceResult {
335 /// Local identifier name in the import statement (e.g., `Derive` in `import { Derive }`).
336 pub local: String,
337 /// Module specifier this identifier was imported from (e.g., `"macroforge-ts"`).
338 pub module: String,
339}
340
341/// Result of checking TypeScript syntax validity.
342///
343/// Returned by [`check_syntax`] to indicate whether code parses successfully.
344#[napi(object)]
345#[derive(Clone)]
346pub struct SyntaxCheckResult {
347 /// `true` if the code parsed without errors, `false` otherwise.
348 pub ok: bool,
349 /// Error message if parsing failed, `None` if successful.
350 pub error: Option<String>,
351}
352
353/// A span (range) in source code, represented as start position and length.
354///
355/// Used for mapping diagnostics and other positional information.
356#[napi(object)]
357#[derive(Clone)]
358pub struct SpanResult {
359 /// Byte offset where the span starts.
360 pub start: u32,
361 /// Length of the span in bytes.
362 pub length: u32,
363}
364
365/// A diagnostic from the TypeScript/JavaScript compiler or IDE.
366///
367/// This structure mirrors TypeScript's diagnostic format for interoperability
368/// with language servers and IDEs.
369#[napi(object)]
370#[derive(Clone)]
371pub struct JsDiagnostic {
372 /// Byte offset where the diagnostic starts. `None` for global diagnostics.
373 pub start: Option<u32>,
374 /// Length of the diagnostic span in bytes.
375 pub length: Option<u32>,
376 /// Human-readable diagnostic message.
377 pub message: Option<String>,
378 /// TypeScript diagnostic code (e.g., 2304 for "Cannot find name").
379 pub code: Option<u32>,
380 /// Diagnostic category: "error", "warning", "suggestion", "message".
381 pub category: Option<String>,
382}
383
384// ============================================================================
385// Position Mapper (Optimized with Binary Search)
386// ============================================================================
387
388/// Bidirectional position mapper for translating between original and expanded source positions.
389///
390/// This mapper enables IDE features like error reporting, go-to-definition, and hover
391/// to work correctly with macro-expanded code by translating positions between the
392/// original source (what the user wrote) and the expanded source (what the compiler sees).
393///
394/// # Performance
395///
396/// Position lookups use binary search for O(log n) complexity, where n is the number
397/// of mapping segments. This is critical for responsive IDE interactions.
398///
399/// # Example
400///
401/// ```javascript
402/// const mapper = new PositionMapper(sourceMapping);
403///
404/// // Convert original position to expanded
405/// const expandedPos = mapper.original_to_expanded(42);
406///
407/// // Convert expanded position back to original (if not in generated code)
408/// const originalPos = mapper.expanded_to_original(100);
409///
410/// // Check if a position is in macro-generated code
411/// if (mapper.is_in_generated(pos)) {
412/// const macro = mapper.generated_by(pos); // e.g., "Debug"
413/// }
414/// ```
415#[napi(js_name = "PositionMapper")]
416pub struct NativePositionMapper {
417 /// Mapping segments sorted by position for binary search.
418 segments: Vec<MappingSegmentResult>,
419 /// Regions marking code generated by macros.
420 generated_regions: Vec<GeneratedRegionResult>,
421}
422
423/// Wrapper around `NativePositionMapper` for NAPI compatibility.
424///
425/// This provides the same functionality as `NativePositionMapper` but with a
426/// different JavaScript class name. Used internally by [`NativePlugin::get_mapper`].
427#[napi(js_name = "NativeMapper")]
428pub struct NativeMapper {
429 /// The underlying position mapper implementation.
430 inner: NativePositionMapper,
431}
432
433#[napi]
434impl NativePositionMapper {
435 /// Creates a new position mapper from source mapping data.
436 ///
437 /// # Arguments
438 ///
439 /// * `mapping` - The source mapping result from macro expansion
440 ///
441 /// # Returns
442 ///
443 /// A new `NativePositionMapper` ready for position translation.
444 #[napi(constructor)]
445 pub fn new(mapping: SourceMappingResult) -> Self {
446 Self {
447 segments: mapping.segments,
448 generated_regions: mapping.generated_regions,
449 }
450 }
451
452 /// Checks if this mapper has no mapping data.
453 ///
454 /// An empty mapper indicates no transformations occurred, so position
455 /// translation is an identity operation.
456 ///
457 /// # Returns
458 ///
459 /// `true` if there are no segments and no generated regions.
460 #[napi(js_name = "isEmpty")]
461 pub fn is_empty(&self) -> bool {
462 self.segments.is_empty() && self.generated_regions.is_empty()
463 }
464
465 /// Converts a position in the original source to the corresponding position in expanded source.
466 ///
467 /// Uses binary search for O(log n) lookup performance.
468 ///
469 /// # Arguments
470 ///
471 /// * `pos` - Byte offset in the original source
472 ///
473 /// # Returns
474 ///
475 /// The corresponding byte offset in the expanded source. If the position falls
476 /// in a gap between segments, returns the position unchanged. If after the last
477 /// segment, extrapolates based on the delta.
478 ///
479 /// # Algorithm
480 ///
481 /// 1. Binary search to find the segment containing or after `pos`
482 /// 2. If inside a segment, compute offset within segment and translate
483 /// 3. If after all segments, extrapolate from the last segment
484 /// 4. Otherwise, return position unchanged (gap or before first segment)
485 #[napi]
486 pub fn original_to_expanded(&self, pos: u32) -> u32 {
487 // Binary search to find the first segment where original_end > pos.
488 // This gives us the segment that might contain pos, or the one after it.
489 let idx = self.segments.partition_point(|seg| seg.original_end <= pos);
490
491 if let Some(seg) = self.segments.get(idx) {
492 // Check if pos is actually inside this segment (it might be in a gap)
493 if pos >= seg.original_start && pos < seg.original_end {
494 // Position is within this segment - calculate the offset and translate
495 let offset = pos - seg.original_start;
496 return seg.expanded_start + offset;
497 }
498 }
499
500 // Handle case where position is after the last segment.
501 // Extrapolate by adding the delta from the end of the last segment.
502 if let Some(last) = self.segments.last()
503 && pos >= last.original_end
504 {
505 let delta = pos - last.original_end;
506 return last.expanded_end + delta;
507 }
508
509 // Fallback for positions before first segment or in gaps between segments.
510 // Return unchanged as an identity mapping.
511 pos
512 }
513
514 /// Converts a position in the expanded source back to the original source position.
515 ///
516 /// Returns `None` if the position is inside macro-generated code that has no
517 /// corresponding location in the original source.
518 ///
519 /// # Arguments
520 ///
521 /// * `pos` - Byte offset in the expanded source
522 ///
523 /// # Returns
524 ///
525 /// `Some(original_pos)` if the position maps to original code,
526 /// `None` if the position is in macro-generated code.
527 #[napi]
528 pub fn expanded_to_original(&self, pos: u32) -> Option<u32> {
529 // First check if the position is in a generated region (no original mapping)
530 if self.is_in_generated(pos) {
531 return None;
532 }
533
534 // Binary search to find the segment containing or after this expanded position
535 let idx = self.segments.partition_point(|seg| seg.expanded_end <= pos);
536
537 if let Some(seg) = self.segments.get(idx)
538 && pos >= seg.expanded_start
539 && pos < seg.expanded_end
540 {
541 // Position is within this segment - translate back to original
542 let offset = pos - seg.expanded_start;
543 return Some(seg.original_start + offset);
544 }
545
546 // Handle extrapolation after the last segment
547 if let Some(last) = self.segments.last()
548 && pos >= last.expanded_end
549 {
550 let delta = pos - last.expanded_end;
551 return Some(last.original_end + delta);
552 }
553
554 // Position doesn't map to any segment
555 None
556 }
557
558 /// Returns the name of the macro that generated code at the given position.
559 ///
560 /// # Arguments
561 ///
562 /// * `pos` - Byte offset in the expanded source
563 ///
564 /// # Returns
565 ///
566 /// `Some(macro_name)` if the position is inside generated code (e.g., "Debug"),
567 /// `None` if the position is in original (non-generated) code.
568 #[napi]
569 pub fn generated_by(&self, pos: u32) -> Option<String> {
570 // Generated regions are typically small in number, so linear scan is acceptable.
571 // If this becomes a bottleneck with many macros, could be optimized with binary search.
572 self.generated_regions
573 .iter()
574 .find(|r| pos >= r.start && pos < r.end)
575 .map(|r| r.source_macro.clone())
576 }
577
578 /// Maps a span (start + length) from expanded source to original source.
579 ///
580 /// # Arguments
581 ///
582 /// * `start` - Start byte offset in expanded source
583 /// * `length` - Length of the span in bytes
584 ///
585 /// # Returns
586 ///
587 /// `Some(SpanResult)` with the mapped span in original source,
588 /// `None` if either endpoint is in generated code.
589 #[napi]
590 pub fn map_span_to_original(&self, start: u32, length: u32) -> Option<SpanResult> {
591 let end = start.saturating_add(length);
592 // Both start and end must successfully map for the span to be valid
593 let original_start = self.expanded_to_original(start)?;
594 let original_end = self.expanded_to_original(end)?;
595
596 Some(SpanResult {
597 start: original_start,
598 length: original_end.saturating_sub(original_start),
599 })
600 }
601
602 /// Maps a span (start + length) from original source to expanded source.
603 ///
604 /// This always succeeds since every original position has an expanded equivalent.
605 ///
606 /// # Arguments
607 ///
608 /// * `start` - Start byte offset in original source
609 /// * `length` - Length of the span in bytes
610 ///
611 /// # Returns
612 ///
613 /// A `SpanResult` with the mapped span in expanded source.
614 #[napi]
615 pub fn map_span_to_expanded(&self, start: u32, length: u32) -> SpanResult {
616 let end = start.saturating_add(length);
617 let expanded_start = self.original_to_expanded(start);
618 let expanded_end = self.original_to_expanded(end);
619
620 SpanResult {
621 start: expanded_start,
622 length: expanded_end.saturating_sub(expanded_start),
623 }
624 }
625
626 /// Checks if a position is inside macro-generated code.
627 ///
628 /// # Arguments
629 ///
630 /// * `pos` - Byte offset in the expanded source
631 ///
632 /// # Returns
633 ///
634 /// `true` if the position is inside a generated region, `false` otherwise.
635 #[napi]
636 pub fn is_in_generated(&self, pos: u32) -> bool {
637 self.generated_regions
638 .iter()
639 .any(|r| pos >= r.start && pos < r.end)
640 }
641}
642
643#[napi]
644impl NativeMapper {
645 /// Creates a new mapper wrapping the given source mapping.
646 ///
647 /// # Arguments
648 ///
649 /// * `mapping` - The source mapping result from macro expansion
650 #[napi(constructor)]
651 pub fn new(mapping: SourceMappingResult) -> Self {
652 Self {
653 inner: NativePositionMapper::new(mapping),
654 }
655 }
656
657 /// Checks if this mapper has no mapping data.
658 #[napi(js_name = "isEmpty")]
659 pub fn is_empty(&self) -> bool {
660 self.inner.is_empty()
661 }
662
663 /// Converts a position in the original source to expanded source.
664 /// See [`NativePositionMapper::original_to_expanded`] for details.
665 #[napi]
666 pub fn original_to_expanded(&self, pos: u32) -> u32 {
667 self.inner.original_to_expanded(pos)
668 }
669
670 /// Converts a position in the expanded source back to original.
671 /// See [`NativePositionMapper::expanded_to_original`] for details.
672 #[napi]
673 pub fn expanded_to_original(&self, pos: u32) -> Option<u32> {
674 self.inner.expanded_to_original(pos)
675 }
676
677 /// Returns the name of the macro that generated code at the given position.
678 /// See [`NativePositionMapper::generated_by`] for details.
679 #[napi]
680 pub fn generated_by(&self, pos: u32) -> Option<String> {
681 self.inner.generated_by(pos)
682 }
683
684 /// Maps a span from expanded source to original source.
685 /// See [`NativePositionMapper::map_span_to_original`] for details.
686 #[napi]
687 pub fn map_span_to_original(&self, start: u32, length: u32) -> Option<SpanResult> {
688 self.inner.map_span_to_original(start, length)
689 }
690
691 /// Maps a span from original source to expanded source.
692 /// See [`NativePositionMapper::map_span_to_expanded`] for details.
693 #[napi]
694 pub fn map_span_to_expanded(&self, start: u32, length: u32) -> SpanResult {
695 self.inner.map_span_to_expanded(start, length)
696 }
697
698 /// Checks if a position is inside macro-generated code.
699 /// See [`NativePositionMapper::is_in_generated`] for details.
700 #[napi]
701 pub fn is_in_generated(&self, pos: u32) -> bool {
702 self.inner.is_in_generated(pos)
703 }
704}
705
706/// Checks if the given TypeScript code has valid syntax.
707///
708/// This function attempts to parse the code using SWC's TypeScript parser
709/// without performing any macro expansion.
710///
711/// # Arguments
712///
713/// * `code` - The TypeScript source code to check
714/// * `filepath` - The file path (used to determine if it's TSX based on extension)
715///
716/// # Returns
717///
718/// A [`SyntaxCheckResult`] indicating success or containing the parse error.
719///
720/// # Example
721///
722/// ```javascript
723/// const result = check_syntax("const x: number = 42;", "test.ts");
724/// if (!result.ok) {
725/// console.error("Syntax error:", result.error);
726/// }
727/// ```
728#[napi]
729pub fn check_syntax(code: String, filepath: String) -> SyntaxCheckResult {
730 match parse_program(&code, &filepath) {
731 Ok(_) => SyntaxCheckResult {
732 ok: true,
733 error: None,
734 },
735 Err(err) => SyntaxCheckResult {
736 ok: false,
737 error: Some(err.to_string()),
738 },
739 }
740}
741
742// ============================================================================
743// Core Plugin Logic
744// ============================================================================
745
746/// Options for processing a file through the macro system.
747///
748/// Used by [`NativePlugin::process_file`] to configure expansion behavior
749/// and caching.
750#[napi(object)]
751pub struct ProcessFileOptions {
752 /// If `true`, preserves `@derive` decorators in the output.
753 /// If `false` (default), decorators are stripped after expansion.
754 pub keep_decorators: Option<bool>,
755 /// Version string for cache invalidation.
756 /// When provided, cached results are only reused if versions match.
757 pub version: Option<String>,
758 /// Additional decorator module names from external macros.
759 /// See [`ExpandOptions::external_decorator_modules`] for details.
760 pub external_decorator_modules: Option<Vec<String>>,
761 /// Path to a previously loaded config file (for foreign types lookup).
762 /// See [`ExpandOptions::config_path`] for details.
763 pub config_path: Option<String>,
764}
765
766/// Options for macro expansion.
767///
768/// Used by [`expand_sync`] to configure expansion behavior.
769#[napi(object)]
770pub struct ExpandOptions {
771 /// If `true`, preserves `@derive` decorators in the output.
772 /// If `false` (default), decorators are stripped after expansion.
773 pub keep_decorators: Option<bool>,
774
775 /// Additional decorator module names from external macros.
776 ///
777 /// These are used during decorator stripping to identify Macroforge-specific
778 /// decorators that should be removed from the output. Built-in decorator modules
779 /// (like "serde", "debug") are automatically included.
780 ///
781 /// External macro packages should export their decorator module names, which
782 /// plugins can collect and pass here.
783 ///
784 /// # Example
785 ///
786 /// ```javascript
787 /// expandSync(code, filepath, {
788 /// keepDecorators: false,
789 /// externalDecoratorModules: ["myMacro", "customValidator"]
790 /// });
791 /// ```
792 pub external_decorator_modules: Option<Vec<String>>,
793
794 /// Path to a previously loaded config file.
795 ///
796 /// When provided, the expansion will use the cached configuration
797 /// (including foreign types) from this path. The config must have been
798 /// previously loaded via [`load_config`].
799 ///
800 /// # Example
801 ///
802 /// ```javascript
803 /// // First, load the config
804 /// const configResult = loadConfig(configContent, configPath);
805 ///
806 /// // Then use it during expansion
807 /// expandSync(code, filepath, { configPath });
808 /// ```
809 pub config_path: Option<String>,
810}
811
812/// The main plugin class for macro expansion with caching support.
813///
814/// `NativePlugin` is designed to be instantiated once and reused across multiple
815/// file processing operations. It maintains a cache of expansion results keyed
816/// by filepath and version, enabling efficient incremental processing.
817///
818/// # Thread Safety
819///
820/// The plugin is thread-safe through the use of `Mutex` for internal state.
821/// However, macro expansion itself runs in a separate thread with a 32MB stack
822/// to prevent stack overflow during deep AST recursion.
823///
824/// # Example
825///
826/// ```javascript
827/// // Create a single plugin instance (typically at startup)
828/// const plugin = new NativePlugin();
829///
830/// // Process files with caching
831/// const result1 = plugin.process_file("src/foo.ts", code1, { version: "1" });
832/// const result2 = plugin.process_file("src/foo.ts", code2, { version: "1" }); // Cache hit!
833/// const result3 = plugin.process_file("src/foo.ts", code3, { version: "2" }); // Cache miss
834///
835/// // Get a mapper for position translation
836/// const mapper = plugin.get_mapper("src/foo.ts");
837/// ```
838#[napi]
839pub struct NativePlugin {
840 /// Cache of expansion results, keyed by filepath.
841 /// Protected by a mutex for thread-safe access.
842 cache: std::sync::Mutex<std::collections::HashMap<String, CachedResult>>,
843 /// Optional path to a log file for debugging.
844 /// Protected by a mutex for thread-safe access.
845 log_file: std::sync::Mutex<Option<std::path::PathBuf>>,
846}
847
848impl Default for NativePlugin {
849 fn default() -> Self {
850 Self::new()
851 }
852}
853
854/// Internal structure for cached expansion results.
855#[derive(Clone)]
856struct CachedResult {
857 /// Version string at the time of caching.
858 /// Used for cache invalidation when version changes.
859 version: Option<String>,
860 /// The cached expansion result.
861 result: ExpandResult,
862}
863
864/// Converts `ProcessFileOptions` to `ExpandOptions`.
865///
866/// Extracts only the options relevant for expansion, discarding cache-related
867/// options like `version`.
868fn option_expand_options(opts: Option<ProcessFileOptions>) -> Option<ExpandOptions> {
869 opts.map(|o| ExpandOptions {
870 keep_decorators: o.keep_decorators,
871 external_decorator_modules: o.external_decorator_modules,
872 config_path: o.config_path,
873 })
874}
875
876#[napi]
877impl NativePlugin {
878 /// Creates a new `NativePlugin` instance.
879 ///
880 /// Initializes the plugin with an empty cache and sets up a default log file
881 /// at `/tmp/macroforge-plugin.log` for debugging purposes.
882 ///
883 /// # Returns
884 ///
885 /// A new `NativePlugin` ready for processing files.
886 ///
887 /// # Side Effects
888 ///
889 /// Creates or clears the log file at `/tmp/macroforge-plugin.log`.
890 #[napi(constructor)]
891 pub fn new() -> Self {
892 let plugin = Self {
893 cache: std::sync::Mutex::new(std::collections::HashMap::new()),
894 log_file: std::sync::Mutex::new(None),
895 };
896
897 // Initialize log file with default path for debugging.
898 // This is useful for diagnosing issues in production environments
899 // where console output might not be easily accessible.
900 if let Ok(mut log_guard) = plugin.log_file.lock() {
901 let log_path = std::path::PathBuf::from("/tmp/macroforge-plugin.log");
902
903 // Clear/create log file to start fresh on each plugin instantiation
904 if let Err(e) = std::fs::write(&log_path, "=== macroforge plugin loaded ===\n") {
905 eprintln!("[macroforge] Failed to initialize log file: {}", e);
906 } else {
907 *log_guard = Some(log_path);
908 }
909 }
910
911 plugin
912 }
913
914 /// Writes a message to the plugin's log file.
915 ///
916 /// Useful for debugging macro expansion issues in production environments.
917 ///
918 /// # Arguments
919 ///
920 /// * `message` - The message to log
921 ///
922 /// # Note
923 ///
924 /// Messages are appended to the log file. If the log file hasn't been
925 /// configured or cannot be written to, the message is silently dropped.
926 #[napi]
927 pub fn log(&self, message: String) {
928 if let Ok(log_guard) = self.log_file.lock()
929 && let Some(log_path) = log_guard.as_ref()
930 {
931 use std::io::Write;
932 if let Ok(mut file) = std::fs::OpenOptions::new()
933 .append(true)
934 .create(true)
935 .open(log_path)
936 {
937 let _ = writeln!(file, "{}", message);
938 }
939 }
940 }
941
942 /// Sets the path for the plugin's log file.
943 ///
944 /// # Arguments
945 ///
946 /// * `path` - The file path to use for logging
947 ///
948 /// # Note
949 ///
950 /// This does not create the file; it will be created when the first
951 /// message is logged.
952 #[napi]
953 pub fn set_log_file(&self, path: String) {
954 if let Ok(mut log_guard) = self.log_file.lock() {
955 *log_guard = Some(std::path::PathBuf::from(path));
956 }
957 }
958
959 /// Processes a TypeScript file through the macro expansion system.
960 ///
961 /// This is the main entry point for file processing. It handles caching,
962 /// thread isolation (to prevent stack overflow), and error recovery.
963 ///
964 /// # Arguments
965 ///
966 /// * `_env` - NAPI environment (unused but required by NAPI)
967 /// * `filepath` - Path to the file (used for TSX detection and caching)
968 /// * `code` - The TypeScript source code to process
969 /// * `options` - Optional configuration for expansion and caching
970 ///
971 /// # Returns
972 ///
973 /// An [`ExpandResult`] containing the expanded code, diagnostics, and source mapping.
974 ///
975 /// # Errors
976 ///
977 /// Returns an error if:
978 /// - Thread spawning fails
979 /// - The worker thread panics (often due to stack overflow)
980 /// - Macro expansion fails internally
981 ///
982 /// # Performance
983 ///
984 /// - Uses a 32MB thread stack to prevent stack overflow during deep AST recursion
985 /// - Caches results by filepath and version for efficient incremental processing
986 /// - Early bailout for files without `@derive` decorators
987 ///
988 /// # Thread Safety
989 ///
990 /// Macro expansion runs in a separate thread because:
991 /// 1. SWC AST operations can be deeply recursive, exceeding default stack limits
992 /// 2. Node.js thread stack is typically only 2MB
993 /// 3. Panics in the worker thread are caught and reported gracefully
994 #[napi]
995 pub fn process_file(
996 &self,
997 _env: Env,
998 filepath: String,
999 code: String,
1000 options: Option<ProcessFileOptions>,
1001 ) -> Result<ExpandResult> {
1002 let version = options.as_ref().and_then(|o| o.version.clone());
1003
1004 // Cache Check: Return cached result if version matches.
1005 // This enables efficient incremental processing when files haven't changed.
1006 if let (Some(ver), Ok(guard)) = (version.as_ref(), self.cache.lock())
1007 && let Some(cached) = guard.get(&filepath)
1008 && cached.version.as_ref() == Some(ver)
1009 {
1010 return Ok(cached.result.clone());
1011 }
1012
1013 // Run expansion in a separate thread with a LARGE stack (32MB).
1014 // Standard threads (and Node threads) often have 2MB stacks, which causes
1015 // "Broken pipe" / SEGFAULTS when SWC recurses deeply in macros.
1016 let opts_clone = option_expand_options(options);
1017 let filepath_for_thread = filepath.clone();
1018
1019 let builder = std::thread::Builder::new().stack_size(32 * 1024 * 1024);
1020 let handle = builder
1021 .spawn(move || {
1022 // Set up SWC globals for this thread.
1023 // SWC uses thread-local storage for some operations.
1024 let globals = Globals::default();
1025 GLOBALS.set(&globals, || {
1026 // Catch panics to report them gracefully instead of crashing.
1027 // Common cause: stack overflow from deeply nested AST.
1028 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1029 // Note: NAPI Env is NOT thread safe - we cannot pass it.
1030 // The expand_inner function uses pure Rust AST operations.
1031 expand_inner(&code, &filepath_for_thread, opts_clone)
1032 }))
1033 })
1034 })
1035 .map_err(|e| {
1036 Error::new(
1037 Status::GenericFailure,
1038 format!("Failed to spawn worker thread: {}", e),
1039 )
1040 })?;
1041
1042 // Wait for the worker thread and unwrap nested Results.
1043 // Result structure: join() -> panic catch -> expand_inner -> final result
1044 let expand_result = handle
1045 .join()
1046 .map_err(|_| {
1047 Error::new(
1048 Status::GenericFailure,
1049 "Macro expansion worker thread panicked (Stack Overflow?)",
1050 )
1051 })?
1052 .map_err(|_| {
1053 Error::new(
1054 Status::GenericFailure,
1055 "Macro expansion panicked inside worker",
1056 )
1057 })??;
1058
1059 // Update Cache: Store the result for future requests with the same version.
1060 if let Ok(mut guard) = self.cache.lock() {
1061 guard.insert(
1062 filepath.clone(),
1063 CachedResult {
1064 version,
1065 result: expand_result.clone(),
1066 },
1067 );
1068 }
1069
1070 Ok(expand_result)
1071 }
1072
1073 /// Retrieves a position mapper for a previously processed file.
1074 ///
1075 /// The mapper enables translation between original and expanded source positions,
1076 /// which is essential for IDE features like error reporting and navigation.
1077 ///
1078 /// # Arguments
1079 ///
1080 /// * `filepath` - Path to the file (must have been previously processed)
1081 ///
1082 /// # Returns
1083 ///
1084 /// `Some(NativeMapper)` if the file has been processed and has source mapping data,
1085 /// `None` if the file hasn't been processed or has no mapping (no macros expanded).
1086 #[napi]
1087 pub fn get_mapper(&self, filepath: String) -> Option<NativeMapper> {
1088 let mapping = match self.cache.lock() {
1089 Ok(guard) => guard
1090 .get(&filepath)
1091 .cloned()
1092 .and_then(|c| c.result.source_mapping),
1093 Err(_) => None,
1094 };
1095
1096 mapping.map(|m| NativeMapper {
1097 inner: NativePositionMapper::new(m),
1098 })
1099 }
1100
1101 /// Maps diagnostics from expanded source positions back to original source positions.
1102 ///
1103 /// This is used by IDE integrations to show errors at the correct locations
1104 /// in the user's original code, rather than in the macro-expanded output.
1105 ///
1106 /// # Arguments
1107 ///
1108 /// * `filepath` - Path to the file the diagnostics are for
1109 /// * `diags` - Diagnostics with positions in the expanded source
1110 ///
1111 /// # Returns
1112 ///
1113 /// Diagnostics with positions mapped back to the original source.
1114 /// If no mapper is available for the file, returns diagnostics unchanged.
1115 #[napi]
1116 pub fn map_diagnostics(&self, filepath: String, diags: Vec<JsDiagnostic>) -> Vec<JsDiagnostic> {
1117 let Some(mapper) = self.get_mapper(filepath) else {
1118 // No mapper available - return diagnostics unchanged
1119 return diags;
1120 };
1121
1122 diags
1123 .into_iter()
1124 .map(|mut d| {
1125 // Attempt to map the diagnostic span back to original source
1126 if let (Some(start), Some(length)) = (d.start, d.length)
1127 && let Some(mapped) = mapper.map_span_to_original(start, length)
1128 {
1129 d.start = Some(mapped.start);
1130 d.length = Some(mapped.length);
1131 }
1132 // Note: Diagnostics in generated code cannot be mapped and keep
1133 // their original (expanded) positions
1134 d
1135 })
1136 .collect()
1137 }
1138}
1139
1140// ============================================================================
1141// Sync Functions (Refactored for Thread Safety & Performance)
1142// ============================================================================
1143
1144/// Parses import statements from TypeScript code and returns their sources.
1145///
1146/// This function extracts information about all import statements in the code,
1147/// mapping each imported identifier to its source module. Useful for analyzing
1148/// dependencies and understanding where decorators come from.
1149///
1150/// # Arguments
1151///
1152/// * `code` - The TypeScript source code to parse
1153/// * `filepath` - The file path (used for TSX detection)
1154///
1155/// # Returns
1156///
1157/// A vector of [`ImportSourceResult`] entries, one for each imported identifier.
1158///
1159/// # Errors
1160///
1161/// Returns an error if the code cannot be parsed.
1162///
1163/// # Example
1164///
1165/// ```javascript
1166/// // For code: import { Derive, Clone } from "macroforge-ts";
1167/// const imports = parse_import_sources(code, "test.ts");
1168/// // Returns: [
1169/// // { local: "Derive", module: "macroforge-ts" },
1170/// // { local: "Clone", module: "macroforge-ts" }
1171/// // ]
1172/// ```
1173#[napi]
1174pub fn parse_import_sources(code: String, filepath: String) -> Result<Vec<ImportSourceResult>> {
1175 let (program, _cm) = parse_program(&code, &filepath)?;
1176 let module = match program {
1177 Program::Module(module) => module,
1178 // Scripts don't have import statements
1179 Program::Script(_) => return Ok(vec![]),
1180 };
1181
1182 let import_result = crate::host::collect_import_sources(&module, &code);
1183 let mut imports = Vec::with_capacity(import_result.sources.len());
1184 for (local, module) in import_result.sources {
1185 imports.push(ImportSourceResult { local, module });
1186 }
1187 Ok(imports)
1188}
1189
1190/// The `@Derive` decorator function exported to JavaScript/TypeScript.
1191///
1192/// This is a no-op function that exists purely for TypeScript type checking.
1193/// The actual decorator processing happens during macro expansion, where
1194/// `@derive(...)` decorators are recognized and transformed.
1195///
1196/// # TypeScript Usage
1197///
1198/// ```typescript
1199/// import { Derive } from "macroforge-ts";
1200///
1201/// @Derive(Debug, Clone, Serialize)
1202/// class User {
1203/// name: string;
1204/// email: string;
1205/// }
1206/// ```
1207#[napi(
1208 js_name = "Derive",
1209 ts_return_type = "ClassDecorator",
1210 ts_args_type = "...features: any[]"
1211)]
1212pub fn derive_decorator() {}
1213
1214/// Result of loading a macroforge configuration file.
1215///
1216/// Returned by [`load_config`] after parsing a `macroforge.config.js/ts` file.
1217#[napi(object)]
1218pub struct LoadConfigResult {
1219 /// Whether to preserve `@derive` decorators in the output code.
1220 pub keep_decorators: bool,
1221 /// Whether to generate a convenience const for non-class types.
1222 pub generate_convenience_const: bool,
1223 /// Whether the config has any foreign type handlers defined.
1224 pub has_foreign_types: bool,
1225 /// Number of foreign types configured.
1226 pub foreign_type_count: u32,
1227}
1228
1229/// Load and parse a macroforge configuration file.
1230///
1231/// Parses a `macroforge.config.js/ts` file and caches the result for use
1232/// during macro expansion. The configuration includes both simple settings
1233/// (like `keepDecorators`) and foreign type handlers.
1234///
1235/// # Arguments
1236///
1237/// * `content` - The raw content of the configuration file
1238/// * `filepath` - Path to the configuration file (used to determine syntax and as cache key)
1239///
1240/// # Returns
1241///
1242/// A [`LoadConfigResult`] containing the parsed configuration summary.
1243///
1244/// # Example
1245///
1246/// ```javascript
1247/// import { loadConfig, expandSync } from 'macroforge';
1248/// import fs from 'fs';
1249///
1250/// const configPath = 'macroforge.config.js';
1251/// const configContent = fs.readFileSync(configPath, 'utf-8');
1252///
1253/// // Load and cache the configuration
1254/// const result = loadConfig(configContent, configPath);
1255/// console.log(`Loaded config with ${result.foreignTypeCount} foreign types`);
1256///
1257/// // The config is now cached and will be used by expandSync
1258/// const expanded = expandSync(code, filepath, { configPath });
1259/// ```
1260#[napi]
1261pub fn load_config(content: String, filepath: String) -> Result<LoadConfigResult> {
1262 use crate::host::MacroforgeConfig;
1263
1264 let config = MacroforgeConfig::load_and_cache(&content, &filepath).map_err(|e| {
1265 Error::new(
1266 Status::GenericFailure,
1267 format!("Failed to parse config: {}", e),
1268 )
1269 })?;
1270
1271 Ok(LoadConfigResult {
1272 keep_decorators: config.keep_decorators,
1273 generate_convenience_const: config.generate_convenience_const,
1274 has_foreign_types: !config.foreign_types.is_empty(),
1275 foreign_type_count: config.foreign_types.len() as u32,
1276 })
1277}
1278
1279/// Clears the configuration cache.
1280///
1281/// This is useful for testing to ensure each test starts with a clean state.
1282/// In production, clearing the cache will force configs to be re-parsed on next access.
1283///
1284/// # Example
1285///
1286/// ```javascript
1287/// const { clearConfigCache, loadConfig } = require('macroforge-ts');
1288///
1289/// // Clear cache before each test
1290/// clearConfigCache();
1291///
1292/// // Now load a fresh config
1293/// const result = loadConfig(configContent, configPath);
1294/// ```
1295#[napi]
1296pub fn clear_config_cache() {
1297 crate::host::clear_config_cache();
1298}
1299
1300/// Synchronously transforms TypeScript code through the macro expansion system.
1301///
1302/// This is similar to [`expand_sync`] but returns a [`TransformResult`] which
1303/// includes source map information (when available).
1304///
1305/// # Arguments
1306///
1307/// * `_env` - NAPI environment (unused but required by NAPI)
1308/// * `code` - The TypeScript source code to transform
1309/// * `filepath` - The file path (used for TSX detection)
1310///
1311/// # Returns
1312///
1313/// A [`TransformResult`] containing the transformed code and metadata.
1314///
1315/// # Errors
1316///
1317/// Returns an error if:
1318/// - Thread spawning fails
1319/// - The worker thread panics
1320/// - Macro expansion fails
1321///
1322/// # Thread Safety
1323///
1324/// Uses a 32MB thread stack to prevent stack overflow during deep AST recursion.
1325#[napi]
1326pub fn transform_sync(_env: Env, code: String, filepath: String) -> Result<TransformResult> {
1327 // Run in a separate thread with large stack for deep AST recursion
1328 let builder = std::thread::Builder::new().stack_size(32 * 1024 * 1024);
1329 let handle = builder
1330 .spawn(move || {
1331 let globals = Globals::default();
1332 GLOBALS.set(&globals, || {
1333 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1334 transform_inner(&code, &filepath)
1335 }))
1336 })
1337 })
1338 .map_err(|e| {
1339 Error::new(
1340 Status::GenericFailure,
1341 format!("Failed to spawn transform thread: {}", e),
1342 )
1343 })?;
1344
1345 handle
1346 .join()
1347 .map_err(|_| Error::new(Status::GenericFailure, "Transform worker crashed"))?
1348 .map_err(|_| Error::new(Status::GenericFailure, "Transform panicked"))?
1349}
1350
1351/// Synchronously expands macros in TypeScript code.
1352///
1353/// This is the standalone macro expansion function that doesn't use caching.
1354/// For cached expansion, use [`NativePlugin::process_file`] instead.
1355///
1356/// # Arguments
1357///
1358/// * `_env` - NAPI environment (unused but required by NAPI)
1359/// * `code` - The TypeScript source code to expand
1360/// * `filepath` - The file path (used for TSX detection)
1361/// * `options` - Optional configuration (e.g., `keep_decorators`)
1362///
1363/// # Returns
1364///
1365/// An [`ExpandResult`] containing the expanded code, diagnostics, and source mapping.
1366///
1367/// # Errors
1368///
1369/// Returns an error if:
1370/// - Thread spawning fails
1371/// - The worker thread panics
1372/// - Macro host initialization fails
1373///
1374/// # Performance
1375///
1376/// - Uses a 32MB thread stack to prevent stack overflow
1377/// - Performs early bailout for files without `@derive` decorators
1378///
1379/// # Example
1380///
1381/// ```javascript
1382/// const result = expand_sync(env, code, "user.ts", { keep_decorators: false });
1383/// console.log(result.code); // Expanded TypeScript code
1384/// console.log(result.diagnostics); // Any warnings or errors
1385/// ```
1386#[napi]
1387pub fn expand_sync(
1388 _env: Env,
1389 code: String,
1390 filepath: String,
1391 options: Option<ExpandOptions>,
1392) -> Result<ExpandResult> {
1393 // Run in a separate thread with large stack for deep AST recursion
1394 let builder = std::thread::Builder::new().stack_size(32 * 1024 * 1024);
1395 let handle = builder
1396 .spawn(move || {
1397 let globals = Globals::default();
1398 GLOBALS.set(&globals, || {
1399 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1400 expand_inner(&code, &filepath, options)
1401 }))
1402 })
1403 })
1404 .map_err(|e| {
1405 Error::new(
1406 Status::GenericFailure,
1407 format!("Failed to spawn expand thread: {}", e),
1408 )
1409 })?;
1410
1411 handle
1412 .join()
1413 .map_err(|_| Error::new(Status::GenericFailure, "Expand worker crashed"))?
1414 .map_err(|_| Error::new(Status::GenericFailure, "Expand panicked"))?
1415}
1416
1417// ============================================================================
1418// Inner Logic (Optimized)
1419// ============================================================================
1420
1421/// Core macro expansion logic, decoupled from NAPI Env to allow threading.
1422///
1423/// This function contains the actual expansion implementation and is called
1424/// from a separate thread with a large stack size to prevent stack overflow.
1425///
1426/// # Arguments
1427///
1428/// * `code` - The TypeScript source code to expand
1429/// * `filepath` - The file path (used for TSX detection and error reporting)
1430/// * `options` - Optional expansion configuration
1431///
1432/// # Returns
1433///
1434/// An [`ExpandResult`] containing the expanded code and metadata.
1435///
1436/// # Errors
1437///
1438/// Returns an error if:
1439/// - The macro host fails to initialize
1440/// - Macro expansion fails internally
1441///
1442/// # Algorithm
1443///
1444/// 1. **Early bailout**: If code doesn't contain `@derive`, return unchanged
1445/// 2. **Parse**: Convert code to SWC AST
1446/// 3. **Expand**: Run all registered macros on decorated classes
1447/// 4. **Collect**: Gather diagnostics and source mapping
1448/// 5. **Post-process**: Inject type declarations for generated methods
1449fn expand_inner(
1450 code: &str,
1451 filepath: &str,
1452 options: Option<ExpandOptions>,
1453) -> Result<ExpandResult> {
1454 // Early bailout: Skip files without @derive decorator.
1455 // This optimization avoids expensive parsing for files that don't use macros
1456 // and prevents issues with Svelte runes ($state, $derived, etc.) that use
1457 // similar syntax but aren't macroforge decorators.
1458 if !code.contains("@derive") {
1459 return Ok(ExpandResult::unchanged(code));
1460 }
1461
1462 // Create a new macro host for this thread.
1463 // Each thread needs its own MacroExpander because the expansion process
1464 // is stateful and cannot be safely shared across threads.
1465 let mut macro_host = MacroExpander::new().map_err(|err| {
1466 Error::new(
1467 Status::GenericFailure,
1468 format!("Failed to initialize macro host: {err:?}"),
1469 )
1470 })?;
1471
1472 // Apply options if provided
1473 if let Some(ref opts) = options {
1474 if let Some(keep) = opts.keep_decorators {
1475 macro_host.set_keep_decorators(keep);
1476 }
1477 if let Some(ref modules) = opts.external_decorator_modules {
1478 macro_host.set_external_decorator_modules(modules.clone());
1479 }
1480 }
1481
1482 // Set up foreign types and config imports from config if available
1483 let config_path = options.as_ref().and_then(|o| o.config_path.as_ref());
1484 if let Some(path) = config_path
1485 && let Some(config) = CONFIG_CACHE.get(path)
1486 {
1487 crate::builtin::serde::set_foreign_types(config.foreign_types.clone());
1488 // Convert ImportInfo to just module source strings for the serde module
1489 let config_imports: std::collections::HashMap<String, String> = config
1490 .config_imports
1491 .iter()
1492 .map(|(name, info)| (name.clone(), info.source.clone()))
1493 .collect();
1494 crate::builtin::serde::set_config_imports(config_imports);
1495 }
1496
1497 // Parse the code into an AST.
1498 // On parse errors, we return a graceful "no-op" result instead of failing,
1499 // because parse errors can happen frequently during typing in an IDE.
1500 let (program, _) = match parse_program(code, filepath) {
1501 Ok(p) => p,
1502 Err(e) => {
1503 let error_msg = e.to_string();
1504
1505 // Clean up foreign types and config imports before returning
1506 crate::builtin::serde::clear_foreign_types();
1507 crate::builtin::serde::clear_import_sources();
1508 crate::builtin::serde::clear_config_imports();
1509
1510 // Return a "no-op" expansion result: original code unchanged,
1511 // with an informational diagnostic explaining why.
1512 // This allows the language server to continue functioning smoothly.
1513 return Ok(ExpandResult {
1514 code: code.to_string(),
1515 types: None,
1516 metadata: None,
1517 diagnostics: vec![MacroDiagnostic {
1518 level: "info".to_string(),
1519 message: format!("Macro expansion skipped due to syntax error: {}", error_msg),
1520 start: None,
1521 end: None,
1522 }],
1523 source_mapping: None,
1524 });
1525 }
1526 };
1527
1528 // Extract import sources, aliases, and type-only status for foreign type validation
1529 let (import_sources, import_aliases, type_only_imports) = extract_import_sources(&program);
1530 crate::builtin::serde::set_import_sources(import_sources);
1531 crate::builtin::serde::set_import_aliases(import_aliases);
1532 crate::builtin::serde::set_type_only_imports(type_only_imports);
1533
1534 // Run macro expansion on the parsed AST
1535 let expansion_result = macro_host.expand(code, &program, filepath);
1536
1537 // Clean up all thread-local state after expansion (before error propagation)
1538 crate::builtin::serde::clear_foreign_types();
1539 crate::builtin::serde::clear_import_sources();
1540 crate::builtin::serde::clear_import_aliases();
1541 crate::builtin::serde::clear_type_only_imports();
1542 crate::builtin::serde::clear_required_namespace_imports();
1543 crate::builtin::serde::clear_config_imports();
1544
1545 // Now propagate any error
1546 let expansion = expansion_result.map_err(|err| {
1547 Error::new(
1548 Status::GenericFailure,
1549 format!("Macro expansion failed: {err:?}"),
1550 )
1551 })?;
1552
1553 // Convert internal diagnostics to NAPI-compatible format
1554 let diagnostics = expansion
1555 .diagnostics
1556 .into_iter()
1557 .map(|d| MacroDiagnostic {
1558 level: format!("{:?}", d.level).to_lowercase(),
1559 message: d.message,
1560 start: d.span.map(|s| s.start),
1561 end: d.span.map(|s| s.end),
1562 })
1563 .collect();
1564
1565 // Convert internal source mapping to NAPI-compatible format
1566 let source_mapping = expansion.source_mapping.map(|mapping| SourceMappingResult {
1567 segments: mapping
1568 .segments
1569 .into_iter()
1570 .map(|seg| MappingSegmentResult {
1571 original_start: seg.original_start,
1572 original_end: seg.original_end,
1573 expanded_start: seg.expanded_start,
1574 expanded_end: seg.expanded_end,
1575 })
1576 .collect(),
1577 generated_regions: mapping
1578 .generated_regions
1579 .into_iter()
1580 .map(|region| GeneratedRegionResult {
1581 start: region.start,
1582 end: region.end,
1583 source_macro: region.source_macro,
1584 })
1585 .collect(),
1586 });
1587
1588 // Post-process type declarations.
1589 // Heuristic fix: If the expanded code contains toJSON() but the type
1590 // declarations don't, inject the type signature. This ensures IDEs
1591 // provide proper type information for serialized objects.
1592 let mut types_output = expansion.type_output;
1593 if let Some(types) = &mut types_output
1594 && expansion.code.contains("toJSON(")
1595 && !types.contains("toJSON(")
1596 {
1597 // Find the last closing brace and insert before it.
1598 // This is a heuristic that works for simple cases.
1599 if let Some(insert_at) = types.rfind('}') {
1600 types.insert_str(insert_at, " toJSON(): Record<string, unknown>;\n");
1601 }
1602 }
1603
1604 Ok(ExpandResult {
1605 code: expansion.code,
1606 types: types_output,
1607 // Only include metadata if there were classes processed
1608 metadata: if expansion.classes.is_empty() {
1609 None
1610 } else {
1611 serde_json::to_string(&expansion.classes).ok()
1612 },
1613 diagnostics,
1614 source_mapping,
1615 })
1616}
1617
1618/// Core transform logic, decoupled from NAPI Env to allow threading.
1619///
1620/// Similar to [`expand_inner`] but returns a [`TransformResult`] and fails
1621/// on any error-level diagnostics.
1622///
1623/// # Arguments
1624///
1625/// * `code` - The TypeScript source code to transform
1626/// * `filepath` - The file path (used for TSX detection)
1627///
1628/// # Returns
1629///
1630/// A [`TransformResult`] containing the transformed code.
1631///
1632/// # Errors
1633///
1634/// Returns an error if:
1635/// - The macro host fails to initialize
1636/// - Parsing fails
1637/// - Expansion fails
1638/// - Any error-level diagnostic is emitted
1639fn transform_inner(code: &str, filepath: &str) -> Result<TransformResult> {
1640 let macro_host = MacroExpander::new().map_err(|err| {
1641 Error::new(
1642 Status::GenericFailure,
1643 format!("Failed to init host: {err:?}"),
1644 )
1645 })?;
1646
1647 let (program, cm) = parse_program(code, filepath)?;
1648
1649 let expansion = macro_host
1650 .expand(code, &program, filepath)
1651 .map_err(|err| Error::new(Status::GenericFailure, format!("Expansion failed: {err:?}")))?;
1652
1653 // Unlike expand_inner, transform_inner treats errors as fatal
1654 handle_macro_diagnostics(&expansion.diagnostics, filepath)?;
1655
1656 // Optimization: Only re-emit if we didn't change anything.
1657 // If expansion.changed is true, we already have the string from the expander.
1658 // Otherwise, emit the original AST to string (no changes made).
1659 let generated = if expansion.changed {
1660 expansion.code
1661 } else {
1662 emit_program(&program, &cm)?
1663 };
1664
1665 let metadata = if expansion.classes.is_empty() {
1666 None
1667 } else {
1668 serde_json::to_string(&expansion.classes).ok()
1669 };
1670
1671 Ok(TransformResult {
1672 code: generated,
1673 map: None, // Source mapping handled separately via SourceMappingResult
1674 types: expansion.type_output,
1675 metadata,
1676 })
1677}
1678
1679/// Parses TypeScript source code into an SWC AST.
1680///
1681/// # Arguments
1682///
1683/// * `code` - The TypeScript source code
1684/// * `filepath` - The file path (used to determine TSX mode from extension)
1685///
1686/// # Returns
1687///
1688/// A tuple of `(Program, SourceMap)` on success.
1689///
1690/// # Errors
1691///
1692/// Returns an error if the code contains syntax errors.
1693///
1694/// # Configuration
1695///
1696/// - TSX mode is enabled for `.tsx` files
1697/// - Decorators are always enabled
1698/// - Uses latest ES version
1699/// - `no_early_errors` is enabled for better error recovery
1700fn parse_program(code: &str, filepath: &str) -> Result<(Program, Lrc<SourceMap>)> {
1701 let cm: Lrc<SourceMap> = Lrc::new(SourceMap::default());
1702 let fm = cm.new_source_file(
1703 FileName::Custom(filepath.to_string()).into(),
1704 code.to_string(),
1705 );
1706 // Create a handler that captures errors to a buffer (we don't use its output directly)
1707 let handler =
1708 Handler::with_emitter_writer(Box::new(std::io::Cursor::new(Vec::new())), Some(cm.clone()));
1709
1710 // Configure the lexer for TypeScript with decorator support
1711 let lexer = Lexer::new(
1712 Syntax::Typescript(TsSyntax {
1713 tsx: filepath.ends_with(".tsx"), // Enable TSX for .tsx files
1714 decorators: true, // Required for @derive decorators
1715 dts: false, // Not parsing .d.ts files
1716 no_early_errors: true, // Better error recovery during typing
1717 ..Default::default()
1718 }),
1719 EsVersion::latest(),
1720 StringInput::from(&*fm),
1721 None, // No comments collection
1722 );
1723
1724 let mut parser = Parser::new_from(lexer);
1725 match parser.parse_program() {
1726 Ok(program) => Ok((program, cm)),
1727 Err(error) => {
1728 // Format and emit the error for debugging purposes
1729 let msg = format!("Failed to parse TypeScript: {:?}", error);
1730 error.into_diagnostic(&handler).emit();
1731 Err(Error::new(Status::GenericFailure, msg))
1732 }
1733 }
1734}
1735
1736/// Emits an SWC AST back to JavaScript/TypeScript source code.
1737///
1738/// # Arguments
1739///
1740/// * `program` - The AST to emit
1741/// * `cm` - The source map (used for line/column tracking)
1742///
1743/// # Returns
1744///
1745/// The generated source code as a string.
1746///
1747/// # Errors
1748///
1749/// Returns an error if code generation fails (rare).
1750fn emit_program(program: &Program, cm: &Lrc<SourceMap>) -> Result<String> {
1751 let mut buf = vec![];
1752 let mut emitter = Emitter {
1753 cfg: swc_core::ecma::codegen::Config::default(),
1754 cm: cm.clone(),
1755 comments: None,
1756 wr: Box::new(JsWriter::new(cm.clone(), "\n", &mut buf, None)),
1757 };
1758 emitter
1759 .emit_program(program)
1760 .map_err(|e| Error::new(Status::GenericFailure, format!("{:?}", e)))?;
1761 Ok(String::from_utf8_lossy(&buf).to_string())
1762}
1763
1764/// Checks diagnostics for errors and returns the first error as a Result.
1765///
1766/// This is used by [`transform_inner`] which treats errors as fatal,
1767/// unlike [`expand_inner`] which allows non-fatal errors in diagnostics.
1768///
1769/// # Arguments
1770///
1771/// * `diags` - The diagnostics to check
1772/// * `file` - The file path for error location reporting
1773///
1774/// # Returns
1775///
1776/// `Ok(())` if no errors, `Err` with the first error message otherwise.
1777fn handle_macro_diagnostics(diags: &[Diagnostic], file: &str) -> Result<()> {
1778 for diag in diags {
1779 if matches!(diag.level, DiagnosticLevel::Error) {
1780 // Format error location for helpful error messages
1781 let loc = diag
1782 .span
1783 .map(|s| format!("{}:{}-{}", file, s.start, s.end))
1784 .unwrap_or_else(|| file.to_string());
1785 return Err(Error::new(
1786 Status::GenericFailure,
1787 format!("Macro error at {}: {}", loc, diag.message),
1788 ));
1789 }
1790 }
1791 Ok(())
1792}
1793
1794/// Extracts import sources from a parsed program.
1795///
1796/// Maps imported identifiers to their module sources, which is used for
1797/// foreign type import source validation.
1798///
1799/// # Arguments
1800///
1801/// * `program` - The parsed TypeScript/JavaScript program
1802///
1803/// # Returns
1804///
1805/// A tuple of three HashMaps:
1806/// - `sources`: Maps imported identifier names to their module sources
1807/// - `aliases`: Maps local alias names to their original imported names
1808/// - `type_only`: Maps identifier names to whether they are type-only imports
1809///
1810/// # Example
1811///
1812/// For `import { DateTime } from 'effect'`, this returns:
1813/// - sources: `{"DateTime": "effect"}`
1814/// - type_only: `{"DateTime": false}`
1815///
1816/// For `import type { DateTime } from 'effect'`, this returns:
1817/// - sources: `{"DateTime": "effect"}`
1818/// - type_only: `{"DateTime": true}`
1819///
1820/// For `import { Option as EffectOption }`, this returns aliases `{"EffectOption": "Option"}`.
1821fn extract_import_sources(
1822 program: &Program,
1823) -> (
1824 std::collections::HashMap<String, String>,
1825 std::collections::HashMap<String, String>,
1826 std::collections::HashMap<String, bool>,
1827) {
1828 use swc_core::ecma::ast::{ImportSpecifier, ModuleDecl, ModuleExportName, ModuleItem};
1829
1830 let mut sources = std::collections::HashMap::new();
1831 let mut aliases = std::collections::HashMap::new();
1832 let mut type_only = std::collections::HashMap::new();
1833
1834 let module = match program {
1835 Program::Module(m) => m,
1836 Program::Script(_) => return (sources, aliases, type_only),
1837 };
1838
1839 for item in &module.body {
1840 if let ModuleItem::ModuleDecl(ModuleDecl::Import(import)) = item {
1841 let source = String::from_utf8_lossy(import.src.value.as_bytes()).to_string();
1842 // Check if the entire import statement is type-only: `import type { X } from "..."`
1843 let is_import_type_only = import.type_only;
1844
1845 for specifier in &import.specifiers {
1846 match specifier {
1847 ImportSpecifier::Named(named) => {
1848 let local = named.local.sym.to_string();
1849 sources.insert(local.clone(), source.clone());
1850 // An import is type-only if either the import statement or the specifier is type-only
1851 // e.g., `import type { X }` or `import { type X }`
1852 type_only.insert(local.clone(), is_import_type_only || named.is_type_only);
1853
1854 // Track aliases: if there's an imported name different from local
1855 if let Some(imported) = &named.imported {
1856 let original_name = match imported {
1857 ModuleExportName::Ident(ident) => ident.sym.to_string(),
1858 ModuleExportName::Str(s) => {
1859 String::from_utf8_lossy(s.value.as_bytes()).to_string()
1860 }
1861 };
1862 if original_name != local {
1863 aliases.insert(local, original_name);
1864 }
1865 }
1866 }
1867 ImportSpecifier::Default(default) => {
1868 let local = default.local.sym.to_string();
1869 sources.insert(local.clone(), source.clone());
1870 type_only.insert(local, is_import_type_only);
1871 }
1872 ImportSpecifier::Namespace(ns) => {
1873 let local = ns.local.sym.to_string();
1874 sources.insert(local.clone(), source.clone());
1875 type_only.insert(local, is_import_type_only);
1876 }
1877 }
1878 }
1879 }
1880 }
1881
1882 (sources, aliases, type_only)
1883}
1884
1885// ============================================================================
1886// Manifest / Debug API
1887// ============================================================================
1888
1889/// Entry for a registered macro in the manifest.
1890///
1891/// Used by [`MacroManifest`] to describe available macros to tooling
1892/// such as IDE extensions and documentation generators.
1893#[napi(object)]
1894pub struct MacroManifestEntry {
1895 /// The macro name (e.g., "Debug", "Clone", "Serialize").
1896 pub name: String,
1897 /// The macro kind: "derive", "attribute", or "function".
1898 pub kind: String,
1899 /// Human-readable description of what the macro does.
1900 pub description: String,
1901 /// The package that provides this macro (e.g., "macroforge-ts").
1902 pub package: String,
1903}
1904
1905/// Entry for a registered decorator in the manifest.
1906///
1907/// Used by [`MacroManifest`] to describe field-level decorators
1908/// that can be used with macros.
1909#[napi(object)]
1910pub struct DecoratorManifestEntry {
1911 /// The module this decorator belongs to (e.g., "serde").
1912 pub module: String,
1913 /// The exported name of the decorator (e.g., "skip", "rename").
1914 pub export: String,
1915 /// The decorator kind: "class", "property", "method", "accessor", "parameter".
1916 pub kind: String,
1917 /// Documentation string for the decorator.
1918 pub docs: String,
1919}
1920
1921/// Complete manifest of all available macros and decorators.
1922///
1923/// This is returned by [`get_macro_manifest`] and is useful for:
1924/// - IDE autocompletion
1925/// - Documentation generation
1926/// - Tooling integration
1927#[napi(object)]
1928pub struct MacroManifest {
1929 /// ABI version for compatibility checking.
1930 pub version: u32,
1931 /// All registered macros (derive, attribute, function).
1932 pub macros: Vec<MacroManifestEntry>,
1933 /// All registered field/class decorators.
1934 pub decorators: Vec<DecoratorManifestEntry>,
1935}
1936
1937/// Returns the complete manifest of all registered macros and decorators.
1938///
1939/// This is a debug/introspection API that allows tooling to discover
1940/// what macros are available at runtime.
1941///
1942/// # Returns
1943///
1944/// A [`MacroManifest`] containing all registered macros and decorators.
1945///
1946/// # Example (JavaScript)
1947///
1948/// ```javascript
1949/// const manifest = __macroforgeGetManifest();
1950/// console.log("Available macros:", manifest.macros.map(m => m.name));
1951/// // ["Debug", "Clone", "PartialEq", "Hash", "Serialize", "Deserialize", ...]
1952/// ```
1953#[napi(js_name = "__macroforgeGetManifest")]
1954pub fn get_macro_manifest() -> MacroManifest {
1955 let manifest = derived::get_manifest();
1956 MacroManifest {
1957 version: manifest.version,
1958 macros: manifest
1959 .macros
1960 .into_iter()
1961 .map(|m| MacroManifestEntry {
1962 name: m.name.to_string(),
1963 kind: format!("{:?}", m.kind).to_lowercase(),
1964 description: m.description.to_string(),
1965 package: m.package.to_string(),
1966 })
1967 .collect(),
1968 decorators: manifest
1969 .decorators
1970 .into_iter()
1971 .map(|d| DecoratorManifestEntry {
1972 module: d.module.to_string(),
1973 export: d.export.to_string(),
1974 kind: format!("{:?}", d.kind).to_lowercase(),
1975 docs: d.docs.to_string(),
1976 })
1977 .collect(),
1978 }
1979}
1980
1981/// Checks if any macros are registered in this package.
1982///
1983/// Useful for build tools to determine if macro expansion is needed.
1984///
1985/// # Returns
1986///
1987/// `true` if at least one macro is registered, `false` otherwise.
1988#[napi(js_name = "__macroforgeIsMacroPackage")]
1989pub fn is_macro_package() -> bool {
1990 !derived::macro_names().is_empty()
1991}
1992
1993/// Returns the names of all registered macros.
1994///
1995/// # Returns
1996///
1997/// A vector of macro names (e.g., `["Debug", "Clone", "Serialize"]`).
1998#[napi(js_name = "__macroforgeGetMacroNames")]
1999pub fn get_macro_names() -> Vec<String> {
2000 derived::macro_names()
2001 .into_iter()
2002 .map(|s| s.to_string())
2003 .collect()
2004}
2005
2006/// Returns all registered macro module names (debug API).
2007///
2008/// Modules group related macros together (e.g., "builtin", "serde").
2009///
2010/// # Returns
2011///
2012/// A vector of module names.
2013#[napi(js_name = "__macroforgeDebugGetModules")]
2014pub fn debug_get_modules() -> Vec<String> {
2015 crate::host::derived::modules()
2016 .into_iter()
2017 .map(|s| s.to_string())
2018 .collect()
2019}
2020
2021/// Looks up a macro by module and name (debug API).
2022///
2023/// Useful for testing macro registration and debugging lookup issues.
2024///
2025/// # Arguments
2026///
2027/// * `module` - The module name (e.g., "builtin")
2028/// * `name` - The macro name (e.g., "Debug")
2029///
2030/// # Returns
2031///
2032/// A string describing whether the macro was found or not.
2033#[napi(js_name = "__macroforgeDebugLookup")]
2034pub fn debug_lookup(module: String, name: String) -> String {
2035 match MacroExpander::new() {
2036 Ok(host) => match host.dispatcher.registry().lookup(&module, &name) {
2037 Ok(_) => format!("Found: ({}, {})", module, name),
2038 Err(_) => format!("Not found: ({}, {})", module, name),
2039 },
2040 Err(e) => format!("Host init failed: {}", e),
2041 }
2042}
2043
2044/// Returns debug information about all registered macro descriptors (debug API).
2045///
2046/// This provides low-level access to the inventory-based macro registration
2047/// system for debugging purposes.
2048///
2049/// # Returns
2050///
2051/// A vector of strings describing each registered macro descriptor.
2052#[napi(js_name = "__macroforgeDebugDescriptors")]
2053pub fn debug_descriptors() -> Vec<String> {
2054 inventory::iter::<crate::host::derived::DerivedMacroRegistration>()
2055 .map(|entry| {
2056 format!(
2057 "name={}, module={}, package={}",
2058 entry.descriptor.name, entry.descriptor.module, entry.descriptor.package
2059 )
2060 })
2061 .collect()
2062}