use anyhow::{Context, Result, anyhow};
use ignore::WalkBuilder;
use macroforge_ts::host::{MacroExpander, MacroExpansion};
use std::{
fs,
path::{Path, PathBuf},
};
pub fn scan_and_expand(root: PathBuf, include_ignored: bool) -> Result<()> {
use rayon::prelude::*;
let root = root.canonicalize().unwrap_or(root);
eprintln!("[macroforge] scanning {}", root.display());
let mut files: Vec<PathBuf> = Vec::new();
let walker = WalkBuilder::new(&root)
.hidden(false)
.git_ignore(!include_ignored)
.git_global(false)
.git_exclude(false)
.build();
for entry in walker.flatten() {
let path = entry.path();
let is_ts_file = path
.extension()
.is_some_and(|ext| ext == "ts" || ext == "tsx")
&& !path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.ends_with(".d.ts");
if !is_ts_file || !path.is_file() {
continue;
}
let filename = path.file_name().unwrap_or_default().to_string_lossy();
if filename.contains(".expanded.") {
continue;
}
files.push(path.to_path_buf());
}
let files_found = files.len();
let pool = rayon::ThreadPoolBuilder::new().build()?;
let results: Vec<_> = pool.install(|| {
files
.par_iter()
.map(|path| {
let result = try_expand_file(path.clone(), None, None, false, true);
(path.clone(), result)
})
.collect()
});
let mut files_expanded = 0;
for (path, result) in &results {
match result {
Ok(true) => files_expanded += 1,
Ok(false) => {}
Err(e) => {
eprintln!(
"[macroforge] error expanding {}: {}",
path.strip_prefix(&root).unwrap_or(path).display(),
e
);
}
}
}
eprintln!(
"[macroforge] scan complete: {} files found, {} expanded",
files_found, files_expanded
);
Ok(())
}
pub fn expand_file(
input: PathBuf,
out: Option<PathBuf>,
types_out: Option<PathBuf>,
print: bool,
quiet: bool,
) -> Result<()> {
match try_expand_file(input.clone(), out, types_out, print, false)? {
true => Ok(()),
false => {
if !quiet {
eprintln!("[macroforge] no macros found in {}", input.display());
}
std::process::exit(2);
}
}
}
pub(crate) fn try_expand_file(
input: PathBuf,
out: Option<PathBuf>,
types_out: Option<PathBuf>,
print: bool,
_is_scanning: bool,
) -> Result<bool> {
try_expand_file_builtin(input, out, types_out, print)
}
pub(crate) fn try_expand_file_builtin(
input: PathBuf,
out: Option<PathBuf>,
types_out: Option<PathBuf>,
print: bool,
) -> Result<bool> {
use macroforge_ts::host::MacroforgeConfigLoader;
if let Ok(Some(config)) = MacroforgeConfigLoader::find_from_path(&input) {
macroforge_ts::host::set_foreign_types(config.foreign_types.clone());
}
let source = fs::read_to_string(&input)
.with_context(|| format!("failed to read {}", input.display()))?;
let expander = MacroExpander::new().context("failed to initialize macro expander")?;
let expansion = expander
.expand_source(&source, &input.display().to_string())
.map_err(|err| anyhow!(format!("{err:?}")))?;
macroforge_ts::host::clear_registry();
macroforge_ts::host::clear_foreign_types();
if !expansion.changed {
return Ok(false);
}
emit_diagnostics(&expansion, &source, &input);
emit_runtime_output(&expansion, &input, out.as_ref(), print)?;
emit_type_output(&expansion, &input, types_out.as_ref(), print)?;
Ok(true)
}
fn emit_runtime_output(
result: &MacroExpansion,
input: &Path,
explicit_out: Option<&PathBuf>,
should_print: bool,
) -> Result<()> {
let code = &result.code;
let out_path = explicit_out
.cloned()
.unwrap_or_else(|| get_expanded_path(input));
write_file(&out_path, code)?;
println!(
"[macroforge] wrote expanded output for {} to {}",
input.display(),
out_path.display()
);
if should_print {
println!("// --- {} (expanded) ---", input.display());
println!("{code}");
}
Ok(())
}
fn emit_type_output(
result: &MacroExpansion,
input: &Path,
explicit_out: Option<&PathBuf>,
print: bool,
) -> Result<()> {
let Some(types) = result.type_output.as_ref() else {
return Ok(());
};
if let Some(path) = explicit_out {
write_file(path, types)?;
println!(
"[macroforge] wrote type output for {} to {}",
input.display(),
path.display()
);
} else if print {
println!("// --- {} (.d.ts) ---", input.display());
println!("{types}");
}
Ok(())
}
fn write_file(path: &PathBuf, contents: &str) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
fs::write(path, contents).with_context(|| format!("failed to write {}", path.display()))?;
Ok(())
}
pub(crate) fn emit_diagnostics(expansion: &MacroExpansion, source: &str, input: &Path) {
if expansion.diagnostics.is_empty() {
return;
}
for diag in &expansion.diagnostics {
let (line, col) = diag
.span
.map(|s| offset_to_line_col(source, s.start as usize))
.unwrap_or((1, 1));
eprintln!(
"[macroforge] {} at {}:{}:{}: {}",
format!("{:?}", diag.level).to_lowercase(),
input.display(),
line,
col,
diag.message
);
}
}
pub(crate) fn offset_to_line_col(source: &str, offset: usize) -> (usize, usize) {
let mut line = 1;
let mut col = 1;
for (idx, ch) in source.char_indices() {
if idx >= offset {
break;
}
if ch == '\n' {
line += 1;
col = 1;
} else {
col += 1;
}
}
(line, col)
}
pub(crate) fn get_expanded_path(input: &Path) -> PathBuf {
let dir = input.parent().unwrap_or_else(|| Path::new("."));
let basename = input.file_name().unwrap_or_default().to_string_lossy();
if let Some(first_dot) = basename.find('.') {
let name_without_ext = &basename[..first_dot];
let extensions = &basename[first_dot..];
dir.join(format!("{}.expanded{}", name_without_ext, extensions))
} else {
dir.join(format!("{}.expanded", basename))
}
}