macroforge_ts 0.1.80

TypeScript macro expansion engine - write compile-time macros in Rust
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
//! Orchestrates `@buildtime` discovery → sandbox invocation → patch emission.
//!
//! The pre-pass is pure: it owns no state beyond the sandbox it's given,
//! and it returns a [`PrepassOutput`] that the host splices into the
//! normal patch pipeline. Errors never propagate up through `anyhow` —
//! every failure becomes a [`Diagnostic`] attached to the offending
//! declaration, so a broken `@buildtime` block fails the user's build
//! with a useful message rather than crashing the compiler.

use std::path::{Path, PathBuf};

use oxc::ast::ast::Program;

use crate::host::buildtime::discovery::{BuildtimeDecl, BuildtimeKind, Visibility, discover};
use crate::host::buildtime::sandbox::{
    BuildtimeSandbox, SandboxError, SandboxOptions, SandboxValue,
};
use crate::host::buildtime::serialize::{SerializeError, value_to_ts_source};
use crate::host::patch_applicator::PatchApplicator;
use crate::ts_syn::abi::{Diagnostic, DiagnosticLevel, Patch};

/// Output of the buildtime pre-pass.
#[derive(Debug, Clone)]
pub struct PrepassOutput {
    /// Rewritten source, or `None` if no `@buildtime` declarations were
    /// found (or all of them produced errors that meant no patch was
    /// emitted). Callers feed this into the next pre-pass stage.
    pub rewritten: Option<String>,
    /// Absolute paths of every file the sandbox read during evaluation.
    /// Used by the host to wire up HMR watchers and invalidate the
    /// on-disk cache when any of them change.
    pub dependencies: Vec<PathBuf>,
    /// Diagnostics to surface to the user. Errors come from the sandbox
    /// (throw, timeout, capability-denied) or from the serializer
    /// (unrepresentable result).
    pub diagnostics: Vec<Diagnostic>,
}

impl PrepassOutput {
    /// True if no `@buildtime` declarations were found. The host uses
    /// this to skip the extra allocator scope that re-parses the
    /// (unchanged) source for the declarative pre-pass.
    #[must_use]
    pub fn is_identity(&self) -> bool {
        self.rewritten.is_none() && self.diagnostics.is_empty()
    }
}

