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};
#[derive(Debug, Clone)]
pub struct PrepassOutput {
pub rewritten: Option<String>,
pub dependencies: Vec<PathBuf>,
pub diagnostics: Vec<Diagnostic>,
}
impl PrepassOutput {
#[must_use]
pub fn is_identity(&self) -> bool {
self.rewritten.is_none() && self.diagnostics.is_empty()
}
}
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();
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();
let (line, column) =
byte_offset_to_line_col(source, decl.decl_span.start.saturating_sub(1));
opts.source_line = line;
opts.source_column = column;
let raw_source = if prelude_for_sandbox.is_empty() {
decl.body_source.clone()
} else {
format!("{}\n{}", prelude_for_sandbox, decl.body_source)
};
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
}
}
};
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)
}
(BuildtimeKind::Tier2Function, SandboxValue::String(text)) => text.clone(),
(BuildtimeKind::Tier2Function, _) => {
let literal = value_to_ts_source(&value)?;
format_const_decl(decl, &literal)
}
(BuildtimeKind::Tier3Type, SandboxValue::String(text)) => format_type_decl(decl, text),
(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 } => {
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,
}
}
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)
}
pub use crate::ts_syn::abi::patch::Patch as PrepassPatch;
fn build_same_file_prelude(
program: &Program<'_>,
source: &str,
decls: &[BuildtimeDecl],
) -> Result<String, Diagnostic> {
use oxc::ast::ast::{Declaration, Statement};
let buildtime_spans: Vec<(u32, u32)> = decls
.iter()
.map(|d| (d.decl_span.start, d.decl_span.end))
.collect();
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);
}
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)
}
fn looks_ts_only(text: &str) -> bool {
let stripped = strip_string_contents(text);
if stripped.contains(" as ") || stripped.contains("<") && stripped.contains(">") {
}
contains_type_colon(&stripped)
}
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 == '\\' {
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
}
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':' => {
if i + 1 < bytes.len() && bytes[i + 1] == b':' {
continue;
}
if i > 0 && bytes[i - 1] == b'?' {
continue;
}
if paren_depth > 0 || (brace_depth == 0 && bracket_depth == 0) {
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(),
),
}
}
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};
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;
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())
}
const WRAPPER_PREAMBLE_LINES: u32 = 5;
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');
}
if out.ends_with('\n') {
out.pop();
}
out
}
fn remap_stack_line(line: &str, origin_display: &str, decl_line: u32) -> String {
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()
}
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
}