macroforge_ts/
lib.rs

1use napi::bindgen_prelude::*;
2use napi_derive::napi;
3use swc_core::{
4    common::{FileName, GLOBALS, Globals, SourceMap, errors::Handler, sync::Lrc},
5    ecma::{
6        ast::{EsVersion, Program},
7        codegen::{Emitter, text_writer::JsWriter},
8        parser::{Parser, StringInput, Syntax, TsSyntax, lexer::Lexer},
9    },
10};
11
12// Allow the crate to reference itself as `macroforge_ts`
13// This is needed for the macroforge_ts_macros generated code
14extern crate self as macroforge_ts;
15
16// ============================================================================
17// Re-exports for Macro Authors
18// ============================================================================
19// These re-exports allow users to only depend on `macroforge_ts` in their
20// Cargo.toml instead of needing to add multiple dependencies.
21
22// Re-export internal crates (needed for generated code)
23pub extern crate macroforge_ts_syn;
24pub extern crate macroforge_ts_quote;
25pub extern crate macroforge_ts_macros;
26pub extern crate inventory;
27pub extern crate serde_json;
28pub extern crate napi;
29pub extern crate napi_derive;
30
31/// TypeScript syntax types for macro development
32/// Use: `use macroforge_ts::ts_syn::*;`
33pub use macroforge_ts_syn as ts_syn;
34
35/// Macro attributes and quote templates
36/// Use: `use macroforge_ts::macros::*;`
37pub mod macros {
38    // Re-export the ts_macro_derive attribute
39    pub use macroforge_ts_macros::ts_macro_derive;
40
41    // Re-export all quote macros
42    pub use macroforge_ts_quote::{ts_template, body, above, below, signature};
43}
44
45// Re-export swc_core and common modules (via ts_syn for version consistency)
46pub use macroforge_ts_syn::swc_core;
47pub use macroforge_ts_syn::swc_common;
48pub use macroforge_ts_syn::swc_ecma_ast;
49
50// ============================================================================
51// Internal modules
52// ============================================================================
53pub mod host;
54
55// Re-export abi types from ts_syn
56pub use ts_syn::abi;
57
58use host::derived;
59use ts_syn::{Diagnostic, DiagnosticLevel};
60
61mod builtin;
62
63#[cfg(test)]
64mod test;
65
66use crate::host::MacroExpander;
67
68// ============================================================================
69// Data Structures
70// ============================================================================
71
72#[napi(object)]
73#[derive(Clone)]
74pub struct TransformResult {
75    pub code: String,
76    pub map: Option<String>,
77    pub types: Option<String>,
78    pub metadata: Option<String>,
79}
80
81#[napi(object)]
82#[derive(Clone)]
83pub struct MacroDiagnostic {
84    pub level: String,
85    pub message: String,
86    pub start: Option<u32>,
87    pub end: Option<u32>,
88}
89
90#[napi(object)]
91#[derive(Clone)]
92pub struct MappingSegmentResult {
93    pub original_start: u32,
94    pub original_end: u32,
95    pub expanded_start: u32,
96    pub expanded_end: u32,
97}
98
99#[napi(object)]
100#[derive(Clone)]
101pub struct GeneratedRegionResult {
102    pub start: u32,
103    pub end: u32,
104    pub source_macro: String,
105}
106
107#[napi(object)]
108#[derive(Clone)]
109pub struct SourceMappingResult {
110    pub segments: Vec<MappingSegmentResult>,
111    pub generated_regions: Vec<GeneratedRegionResult>,
112}
113
114#[napi(object)]
115#[derive(Clone)]
116pub struct ExpandResult {
117    pub code: String,
118    pub types: Option<String>,
119    pub metadata: Option<String>,
120    pub diagnostics: Vec<MacroDiagnostic>,
121    pub source_mapping: Option<SourceMappingResult>,
122}
123
124#[napi(object)]
125#[derive(Clone)]
126pub struct ImportSourceResult {
127    /// Local identifier name in the import statement
128    pub local: String,
129    /// Module specifier this identifier was imported from
130    pub module: String,
131}
132
133#[napi(object)]
134#[derive(Clone)]
135pub struct SyntaxCheckResult {
136    pub ok: bool,
137    pub error: Option<String>,
138}
139
140#[napi(object)]
141#[derive(Clone)]
142pub struct SpanResult {
143    pub start: u32,
144    pub length: u32,
145}
146
147#[napi(object)]
148#[derive(Clone)]
149pub struct JsDiagnostic {
150    pub start: Option<u32>,
151    pub length: Option<u32>,
152    pub message: Option<String>,
153    pub code: Option<u32>,
154    pub category: Option<String>,
155}
156
157// ============================================================================
158// Position Mapper (Optimized with Binary Search)
159// ============================================================================
160
161#[napi(js_name = "PositionMapper")]
162pub struct NativePositionMapper {
163    segments: Vec<MappingSegmentResult>,
164    generated_regions: Vec<GeneratedRegionResult>,
165}
166
167#[napi(js_name = "NativeMapper")]
168pub struct NativeMapper {
169    inner: NativePositionMapper,
170}
171
172#[napi]
173impl NativePositionMapper {
174    #[napi(constructor)]
175    pub fn new(mapping: SourceMappingResult) -> Self {
176        Self {
177            segments: mapping.segments,
178            generated_regions: mapping.generated_regions,
179        }
180    }
181
182    #[napi(js_name = "isEmpty")]
183    pub fn is_empty(&self) -> bool {
184        self.segments.is_empty() && self.generated_regions.is_empty()
185    }
186
187    #[napi]
188    pub fn original_to_expanded(&self, pos: u32) -> u32 {
189        // OPTIMIZATION: Binary search instead of linear scan
190        let idx = self.segments.partition_point(|seg| seg.original_end <= pos);
191
192        if let Some(seg) = self.segments.get(idx) {
193            // Check if pos is actually inside this segment (it might be in a gap)
194            if pos >= seg.original_start && pos < seg.original_end {
195                let offset = pos - seg.original_start;
196                return seg.expanded_start + offset;
197            }
198        }
199
200        // Handle case where position is extrapolated after the last segment
201        if let Some(last) = self.segments.last()
202            && pos >= last.original_end
203        {
204            let delta = pos - last.original_end;
205            return last.expanded_end + delta;
206        }
207
208        // Fallback for positions before first segment or in gaps
209        pos
210    }
211
212    #[napi]
213    pub fn expanded_to_original(&self, pos: u32) -> Option<u32> {
214        if self.is_in_generated(pos) {
215            return None;
216        }
217
218        // OPTIMIZATION: Binary search
219        let idx = self.segments.partition_point(|seg| seg.expanded_end <= pos);
220
221        if let Some(seg) = self.segments.get(idx)
222            && pos >= seg.expanded_start
223            && pos < seg.expanded_end
224        {
225            let offset = pos - seg.expanded_start;
226            return Some(seg.original_start + offset);
227        }
228
229        if let Some(last) = self.segments.last()
230            && pos >= last.expanded_end
231        {
232            let delta = pos - last.expanded_end;
233            return Some(last.original_end + delta);
234        }
235
236        None
237    }
238
239    #[napi]
240    pub fn generated_by(&self, pos: u32) -> Option<String> {
241        // generated_regions are usually small, linear scan is fine, but can optimize if needed
242        self.generated_regions
243            .iter()
244            .find(|r| pos >= r.start && pos < r.end)
245            .map(|r| r.source_macro.clone())
246    }
247
248    #[napi]
249    pub fn map_span_to_original(&self, start: u32, length: u32) -> Option<SpanResult> {
250        let end = start.saturating_add(length);
251        let original_start = self.expanded_to_original(start)?;
252        let original_end = self.expanded_to_original(end)?;
253
254        Some(SpanResult {
255            start: original_start,
256            length: original_end.saturating_sub(original_start),
257        })
258    }
259
260    #[napi]
261    pub fn map_span_to_expanded(&self, start: u32, length: u32) -> SpanResult {
262        let end = start.saturating_add(length);
263        let expanded_start = self.original_to_expanded(start);
264        let expanded_end = self.original_to_expanded(end);
265
266        SpanResult {
267            start: expanded_start,
268            length: expanded_end.saturating_sub(expanded_start),
269        }
270    }
271
272    #[napi]
273    pub fn is_in_generated(&self, pos: u32) -> bool {
274        self.generated_regions
275            .iter()
276            .any(|r| pos >= r.start && pos < r.end)
277    }
278}
279
280#[napi]
281impl NativeMapper {
282    #[napi(constructor)]
283    pub fn new(mapping: SourceMappingResult) -> Self {
284        Self {
285            inner: NativePositionMapper::new(mapping),
286        }
287    }
288    // Delegate all methods to inner
289    #[napi(js_name = "isEmpty")]
290    pub fn is_empty(&self) -> bool {
291        self.inner.is_empty()
292    }
293    #[napi]
294    pub fn original_to_expanded(&self, pos: u32) -> u32 {
295        self.inner.original_to_expanded(pos)
296    }
297    #[napi]
298    pub fn expanded_to_original(&self, pos: u32) -> Option<u32> {
299        self.inner.expanded_to_original(pos)
300    }
301    #[napi]
302    pub fn generated_by(&self, pos: u32) -> Option<String> {
303        self.inner.generated_by(pos)
304    }
305    #[napi]
306    pub fn map_span_to_original(&self, start: u32, length: u32) -> Option<SpanResult> {
307        self.inner.map_span_to_original(start, length)
308    }
309    #[napi]
310    pub fn map_span_to_expanded(&self, start: u32, length: u32) -> SpanResult {
311        self.inner.map_span_to_expanded(start, length)
312    }
313    #[napi]
314    pub fn is_in_generated(&self, pos: u32) -> bool {
315        self.inner.is_in_generated(pos)
316    }
317}
318
319#[napi]
320pub fn check_syntax(code: String, filepath: String) -> SyntaxCheckResult {
321    match parse_program(&code, &filepath) {
322        Ok(_) => SyntaxCheckResult {
323            ok: true,
324            error: None,
325        },
326        Err(err) => SyntaxCheckResult {
327            ok: false,
328            error: Some(err.to_string()),
329        },
330    }
331}
332
333// ============================================================================
334// Core Plugin Logic
335// ============================================================================
336
337#[napi(object)]
338pub struct ProcessFileOptions {
339    pub keep_decorators: Option<bool>,
340    pub version: Option<String>,
341}
342
343#[napi(object)]
344pub struct ExpandOptions {
345    pub keep_decorators: Option<bool>,
346}
347
348#[napi]
349pub struct NativePlugin {
350    cache: std::sync::Mutex<std::collections::HashMap<String, CachedResult>>,
351    log_file: std::sync::Mutex<Option<std::path::PathBuf>>,
352}
353
354impl Default for NativePlugin {
355    fn default() -> Self {
356        Self::new()
357    }
358}
359
360#[derive(Clone)]
361struct CachedResult {
362    version: Option<String>,
363    result: ExpandResult,
364}
365
366fn option_expand_options(opts: Option<ProcessFileOptions>) -> Option<ExpandOptions> {
367    opts.map(|o| ExpandOptions {
368        keep_decorators: o.keep_decorators,
369    })
370}
371
372#[napi]
373impl NativePlugin {
374    #[napi(constructor)]
375    pub fn new() -> Self {
376        let plugin = Self {
377            cache: std::sync::Mutex::new(std::collections::HashMap::new()),
378            log_file: std::sync::Mutex::new(None),
379        };
380
381        // Initialize log file with default path
382        if let Ok(mut log_guard) = plugin.log_file.lock() {
383            let log_path = std::path::PathBuf::from("/tmp/macroforge-plugin.log");
384
385            // Clear/create log file
386            if let Err(e) = std::fs::write(&log_path, "=== macroforge plugin loaded ===\n") {
387                eprintln!("[macroforge] Failed to initialize log file: {}", e);
388            } else {
389                *log_guard = Some(log_path);
390            }
391        }
392
393        plugin
394    }
395
396    #[napi]
397    pub fn log(&self, message: String) {
398        if let Ok(log_guard) = self.log_file.lock()
399            && let Some(log_path) = log_guard.as_ref()
400        {
401            use std::io::Write;
402            if let Ok(mut file) = std::fs::OpenOptions::new()
403                .append(true)
404                .create(true)
405                .open(log_path)
406            {
407                let _ = writeln!(file, "{}", message);
408            }
409        }
410    }
411
412    #[napi]
413    pub fn set_log_file(&self, path: String) {
414        if let Ok(mut log_guard) = self.log_file.lock() {
415            *log_guard = Some(std::path::PathBuf::from(path));
416        }
417    }
418
419    #[napi]
420    pub fn process_file(
421        &self,
422        _env: Env,
423        filepath: String,
424        code: String,
425        options: Option<ProcessFileOptions>,
426    ) -> Result<ExpandResult> {
427        let version = options.as_ref().and_then(|o| o.version.clone());
428
429        // Cache Check
430        if let (Some(ver), Ok(guard)) = (version.as_ref(), self.cache.lock())
431            && let Some(cached) = guard.get(&filepath)
432            && cached.version.as_ref() == Some(ver)
433        {
434            return Ok(cached.result.clone());
435        }
436
437        // FIX: Run expansion in a separate thread with a LARGE stack (32MB).
438        // Standard threads (and Node threads) often have 2MB stacks, which causes
439        // "Broken pipe" / SEGFAULTS when SWC recurses deeply in macros.
440        let opts_clone = option_expand_options(options);
441        let filepath_for_thread = filepath.clone();
442
443        let builder = std::thread::Builder::new().stack_size(32 * 1024 * 1024);
444        let handle = builder
445            .spawn(move || {
446                let globals = Globals::default();
447                GLOBALS.set(&globals, || {
448                    std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
449                        // We need a dummy Env here or refactor expand_inner to not take Env
450                        // since Env cannot be sent across threads.
451                        // However, expand_inner only uses Env for MacroExpander which likely needs it.
452                        // IMPORTANT: NAPI Env is NOT thread safe. We cannot pass it.
453                        // We must initialize MacroExpander without Env or create a temporary scope if possible.
454                        // Assuming expand_inner logic handles mostly pure Rust AST operations:
455                        expand_inner(&code, &filepath_for_thread, opts_clone)
456                    }))
457                })
458            })
459            .map_err(|e| {
460                Error::new(
461                    Status::GenericFailure,
462                    format!("Failed to spawn worker thread: {}", e),
463                )
464            })?;
465
466        let expand_result = handle
467            .join()
468            .map_err(|_| {
469                Error::new(
470                    Status::GenericFailure,
471                    "Macro expansion worker thread panicked (Stack Overflow?)",
472                )
473            })?
474            .map_err(|_| {
475                Error::new(
476                    Status::GenericFailure,
477                    "Macro expansion panicked inside worker",
478                )
479            })??;
480
481        // Update Cache
482        if let Ok(mut guard) = self.cache.lock() {
483            guard.insert(
484                filepath.clone(),
485                CachedResult {
486                    version,
487                    result: expand_result.clone(),
488                },
489            );
490        }
491
492        Ok(expand_result)
493    }
494
495    #[napi]
496    pub fn get_mapper(&self, filepath: String) -> Option<NativeMapper> {
497        let mapping = match self.cache.lock() {
498            Ok(guard) => guard
499                .get(&filepath)
500                .cloned()
501                .and_then(|c| c.result.source_mapping),
502            Err(_) => None,
503        };
504
505        mapping.map(|m| NativeMapper {
506            inner: NativePositionMapper::new(m),
507        })
508    }
509
510    #[napi]
511    pub fn map_diagnostics(&self, filepath: String, diags: Vec<JsDiagnostic>) -> Vec<JsDiagnostic> {
512        let Some(mapper) = self.get_mapper(filepath) else {
513            return diags;
514        };
515
516        diags
517            .into_iter()
518            .map(|mut d| {
519                if let (Some(start), Some(length)) = (d.start, d.length)
520                    && let Some(mapped) = mapper.map_span_to_original(start, length)
521                {
522                    d.start = Some(mapped.start);
523                    d.length = Some(mapped.length);
524                }
525                d
526            })
527            .collect()
528    }
529}
530
531// ============================================================================
532// Sync Functions (Refactored for Thread Safety & Performance)
533// ============================================================================
534
535#[napi]
536pub fn parse_import_sources(code: String, filepath: String) -> Result<Vec<ImportSourceResult>> {
537    let (program, _cm) = parse_program(&code, &filepath)?;
538    let module = match program {
539        Program::Module(module) => module,
540        Program::Script(_) => return Ok(vec![]),
541    };
542
543    let import_map = crate::host::collect_import_sources(&module, &code);
544    let mut imports = Vec::with_capacity(import_map.len());
545    for (local, module) in import_map {
546        imports.push(ImportSourceResult { local, module });
547    }
548    Ok(imports)
549}
550
551#[napi(
552    js_name = "Derive",
553    ts_return_type = "ClassDecorator",
554    ts_args_type = "...features: any[]"
555)]
556pub fn derive_decorator() {}
557
558#[napi]
559pub fn transform_sync(_env: Env, code: String, filepath: String) -> Result<TransformResult> {
560    // FIX: Thread isolation for transforms too
561    let builder = std::thread::Builder::new().stack_size(32 * 1024 * 1024);
562    let handle = builder
563        .spawn(move || {
564            let globals = Globals::default();
565            GLOBALS.set(&globals, || {
566                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
567                    transform_inner(&code, &filepath)
568                }))
569            })
570        })
571        .map_err(|e| {
572            Error::new(
573                Status::GenericFailure,
574                format!("Failed to spawn transform thread: {}", e),
575            )
576        })?;
577
578    handle
579        .join()
580        .map_err(|_| Error::new(Status::GenericFailure, "Transform worker crashed"))?
581        .map_err(|_| Error::new(Status::GenericFailure, "Transform panicked"))?
582}
583
584/// Expand macros in TypeScript code and return the transformed TS (types) and diagnostics
585#[napi]
586pub fn expand_sync(
587    _env: Env,
588    code: String,
589    filepath: String,
590    options: Option<ExpandOptions>,
591) -> Result<ExpandResult> {
592    // FIX: Thread isolation for expands too
593    let builder = std::thread::Builder::new().stack_size(32 * 1024 * 1024);
594    let handle = builder
595        .spawn(move || {
596            let globals = Globals::default();
597            GLOBALS.set(&globals, || {
598                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
599                    expand_inner(&code, &filepath, options)
600                }))
601            })
602        })
603        .map_err(|e| {
604            Error::new(
605                Status::GenericFailure,
606                format!("Failed to spawn expand thread: {}", e),
607            )
608        })?;
609
610    handle
611        .join()
612        .map_err(|_| Error::new(Status::GenericFailure, "Expand worker crashed"))?
613        .map_err(|_| Error::new(Status::GenericFailure, "Expand panicked"))?
614}
615
616// ============================================================================
617// Inner Logic (Optimized)
618// ============================================================================
619
620/// Inner logic decoupled from NAPI Env to allow threading
621fn expand_inner(
622    code: &str,
623    filepath: &str,
624    options: Option<ExpandOptions>,
625) -> Result<ExpandResult> {
626    // We create a NEW macro host for this thread.
627    // Note: If MacroExpander requires NAPI Env for calling back into JS,
628    // that part will fail in a threaded context. Assuming pure-Rust expansion here.
629    let mut macro_host = MacroExpander::new().map_err(|err| {
630        Error::new(
631            Status::GenericFailure,
632            format!("Failed to initialize macro host: {err:?}"),
633        )
634    })?;
635
636    if let Some(opts) = options
637        && let Some(keep) = opts.keep_decorators
638    {
639        macro_host.set_keep_decorators(keep);
640    }
641
642    let (program, _) = match parse_program(code, filepath) {
643        Ok(p) => p,
644        Err(e) => {
645            // Instead of failing on parse errors (which can happen frequently
646            // when the user is typing or the code is incomplete), return a
647            // no-op expansion result with the original code unchanged.
648            // This allows the language server to continue functioning smoothly.
649            let error_msg = e.to_string();
650
651            // Return a "no-op" expansion result: original code, no changes,
652            // and optionally a diagnostic for the user.
653            return Ok(ExpandResult {
654                code: code.to_string(),
655                types: None,
656                metadata: None,
657                diagnostics: vec![MacroDiagnostic {
658                    level: "info".to_string(),
659                    message: format!("Macro expansion skipped due to syntax error: {}", error_msg),
660                    start: None,
661                    end: None,
662                }],
663                source_mapping: None,
664            });
665        }
666    };
667
668    let expansion = macro_host.expand(code, &program, filepath).map_err(|err| {
669        Error::new(
670            Status::GenericFailure,
671            format!("Macro expansion failed: {err:?}"),
672        )
673    })?;
674
675    let diagnostics = expansion
676        .diagnostics
677        .into_iter()
678        .map(|d| MacroDiagnostic {
679            level: format!("{:?}", d.level).to_lowercase(),
680            message: d.message,
681            start: d.span.map(|s| s.start),
682            end: d.span.map(|s| s.end),
683        })
684        .collect();
685
686    let source_mapping = expansion.source_mapping.map(|mapping| SourceMappingResult {
687        segments: mapping
688            .segments
689            .into_iter()
690            .map(|seg| MappingSegmentResult {
691                original_start: seg.original_start,
692                original_end: seg.original_end,
693                expanded_start: seg.expanded_start,
694                expanded_end: seg.expanded_end,
695            })
696            .collect(),
697        generated_regions: mapping
698            .generated_regions
699            .into_iter()
700            .map(|region| GeneratedRegionResult {
701                start: region.start,
702                end: region.end,
703                source_macro: region.source_macro,
704            })
705            .collect(),
706    });
707
708    let mut types_output = expansion.type_output;
709    // Heuristic fix: Ensure we don't inject toJSON if it exists, and be careful about placement
710    if let Some(types) = &mut types_output
711        && expansion.code.contains("toJSON(")
712        && !types.contains("toJSON(")
713    {
714        // Find the last closing brace. This is still a heuristic but functional for simple cases.
715        if let Some(insert_at) = types.rfind('}') {
716            types.insert_str(insert_at, "  toJSON(): Record<string, unknown>;\n");
717        }
718    }
719
720    Ok(ExpandResult {
721        code: expansion.code,
722        types: types_output,
723        metadata: if expansion.classes.is_empty() {
724            None
725        } else {
726            serde_json::to_string(&expansion.classes).ok()
727        },
728        diagnostics,
729        source_mapping,
730    })
731}
732
733fn transform_inner(code: &str, filepath: &str) -> Result<TransformResult> {
734    let macro_host = MacroExpander::new().map_err(|err| {
735        Error::new(
736            Status::GenericFailure,
737            format!("Failed to init host: {err:?}"),
738        )
739    })?;
740
741    let (program, cm) = parse_program(code, filepath)?;
742
743    let expansion = macro_host
744        .expand(code, &program, filepath)
745        .map_err(|err| Error::new(Status::GenericFailure, format!("Expansion failed: {err:?}")))?;
746
747    handle_macro_diagnostics(&expansion.diagnostics, filepath)?;
748
749    // FIX: REMOVED REDUNDANT ROUND-TRIP
750    // Previously: Parse -> Expand -> Stringify -> Parse -> Stringify
751    // Now: Parse -> Expand -> Stringify (or use cached result)
752    let generated = if expansion.changed {
753        expansion.code
754    } else {
755        // Only emit if we didn't change anything (fallback to standard emit)
756        emit_program(&program, &cm)?
757    };
758
759    let metadata = if expansion.classes.is_empty() {
760        None
761    } else {
762        serde_json::to_string(&expansion.classes).ok()
763    };
764
765    Ok(TransformResult {
766        code: generated,
767        map: None,
768        types: expansion.type_output,
769        metadata,
770    })
771}
772
773fn parse_program(code: &str, filepath: &str) -> Result<(Program, Lrc<SourceMap>)> {
774    let cm: Lrc<SourceMap> = Lrc::new(SourceMap::default());
775    let fm = cm.new_source_file(
776        FileName::Custom(filepath.to_string()).into(),
777        code.to_string(),
778    );
779    let handler =
780        Handler::with_emitter_writer(Box::new(std::io::Cursor::new(Vec::new())), Some(cm.clone()));
781
782    let lexer = Lexer::new(
783        Syntax::Typescript(TsSyntax {
784            tsx: filepath.ends_with(".tsx"),
785            decorators: true,
786            dts: false,
787            no_early_errors: true,
788            ..Default::default()
789        }),
790        EsVersion::latest(),
791        StringInput::from(&*fm),
792        None,
793    );
794
795    let mut parser = Parser::new_from(lexer);
796    match parser.parse_program() {
797        Ok(program) => Ok((program, cm)),
798        Err(error) => {
799            let msg = format!("Failed to parse TypeScript: {:?}", error);
800            error.into_diagnostic(&handler).emit();
801            Err(Error::new(Status::GenericFailure, msg))
802        }
803    }
804}
805
806fn emit_program(program: &Program, cm: &Lrc<SourceMap>) -> Result<String> {
807    let mut buf = vec![];
808    let mut emitter = Emitter {
809        cfg: swc_core::ecma::codegen::Config::default(),
810        cm: cm.clone(),
811        comments: None,
812        wr: Box::new(JsWriter::new(cm.clone(), "\n", &mut buf, None)),
813    };
814    emitter
815        .emit_program(program)
816        .map_err(|e| Error::new(Status::GenericFailure, format!("{:?}", e)))?;
817    Ok(String::from_utf8_lossy(&buf).to_string())
818}
819
820fn handle_macro_diagnostics(diags: &[Diagnostic], file: &str) -> Result<()> {
821    for diag in diags {
822        if matches!(diag.level, DiagnosticLevel::Error) {
823            let loc = diag
824                .span
825                .map(|s| format!("{}:{}-{}", file, s.start, s.end))
826                .unwrap_or_else(|| file.to_string());
827            return Err(Error::new(
828                Status::GenericFailure,
829                format!("Macro error at {}: {}", loc, diag.message),
830            ));
831        }
832    }
833    Ok(())
834}
835
836// ============================================================================
837// Manifest / Debug API
838// ============================================================================
839
840#[napi(object)]
841pub struct MacroManifestEntry {
842    pub name: String,
843    pub kind: String,
844    pub description: String,
845    pub package: String,
846}
847#[napi(object)]
848pub struct DecoratorManifestEntry {
849    pub module: String,
850    pub export: String,
851    pub kind: String,
852    pub docs: String,
853}
854#[napi(object)]
855pub struct MacroManifest {
856    pub version: u32,
857    pub macros: Vec<MacroManifestEntry>,
858    pub decorators: Vec<DecoratorManifestEntry>,
859}
860
861#[napi(js_name = "__macroforgeGetManifest")]
862pub fn get_macro_manifest() -> MacroManifest {
863    let manifest = derived::get_manifest();
864    MacroManifest {
865        version: manifest.version,
866        macros: manifest
867            .macros
868            .into_iter()
869            .map(|m| MacroManifestEntry {
870                name: m.name.to_string(),
871                kind: format!("{:?}", m.kind).to_lowercase(),
872                description: m.description.to_string(),
873                package: m.package.to_string(),
874            })
875            .collect(),
876        decorators: manifest
877            .decorators
878            .into_iter()
879            .map(|d| DecoratorManifestEntry {
880                module: d.module.to_string(),
881                export: d.export.to_string(),
882                kind: format!("{:?}", d.kind).to_lowercase(),
883                docs: d.docs.to_string(),
884            })
885            .collect(),
886    }
887}
888
889#[napi(js_name = "__macroforgeIsMacroPackage")]
890pub fn is_macro_package() -> bool {
891    !derived::macro_names().is_empty()
892}
893#[napi(js_name = "__macroforgeGetMacroNames")]
894pub fn get_macro_names() -> Vec<String> {
895    derived::macro_names()
896        .into_iter()
897        .map(|s| s.to_string())
898        .collect()
899}
900#[napi(js_name = "__macroforgeDebugGetModules")]
901pub fn debug_get_modules() -> Vec<String> {
902    crate::host::derived::modules()
903        .into_iter()
904        .map(|s| s.to_string())
905        .collect()
906}
907
908#[napi(js_name = "__macroforgeDebugLookup")]
909pub fn debug_lookup(module: String, name: String) -> String {
910    match MacroExpander::new() {
911        Ok(host) => match host.dispatcher.registry().lookup(&module, &name) {
912            Ok(_) => format!("Found: ({}, {})", module, name),
913            Err(_) => format!("Not found: ({}, {})", module, name),
914        },
915        Err(e) => format!("Host init failed: {}", e),
916    }
917}
918
919#[napi(js_name = "__macroforgeDebugDescriptors")]
920pub fn debug_descriptors() -> Vec<String> {
921    inventory::iter::<crate::host::derived::DerivedMacroRegistration>()
922        .map(|entry| {
923            format!(
924                "name={}, module={}, package={}",
925                entry.descriptor.name, entry.descriptor.module, entry.descriptor.package
926            )
927        })
928        .collect()
929}