/// Run the pre-pass against `source`.
///
/// * `program` — already-parsed OXC AST. The host reuses its allocator.
/// * `source` — original TS source text, used for span-to-text lookups.
/// * `origin_path` — absolute file path; surfaced inside the sandbox
///   as `buildtime.location.file`.
/// * `sandbox` — backend implementation. Typically
///   [`crate::host::buildtime::default_backend`].
/// * `base_options` — capability/timeout/heap config, built from the
///   user's `macroforge.config.{js,ts}`. Each declaration gets a copy
///   with its `source_file` and `source_line` overridden to point at
///   the declaration.
pub fn run_prepass(
    program: &Program<'_>,
    source: &str,
    origin_path: &Path,
    sandbox: &dyn BuildtimeSandbox,
    base_options: &SandboxOptions,
) -> PrepassOutput {
    let decls = discover(program, source);
    if decls.is_empty() {
        return PrepassOutput {
            rewritten: None,
            dependencies: Vec::new(),
            diagnostics: Vec::new(),
        };
    }

    let mut patches: Vec<Patch> = Vec::with_capacity(decls.len());
    let mut dependencies: Vec<PathBuf> = Vec::new();
    let mut diagnostics: Vec<Diagnostic> = Vec::new();

    // Build a same-file prelude: the source with all `@buildtime`
    // declarations stripped, so the sandbox can see pure helper
    // functions/constants the user defined alongside the buildtime
    // blocks. This gives Zig-comptime-like semantics where a @buildtime
    // block can call any pure function in scope.
    //
    // If the stripped source has impure top-level code, the prelude is
    // disabled and we emit a diagnostic — but evaluation still proceeds
    // with an empty prelude so files that don't rely on siblings work.
    let prelude = build_same_file_prelude(program, source, &decls);
    let prelude_for_sandbox = match &prelude {
        Ok(text) => text.as_str(),
        Err(diag) => {
            diagnostics.push(diag.clone());
            ""
        }
    };

    for decl in decls {
        let mut opts = base_options.clone();
        opts.source_file = origin_path.to_path_buf();
        // SpanIR is 1-based; the line/column helper expects a 0-based
        // byte offset into source.
        let (line, column) =
            byte_offset_to_line_col(source, decl.decl_span.start.saturating_sub(1));
        opts.source_line = line;
        opts.source_column = column;

        // Compose: prelude first, then the user's body. The prelude is
        // `const` / `function` / etc. declarations hoisted to the top;
        // the body is a `return (EXPR);` or statement list that
        // references those declarations.
        let raw_source = if prelude_for_sandbox.is_empty() {
            decl.body_source.clone()
        } else {
            format!("{}\n{}", prelude_for_sandbox, decl.body_source)
        };
        // Boa is a JS-only engine, so any TS syntax in the user's
        // body (e.g. `: number` annotations, `as Foo` casts inside
        // an IIFE) needs stripping before evaluation. We wrap the
        // body in a fresh function, run it through OXC's TS->JS
        // transformer, then unwrap.
        let effective_source = match strip_ts_from_body(&raw_source) {
            Ok(stripped) => stripped,
            Err(e) => {
                diagnostics.push(Diagnostic {
                    level: DiagnosticLevel::Error,
                    message: format!(
                        "@buildtime `{}` body could not be lowered to JS: {}",
                        decl.name, e
                    ),
                    span: Some(decl.decl_span),
                    notes: vec![],
                    help: None,
                });
                continue;
            }
        };
        match sandbox.evaluate(&effective_source, origin_path, &opts) {
            Ok(result) => {
                dependencies.extend(result.dependencies);
                match build_patch(&decl, result.value) {
                    Ok(patch) => patches.push(patch),
                    Err(err) => diagnostics.push(serialize_error_to_diagnostic(&decl, err)),
                }
            }
            Err(err) => diagnostics.push(sandbox_error_to_diagnostic(
                &decl,
                err,
                origin_path,
                opts.source_line,
            )),
        }
    }

    let rewritten = if patches.is_empty() {
        None
    } else {
        match PatchApplicator::new(source, patches).apply() {
            Ok(code) => Some(code),
            Err(err) => {
                diagnostics.push(Diagnostic {
                    level: DiagnosticLevel::Error,
                    message: format!("buildtime patch apply failed: {}", err),
                    span: None,
                    notes: vec![],
                    help: None,
                });
                None
            }
        }
    };

    // Deduplicate dependencies — multiple decls may read the same file.
    dependencies.sort();
    dependencies.dedup();

    PrepassOutput {
        rewritten,
        dependencies,
        diagnostics,
    }
}

fn build_patch(decl: &BuildtimeDecl, value: SandboxValue) -> Result<Patch, SerializeError> {
    let replacement = match (decl.kind, &value) {
        (BuildtimeKind::Tier1Const, _) => {
            let literal = value_to_ts_source(&value)?;
            format_const_decl(decl, &literal)
        }
        // Tier 2 — string result splices verbatim.
        (BuildtimeKind::Tier2Function, SandboxValue::String(text)) => text.clone(),
        // Tier 2 — non-string result degrades to a const declaration
        // named for the original function. This matches the spec:
        // `function f() { return 42; }` ≡ `const f = 42;`.
        (BuildtimeKind::Tier2Function, _) => {
            let literal = value_to_ts_source(&value)?;
            format_const_decl(decl, &literal)
        }
        // Tier 3 — the returned string IS the new type RHS.
        (BuildtimeKind::Tier3Type, SandboxValue::String(text)) => format_type_decl(decl, text),
        // Tier 3 with non-string return — error out, this is almost
        // always a user mistake (forgot to join(), returned a number
        // that can't be a type, etc.). SerializeError carries through
        // as a diagnostic.
        (BuildtimeKind::Tier3Type, _) => {
            return Err(SerializeError::NotRepresentable(
                "@buildtime type RHS must evaluate to a string of TS type syntax",
            ));
        }
    };
    Ok(Patch::ReplaceRaw {
        span: decl.decl_span,
        code: replacement,
        context: Some(format!("@buildtime:{}", decl.name)),
        source_macro: Some("buildtime".to_string()),
    })
}

fn format_type_decl(decl: &BuildtimeDecl, type_text: &str) -> String {
    match decl.visibility {
        Visibility::Export => format!("export type {} = {};", decl.name, type_text),
        Visibility::Private => format!("type {} = {};", decl.name, type_text),
    }
}

fn format_const_decl(decl: &BuildtimeDecl, literal: &str) -> String {
    match decl.visibility {
        Visibility::Export => format!("export const {} = {};", decl.name, literal),
        Visibility::Private => format!("const {} = {};", decl.name, literal),
    }
}

fn sandbox_error_to_diagnostic(
    decl: &BuildtimeDecl,
    err: SandboxError,
    origin_path: &Path,
    decl_line: u32,
) -> Diagnostic {
    let (message, notes, help) = match &err {
        SandboxError::Threw { message, stack } => {
            // Always include the user's source attribution in the
            // notes, even when the JS engine doesn't populate `e.stack`
            // (Boa, currently). The user needs to know which file the
            // throw originated from to debug it.
            let mut notes = vec![format!(
                "thrown from {}:{} (inside @buildtime `{}`)",
                origin_path.display(),
                decl_line,
                decl.name
            )];
            if !stack.is_empty() {
                notes.push(format!(
                    "stack (mapped back to {}):\n{}",
                    origin_path.display(),
                    remap_stack(stack, origin_path, decl_line)
                ));
            }
            (
                format!("@buildtime evaluation threw: {}", message),
                notes,
                Some(format!(
                    "the @buildtime `{}` declaration ran to completion but threw the above error",
                    decl.name
                )),
            )
        }
        SandboxError::Timeout { .. } => (
            format!("@buildtime evaluation for `{}` exceeded the timeout", decl.name),
            vec![],
            Some("increase `buildtime.capabilities.timeout` in macroforge.config.js or simplify the expression".to_string()),
        ),
        SandboxError::OutOfMemory { limit } => (
            format!(
                "@buildtime evaluation for `{}` exceeded the heap limit of {} bytes",
                decl.name, limit
            ),
            vec![],
            Some("increase `buildtime.capabilities.maxHeap` in macroforge.config.js".to_string()),
        ),
        SandboxError::UnauthorizedRead { path } => (
            format!(
                "@buildtime `{}` tried to read `{}` but filesystem reads are not permitted for that path",
                decl.name,
                path.display()
            ),
            vec![],
            Some("add the path to `buildtime.capabilities.filesystem.read` in macroforge.config.js".to_string()),
        ),
        SandboxError::UnauthorizedWrite { path } => (
            format!(
                "@buildtime `{}` tried to write `{}` but filesystem writes are not permitted for that path",
                decl.name,
                path.display()
            ),
            vec![],
            Some("add the path to `buildtime.capabilities.filesystem.write` in macroforge.config.js".to_string()),
        ),
        SandboxError::UnauthorizedEnv { var } => (
            format!(
                "@buildtime `{}` tried to read env var `{}` which is not in the capability allowlist",
                decl.name, var
            ),
            vec![],
            Some("add the variable name to `buildtime.capabilities.env` in macroforge.config.js".to_string()),
        ),
        SandboxError::UnauthorizedNetwork { url } => (
            format!(
                "@buildtime `{}` tried to make a network request to {} but the network capability is off",
                decl.name, url
            ),
            vec![],
            Some("set `buildtime.capabilities.network = true` in macroforge.config.js (and accept non-deterministic builds)".to_string()),
        ),
        SandboxError::UnserializableResult { kind } => (
            format!(
                "@buildtime `{}` returned a {} which has no TypeScript literal representation",
                decl.name, kind
            ),
            vec!["@buildtime results must be null, boolean, number, bigint, string, array, or plain object".to_string()],
            None,
        ),
        SandboxError::Backend(msg) => (
            format!("@buildtime backend error for `{}`: {}", decl.name, msg),
            vec![],
            None,
        ),
    };
    Diagnostic {
        level: DiagnosticLevel::Error,
        message,
        span: Some(decl.decl_span),
        notes,
        help,
    }
}

fn serialize_error_to_diagnostic(decl: &BuildtimeDecl, err: SerializeError) -> Diagnostic {
    Diagnostic {
        level: DiagnosticLevel::Error,
        message: format!(
            "@buildtime `{}` produced a value that couldn't be serialized: {}",
            decl.name, err
        ),
        span: Some(decl.decl_span),
        notes: vec![],
        help: None,
    }
}

/// Convert a byte offset into a 1-based `(line, column)` pair for the
/// given source. Used to stamp the declaration's position onto
/// `SandboxOptions` for `buildtime.location.{line,column}`.
fn byte_offset_to_line_col(source: &str, offset: u32) -> (u32, u32) {
    let offset = (offset as usize).min(source.len());
    let mut line = 1u32;
    let mut column = 1u32;
    for (i, ch) in source.char_indices() {
        if i >= offset {
            break;
        }
        if ch == '\n' {
            line += 1;
            column = 1;
        } else {
            column += 1;
        }
    }
    (line, column)
}

/// Re-export so callers don't need to pull in `ts_syn::abi::patch`.
///
/// A shim so external users can pattern-match the prepass output
/// without depending on the internal patch types.
pub use crate::ts_syn::abi::patch::Patch as PrepassPatch;

/// Build a prelude string that the sandbox will evaluate before the
/// user's `@buildtime` body. The prelude contains every pure top-level
/// `const` or `function` (and class) declaration from the source,
/// *transpiled to plain JS* so the sandbox (which doesn't understand
/// TS syntax) can parse it.
///
/// Returns:
/// - `Ok(String)` — the prelude text to prepend to the body.
/// - `Err(Diagnostic)` — the source has impure top-level code that
///   makes it unsafe to include in the prelude. Evaluation still
///   proceeds without the prelude (so @buildtime blocks that only
///   use their own body still work).
fn build_same_file_prelude(
    program: &Program<'_>,
    source: &str,
    decls: &[BuildtimeDecl],
) -> Result<String, Diagnostic> {
    use oxc::ast::ast::{Declaration, Statement};

    // First pass: scan for impurity so we can early-exit with a warning.
    let buildtime_spans: Vec<(u32, u32)> = decls
        .iter()
        .map(|d| (d.decl_span.start, d.decl_span.end))
        .collect();
    // A statement counts as "buildtime" when its span lies *inside*
    // any of the buildtime decl spans. Using containment rather than
    // equality is necessary because BuildtimeDecl.decl_span is
    // extended backward to swallow the leading `/** @buildtime */`
    // JSDoc, so it strictly contains the AST node's own span.
    let is_buildtime = |start: u32, end: u32| -> bool {
        buildtime_spans
            .iter()
            .any(|(s, e)| *s <= start && end <= *e)
    };

    let mut impure_diag: Option<Diagnostic> = None;
    for stmt in &program.body {
        match stmt {
            Statement::VariableDeclaration(var) => {
                if is_buildtime(var.span.start, var.span.end) {
                    continue;
                }
                if let Some(reason) = impure_init_reason(var) {
                    impure_diag.get_or_insert_with(|| {
                        prelude_warning(reason, var.span.start, var.span.end)
                    });
                }
            }
            Statement::FunctionDeclaration(_)
            | Statement::ClassDeclaration(_)
            | Statement::TSTypeAliasDeclaration(_)
            | Statement::TSInterfaceDeclaration(_)
            | Statement::TSEnumDeclaration(_)
            | Statement::TSModuleDeclaration(_)
            | Statement::ImportDeclaration(_)
            | Statement::ExportAllDeclaration(_)
            | Statement::ExportDefaultDeclaration(_) => {}
            Statement::ExportNamedDeclaration(export) => {
                if is_buildtime(export.span.start, export.span.end) {
                    continue;
                }
                if let Some(Declaration::VariableDeclaration(var)) = &export.declaration
                    && let Some(reason) = impure_init_reason(var)
                {
                    impure_diag.get_or_insert_with(|| {
                        prelude_warning(reason, var.span.start, var.span.end)
                    });
                }
            }
            other => {
                if impure_diag.is_none() {
                    use oxc::span::GetSpan;
                    let sp = other.span();
                    impure_diag = Some(prelude_warning(
                        "unsupported top-level statement",
                        sp.start,
                        sp.end,
                    ));
                }
            }
        }
    }

    if let Some(diag) = impure_diag {
        return Err(diag);
    }

    // Second pass: collect the source text of each pure declaration we
    // want to include, per kind. Classes and TS-only declarations are
    // excluded because they can contain type annotations OXC codegen
    // doesn't strip — we'd end up passing invalid JS to the sandbox.
    // `const` initializers go through a JS-only check too: if the
    // initializer text contains `as`, `<T>`, `satisfies`, or a colon
    // outside a string, we skip that declaration.
    let mut prelude = String::new();
    for stmt in &program.body {
        match stmt {
            Statement::VariableDeclaration(var) => {
                if is_buildtime(var.span.start, var.span.end) {
                    continue;
                }
                let text = &source[var.span.start as usize..var.span.end as usize];
                if looks_ts_only(text) {
                    continue;
                }
                prelude.push_str(text);
                prelude.push('\n');
            }
            Statement::FunctionDeclaration(func) => {
                let text = &source[func.span.start as usize..func.span.end as usize];
                if looks_ts_only(text) {
                    continue;
                }
                prelude.push_str(text);
                prelude.push('\n');
            }
            Statement::ExportNamedDeclaration(export) => {
                if is_buildtime(export.span.start, export.span.end) {
                    continue;
                }
                let Some(declaration) = &export.declaration else {
                    continue;
                };
                match declaration {
                    Declaration::VariableDeclaration(var) => {
                        let text = &source[var.span.start as usize..var.span.end as usize];
                        if looks_ts_only(text) {
                            continue;
                        }
                        prelude.push_str(text);
                        prelude.push('\n');
                    }
                    Declaration::FunctionDeclaration(func) => {
                        let text = &source[func.span.start as usize..func.span.end as usize];
                        if looks_ts_only(text) {
                            continue;
                        }
                        prelude.push_str(text);
                        prelude.push('\n');
                    }
                    _ => {}
                }
            }
            _ => {}
        }
    }
    Ok(prelude)
}

/// Conservative TS-vs-JS check: if the source has a TS-only
/// construct we can't reliably strip (type annotations, generics in
/// type position, `as`/`satisfies`), skip the declaration from the
/// prelude rather than risk a parse error in the sandbox.
///
/// False positives are fine — they just mean the user can't reference
/// that helper from `@buildtime`. The user can work around by moving
/// the helper to a plain-JS `.buildtime.ts` file in a follow-up.
fn looks_ts_only(text: &str) -> bool {
    // Strip strings + regex literals so colons / keywords inside them
    // don't trigger false positives.
    let stripped = strip_string_contents(text);
    if stripped.contains(" as ") || stripped.contains("<") && stripped.contains(">") {
        // `<T>` or `as X` — almost always a type annotation in a
        // top-level position.
        // `<T>` also matches JSX, but JSX at top level of a const
        // initializer is rare and still JS-safe — false positive is
        // acceptable.
    }
    // The main signal: a colon in a parameter list (`(x: T)`) or
    // in a variable declarator (`const x: T = ...`). We scan the text
    // for a colon that isn't inside a string literal and isn't part
    // of an object-property shorthand.
    contains_type_colon(&stripped)
}

/// Replace the contents of every string / template / regex literal in
/// `src` with spaces so token scans don't misfire on characters inside
/// them. Keeps the quote marks + backticks so the scanner can still
/// track opening/closing.
fn strip_string_contents(src: &str) -> String {
    let mut out = String::with_capacity(src.len());
    let mut chars = src.chars().peekable();
    while let Some(c) = chars.next() {
        match c {
            '"' | '\'' | '`' => {
                out.push(c);
                while let Some(&next) = chars.peek() {
                    chars.next();
                    if next == '\\' {
                        // Skip the escaped char.
                        if let Some(_escaped) = chars.next() {
                            out.push(' ');
                        }
                        out.push(' ');
                    } else if next == c {
                        out.push(c);
                        break;
                    } else {
                        out.push(' ');
                    }
                }
            }
            _ => out.push(c),
        }
    }
    out
}

/// True if `src` contains a `:` in a position consistent with a TS
/// type annotation: after `)`, `]`, or an identifier, and not followed
/// by `=` (which would make it `:=` — a shorthand property with
/// default — those are rare and we tolerate false positives).
fn contains_type_colon(src: &str) -> bool {
    let bytes = src.as_bytes();
    let mut paren_depth = 0i32;
    let mut brace_depth = 0i32;
    let mut bracket_depth = 0i32;
    for (i, &b) in bytes.iter().enumerate() {
        match b {
            b'(' => paren_depth += 1,
            b')' => paren_depth -= 1,
            b'{' => brace_depth += 1,
            b'}' => brace_depth -= 1,
            b'[' => bracket_depth += 1,
            b']' => bracket_depth -= 1,
            b':' => {
                // Skip `::`, `?:`, and object-literal keys (`:` after
                // an identifier inside an object brace context).
                if i + 1 < bytes.len() && bytes[i + 1] == b':' {
                    continue;
                }
                if i > 0 && bytes[i - 1] == b'?' {
                    continue;
                }
                // Signal: `:` inside parens or in a decl RHS (outside
                // any braces / brackets) is a type annotation.
                if paren_depth > 0 || (brace_depth == 0 && bracket_depth == 0) {
                    // Still need to exclude object-literal shorthand
                    // like `{ a: 1 }` — which has brace_depth > 0 at
                    // the colon. We've already required brace_depth == 0
                    // for the non-paren case.
                    return true;
                }
            }
            _ => {}
        }
    }
    false
}

fn impure_init_reason(var: &oxc::ast::ast::VariableDeclaration<'_>) -> Option<&'static str> {
    var.declarations.iter().find_map(|declarator| {
        declarator
            .init
            .as_ref()
            .and_then(crate::host::buildtime::purity::expression_side_effect)
    })
}

fn prelude_warning(reason: &str, start: u32, end: u32) -> Diagnostic {
    use crate::ts_syn::abi::SpanIR;
    Diagnostic {
        level: DiagnosticLevel::Warning,
        message: format!(
            "@buildtime same-file prelude disabled: {}",
            reason
        ),
        span: Some(SpanIR { start, end }),
        notes: vec![
            "only pure top-level code (const/function/class, with pure initializers) can be referenced from @buildtime bodies".to_string(),
        ],
        help: Some(
            "move the impure code into a separate .buildtime.ts file or remove its side effects"
                .to_string(),
        ),
    }
}

/// Strip TypeScript syntax from a body source so the JS-only sandbox
/// can parse it. Wraps the body in a synthetic function (to make it a
/// valid program), runs OXC's TS→JS transformer, then unwraps the
/// function back out.
///
/// Errors are returned as a string for the caller to attach to a
/// Diagnostic.
fn strip_ts_from_body(body: &str) -> Result<String, String> {
    use oxc::allocator::Allocator;
    use oxc::codegen::Codegen;
    use oxc::parser::Parser as OxcParser;
    use oxc::semantic::SemanticBuilder;
    use oxc::span::SourceType;
    use oxc::transformer::{TransformOptions, Transformer};

    // Wrap as async so `await` in the user body parses.
    let wrapped = format!("async function __mf_body() {{\n{}\n}}", body);
    let allocator = Allocator::default();
    let parsed = OxcParser::new(&allocator, &wrapped, SourceType::ts()).parse();
    if !parsed.errors.is_empty() {
        return Err(format!(
            "parse error while stripping TS: {}",
            parsed
                .errors
                .iter()
                .map(|d| d.to_string())
                .collect::<Vec<_>>()
                .join("; ")
        ));
    }
    let mut program = parsed.program;
    let semantic = SemanticBuilder::new().build(&program);
    let scoping = semantic.semantic.into_scoping();
    let options = TransformOptions::default();
    let ret = Transformer::new(
        &allocator,
        std::path::Path::new("<buildtime-body>"),
        &options,
    )
    .build_with_scoping(scoping, &mut program);
    if !ret.errors.is_empty() {
        return Err(format!(
            "TS strip failed: {}",
            ret.errors
                .iter()
                .map(|e| e.to_string())
                .collect::<Vec<_>>()
                .join("; ")
        ));
    }
    let printed = Codegen::new().build(&program).code;
    // Pull the body back out from `function __mf_body() { ... }`.
    let open = printed
        .find('{')
        .ok_or_else(|| format!("ts-strip: no `{{` in output: {printed}"))?;
    let close = printed
        .rfind('}')
        .ok_or_else(|| format!("ts-strip: no `}}` in output: {printed}"))?;
    if close <= open + 1 {
        return Ok(String::new());
    }
    Ok(printed[open + 1..close].trim_matches('\n').to_string())
}

/// Number of preamble lines the sandbox prepends to the user's body
/// in its synthetic wrapper (see `wrap_source` in backends/boa.rs).
/// A frame in the sandbox at line `N` corresponds to user-body line
/// `N - WRAPPER_PREAMBLE_LINES`.
const WRAPPER_PREAMBLE_LINES: u32 = 5;

/// Rewrite `eval_script:LINE:COL` stack frames so they point at the user's
/// source file with an approximate user-source line number. The mapping
/// is:
///
/// ```text
///   user_line ≈ (sandbox_line - WRAPPER_PREAMBLE_LINES) + (decl_line - 1)
/// ```
///
/// It's approximate because for Tier 2 functions we strip the outer
/// braces but preserve inner newlines, which adds a line of offset the
/// formula doesn't account for. Off-by-one is acceptable when the
/// alternative is a sandbox-internal `eval_script:5:10` with no
/// connection to the user's file.
fn remap_stack(stack: &str, origin_path: &Path, decl_line: u32) -> String {
    let origin_display = origin_path.display().to_string();
    let mut out = String::with_capacity(stack.len());
    for line in stack.lines() {
        out.push_str(&remap_stack_line(line, &origin_display, decl_line));
        out.push('\n');
    }
    // Drop the trailing newline we just added.
    if out.ends_with('\n') {
        out.pop();
    }
    out
}

fn remap_stack_line(line: &str, origin_display: &str, decl_line: u32) -> String {
    // Match `eval_script:LINE:COL` or `<eval>:LINE:COL` anywhere in the
    // line and rewrite. A frame without a `:LINE:COL` match is passed
    // through unchanged.
    let patterns = ["eval_script:", "<eval>:", "<anonymous>:", "<buildtime>:"];
    for pat in &patterns {
        if let Some(idx) = line.find(pat) {
            let after = &line[idx + pat.len()..];
            if let Some((ln_str, col_rest)) = split_line_col(after)
                && let Ok(sandbox_line) = ln_str.parse::<i64>()
            {
                let user_line = remap_line(sandbox_line, decl_line);
                let prefix = &line[..idx];
                return format!("{}{}:{}:{}", prefix, origin_display, user_line, col_rest);
            }
        }
    }
    line.to_string()
}

/// Parse `LINE:COL<rest>` from the start of `s`. Returns
/// `("LINE", ":COL<rest>")` if successful.
fn split_line_col(s: &str) -> Option<(&str, &str)> {
    let first_colon = s.find(':')?;
    let line_part = &s[..first_colon];
    if line_part.is_empty() || !line_part.chars().all(|c| c.is_ascii_digit()) {
        return None;
    }
    Some((line_part, &s[first_colon..]))
}

fn remap_line(sandbox_line: i64, decl_line: u32) -> u32 {
    let adjusted = sandbox_line - WRAPPER_PREAMBLE_LINES as i64 + decl_line as i64 - 1;
    adjusted.max(1) as u32
}