defmodule OXC do
@moduledoc """
Elixir bindings for the [OXC](https://oxc.rs) JavaScript toolchain.
Provides fast JavaScript and TypeScript parsing, transformation, and
minification via Rust NIFs. The file extension determines the dialect —
`.js`, `.jsx`, `.ts`, `.tsx`.
iex> {:ok, ast} = OXC.parse("const x = 1 + 2", "test.js")
iex> ast.type
:program
iex> {:ok, js} = OXC.transform("const x: number = 42", "test.ts")
iex> js
"const x = 42;\\n"
Source-taking APIs accept binaries and iodata. AST nodes are maps with atom
keys, following the ESTree specification. The `:type` and `:kind` field values
are snake_case atoms (e.g. `:import_declaration`, `:variable_declaration`).
"""
defmodule Error do
defexception [:message, :errors]
@impl true
def message(%{message: message}), do: message
end
@type source :: iodata()
@type ast :: %{required(:type) => atom(), optional(atom()) => any()}
@type error :: %{message: String.t()}
@type code_with_sourcemap :: %{code: String.t(), sourcemap: String.t()}
@type parse_result :: {:ok, ast()} | {:error, [error()]}
@type transform_result :: {:ok, String.t() | code_with_sourcemap()} | {:error, [error()]}
@type bundle_result :: {:ok, String.t() | code_with_sourcemap()} | {:error, [error()]}
@doc """
Parse JavaScript or TypeScript source code into an ESTree AST.
The filename extension determines the dialect:
- `.js` — JavaScript
- `.jsx` — JavaScript with JSX
- `.ts` — TypeScript
- `.tsx` — TypeScript with JSX
Returns `{:ok, ast}` where `ast` is a map with atom keys, or
`{:error, errors}` with a list of parse error maps.
## Examples
iex> {:ok, ast} = OXC.parse("const x = 1", "test.js")
iex> [decl] = ast.body
iex> decl.type
:variable_declaration
iex> {:error, [%{message: msg} | _]} = OXC.parse("const = ;", "bad.js")
iex> is_binary(msg)
true
"""
@spec parse(source(), String.t()) :: parse_result()
def parse(source, filename) do
case OXC.Native.parse(source, filename) do
{:ok, ast} -> {:ok, atomize_term_keys(ast)}
{:error, errors} -> {:error, atomize_term_keys(errors)}
end
end
@doc """
Like `parse/2` but raises on parse errors.
## Examples
iex> ast = OXC.parse!("const x = 1", "test.js")
iex> ast.type
:program
"""
@spec parse!(source(), String.t()) :: ast()
def parse!(source, filename) do
case parse(source, filename) do
{:ok, ast} ->
ast
{:error, errors} ->
raise Error, message: "OXC parse error: #{inspect(errors)}", errors: errors
end
end
@doc """
Check if source code is syntactically valid.
Faster than `parse/2` — skips AST serialization.
## Examples
iex> OXC.valid?("const x = 1", "test.js")
true
iex> OXC.valid?("const = ;", "bad.js")
false
"""
@spec valid?(source(), String.t()) :: boolean()
def valid?(source, filename) do
OXC.Native.valid(source, filename)
end
@doc """
Transform TypeScript/JSX source code into plain JavaScript.
Strips type annotations, transforms JSX, and lowers syntax features.
The filename extension determines the source dialect.
## Options
* `:jsx` — JSX runtime, `:automatic` (default) or `:classic`
* `:jsx_factory` — function for classic JSX (default: `"React.createElement"`)
* `:jsx_fragment` — fragment for classic JSX (default: `"React.Fragment"`)
* `:import_source` — JSX import source (e.g. `"vue"`, `"preact"`)
* `:target` — downlevel target (e.g. `"es2019"`, `"chrome80"`)
* `:sourcemap` — generate a source map (default: `false`). When `true`,
returns `%{code: String.t(), sourcemap: String.t()}` instead of a plain string.
## Examples
iex> {:ok, js} = OXC.transform("const x: number = 42", "test.ts")
iex> js
"const x = 42;\\n"
iex> {:ok, js} = OXC.transform("<div />", "c.jsx", jsx: :classic)
iex> js =~ "createElement"
true
"""
@spec transform(source(), String.t(), keyword()) :: transform_result()
def transform(source, filename, opts \\ []) do
case OXC.Native.transform(source, filename, normalize_transform_options(opts)) do
{:ok, result} -> {:ok, normalize_native_result(result)}
{:error, errors} -> {:error, atomize_term_keys(errors)}
end
end
@doc """
Like `transform/3` but raises on errors.
## Examples
iex> OXC.transform!("const x: number = 42", "test.ts")
"const x = 42;\\n"
"""
@spec transform!(source(), String.t(), keyword()) :: String.t() | code_with_sourcemap()
def transform!(source, filename, opts \\ []) do
case transform(source, filename, opts) do
{:ok, code} ->
code
{:error, errors} ->
raise Error, message: "OXC transform error: #{inspect(errors)}", errors: errors
end
end
@doc """
Transform multiple source files in parallel using a Rust thread pool.
Accepts a list of `{source, filename}` tuples and shared options.
Returns a list of results in the same order, each being
`{:ok, code}`, `{:ok, %{code: ..., sourcemap: ...}}`, or `{:error, errors}`.
Significantly faster than calling `transform/3` sequentially for many files,
since work is distributed across OS threads without BEAM scheduling overhead.
## Examples
iex> results = OXC.transform_many([{"const x: number = 1", "a.ts"}, {"const y: number = 2", "b.ts"}])
iex> length(results)
2
iex> {:ok, code} = hd(results)
iex> code =~ "const x = 1"
true
"""
@spec transform_many([{source(), String.t()}], keyword()) :: [transform_result()]
def transform_many(inputs, opts \\ []) do
native_opts = normalize_transform_options(opts)
OXC.Native.transform_many(inputs, native_opts)
|> Enum.map(fn
{:ok, result} -> {:ok, normalize_native_result(result)}
{:error, errors} -> {:error, atomize_term_keys(errors)}
end)
end
@doc """
Minify JavaScript source code.
Applies dead code elimination, constant folding, and whitespace removal.
Optionally mangles variable names for smaller output.
## Options
* `:mangle` — rename variables for shorter names (default: `true`)
## Examples
iex> {:ok, min} = OXC.minify("if (false) { x() } y();", "test.js")
iex> min =~ "y()"
true
iex> min =~ "x()"
false
"""
@spec minify(source(), String.t(), keyword()) :: {:ok, String.t()} | {:error, [error()]}
def minify(source, filename, opts \\ []) do
case OXC.Native.minify(source, filename, normalize_minify_options(opts)) do
{:ok, code} -> {:ok, code}
{:error, errors} -> {:error, atomize_term_keys(errors)}
end
end
@doc """
Like `minify/3` but raises on errors.
## Examples
iex> min = OXC.minify!("const x = 1 + 2;", "test.js")
iex> is_binary(min)
true
"""
@spec minify!(source(), String.t(), keyword()) :: String.t()
def minify!(source, filename, opts \\ []) do
case minify(source, filename, opts) do
{:ok, code} ->
code
{:error, errors} ->
raise Error, message: "OXC minify error: #{inspect(errors)}", errors: errors
end
end
@doc """
Select compact parser events by name.
Available selectors:
* `:import_sources` — import/export source maps with `:specifier`, `:type`, `:kind`, `:start`, and `:end`
* `:import_specifiers` — import/export source strings only
* `:asset_urls` — `new URL(..., import.meta.url)` maps with `:specifier`, `:start`, and `:end`
* `:workers` — `new Worker(...)` and `new SharedWorker(...)` maps with `:specifier`, `:kind`, `:start`, and `:end`
* `:glob_imports` — `import.meta.glob(...)` maps with `:patterns`, `:start`, and `:end`
* `:import_meta_env` — `import.meta.env` maps with `:start` and `:end`
* `:dynamic_import_templates` — template-literal `import(...)` maps with `:pattern`, `:start`, `:end`, `:template_start`, and `:template_end`
* `:require_calls` — CommonJS `require(...)` maps with `:specifier`, `:start`, and `:end`
## Examples
iex> {:ok, refs} = OXC.select("import { ref } from 'vue'", "test.js", :import_sources)
iex> refs
[%{specifier: "vue", type: :static, kind: :import, start: 20, end: 25}]
"""
@spec select(source(), String.t(), atom()) :: {:ok, list()} | {:error, [error()]}
def select(source, filename, selector) when is_atom(selector) do
case OXC.Native.select(source, filename, selector_spec(selector)) do
{:ok, results} -> {:ok, results}
{:error, errors} -> {:error, atomize_term_keys(errors)}
end
end
defp selector_spec(:import_sources) do
[
{{:import_source, :"$1", :"$2", :"$3", :"$4", :"$5"}, [],
[
%{specifier: :"$1", type: :"$2", kind: :"$3", start: :"$4", end: :"$5"}
]}
]
end
defp selector_spec(:import_specifiers) do
[
{{:import_source, :"$1", :"$2", :"$3", :"$4", :"$5"}, [], [:"$1"]}
]
end
defp selector_spec(:asset_urls) do
[
{{:asset_url, :"$1", :"$2", :"$3"}, [], [source_span_projection()]}
]
end
defp selector_spec(:workers) do
[
{{:worker, :"$1", :"$2", :"$3", :"$4"}, [],
[
%{specifier: :"$1", kind: :"$2", start: :"$3", end: :"$4"}
]}
]
end
defp selector_spec(:glob_imports) do
[
{{:glob_import, :"$1", :"$2", :"$3"}, [],
[
%{patterns: :"$1", start: :"$2", end: :"$3"}
]}
]
end
defp selector_spec(:import_meta_env) do
[
{{:import_meta_env, :"$1", :"$2"}, [],
[
%{start: :"$1", end: :"$2"}
]}
]
end
defp selector_spec(:dynamic_import_templates) do
[
{{:dynamic_import_template, :"$1", :"$2", :"$3", :"$4", :"$5"}, [],
[
%{pattern: :"$1", start: :"$2", end: :"$3", template_start: :"$4", template_end: :"$5"}
]}
]
end
defp selector_spec(:require_calls) do
[
{{:require_call, :"$1", :"$2", :"$3"}, [], [source_span_projection()]}
]
end
defp selector_spec(selector) do
raise ArgumentError, "unknown OXC selector #{inspect(selector)}"
end
defp source_span_projection do
%{specifier: :"$1", start: :"$2", end: :"$3"}
end
@doc """
Rewrite import/export specifiers in a single pass.
Parses the source, finds all import/export declarations
(ImportDeclaration, ExportNamedDeclaration, ExportAllDeclaration,
and dynamic ImportExpression), and calls `fun` with each specifier string.
The callback returns:
* `{:rewrite, new_specifier}` — replace the specifier
* `:keep` — leave unchanged
Returns `{:ok, patched_source}` or `{:error, errors}`.
## Examples
iex> source = "import { ref } from 'vue'\\nimport a from './utils'"
iex> {:ok, result} = OXC.rewrite_specifiers(source, "test.js", fn
...> "vue" -> {:rewrite, "/@vendor/vue.js"}
...> _ -> :keep
...> end)
iex> result
"import { ref } from '/@vendor/vue.js'\\nimport a from './utils'"
"""
@spec rewrite_specifiers(source(), String.t(), (String.t() -> {:rewrite, iodata()} | :keep)) ::
{:ok, String.t()} | {:error, [error()]}
def rewrite_specifiers(source, filename, fun) when is_function(fun, 1) do
source = IO.iodata_to_binary(source)
case select(source, filename, :import_sources) do
{:ok, imports} ->
patches =
Enum.reduce(imports, [], fn %{specifier: specifier, start: start, end: finish}, acc ->
case fun.(specifier) do
{:rewrite, new} -> [%{start: start + 1, end: finish - 1, change: new} | acc]
:keep -> acc
end
end)
{:ok, patch_string(source, patches)}
{:error, _} = error ->
error
end
end
@doc """
Like `rewrite_specifiers/3` but raises on errors.
"""
@spec rewrite_specifiers!(source(), String.t(), (String.t() -> {:rewrite, iodata()} | :keep)) ::
String.t()
def rewrite_specifiers!(source, filename, fun) do
case rewrite_specifiers(source, filename, fun) do
{:ok, result} ->
result
{:error, errors} ->
raise Error, message: "OXC rewrite_specifiers error: #{inspect(errors)}", errors: errors
end
end
@doc """
Bundle JavaScript/TypeScript into a single output file.
Accepts either a filesystem entry path or a list of `{filename, source}`
tuples representing a virtual project. Filesystem entries resolve packages
through the real project directory (`:cwd`); virtual projects are useful for
tests and generated sources.
## Options
* `:entry` — entry module filename when bundling virtual `files`, for example
`"main.ts"`
* `:cwd` — project directory for filesystem entries and package resolution
* `:format` — output format: `:iife` (default), `:esm`, or `:cjs`
* `:minify` — minify the output (default: `false`)
* `:treeshake` — enable tree-shaking to remove unused exports (default: `false`)
* `:banner` — string to prepend before the IIFE (e.g. `"/* v1.0 */"`)
* `:footer` — string to append after the IIFE
* `:preamble` — code to inject at the top of the IIFE function body,
before any bundled modules (e.g. `"const { ref } = Vue;"`)
* `:external` — list of specifiers to treat as external and preserve
as imports in the output (e.g. `["react", "scheduler"]`)
* `:exports` — output export mode: `:auto`, `:default`, `:named`, or `:none`
* `:preserve_entry_signatures` — Rolldown entry signature mode:
`:strict`, `:allow_extension`, `:exports_only`, or `false`
* `:conditions` — package export conditions used by Rolldown's resolver,
for example `["browser", "import", "default"]`
* `:main_fields` — package.json fields used for package entry resolution
* `:modules` — module directories used by the resolver
* `:define` — compile-time replacements, map of `%{"process.env.NODE_ENV" => ~s("production")}`
* `:sourcemap` — generate a source map (default: `false`). When `true`,
returns `%{code: String.t(), sourcemap: String.t()}` instead of a plain string.
* `:drop_console` — remove `console.*` calls during minification (default: `false`)
* `:jsx` — JSX runtime, `:automatic` (default) or `:classic`
* `:jsx_factory` — function for classic JSX (default: `"React.createElement"`)
* `:jsx_fragment` — fragment for classic JSX (default: `"React.Fragment"`)
* `:import_source` — JSX import source (e.g. `"vue"`, `"preact"`)
* `:target` — downlevel target (e.g. `"es2019"`, `"chrome80"`)
* `:module_types` — map of file extension to loader type, e.g.
`%{".css" => :empty, ".ttf" => :dataurl}`. Supported loaders:
`:js`, `:jsx`, `:ts`, `:tsx`, `:json`, `:text`, `:base64`,
`:dataurl`, `:binary`, `:empty`, `:css`, `:asset`.
## Examples
iex> files = [
...> {"event.ts", "export class Event { type: string; constructor(type: string) { this.type = type } }"},
...> {"target.ts", "import { Event } from './event'\\nexport class Target extends Event {}"}
...> ]
iex> {:ok, js} = OXC.bundle(files, entry: "target.ts")
iex> String.contains?(js, "Event")
true
iex> String.contains?(js, "Target")
true
iex> String.contains?(js, "import ")
false
"""
@spec bundle(String.t() | [{String.t(), source()}], keyword()) :: bundle_result()
def bundle(input, opts \\ [])
def bundle(entry, opts) when is_binary(entry) do
case OXC.Native.bundle_entry(entry, normalize_bundle_options(opts)) do
{:ok, result} -> {:ok, normalize_native_result(result)}
{:error, errors} -> {:error, atomize_term_keys(errors)}
end
end
def bundle(files, opts) when is_list(files) do
if Keyword.get(opts, :entry, "") == "" do
{:error, [%{message: "bundle/2 requires :entry, for example: entry: \"main.ts\""}]}
else
case OXC.Native.bundle(files, normalize_bundle_options(opts)) do
{:ok, result} -> {:ok, normalize_native_result(result)}
{:error, errors} -> {:error, atomize_term_keys(errors)}
end
end
end
@doc """
Like `bundle/2` but raises on errors.
"""
@spec bundle!(String.t() | [{String.t(), source()}], keyword()) ::
String.t() | code_with_sourcemap()
def bundle!(files, opts \\ []) do
case bundle(files, opts) do
{:ok, result} ->
result
{:error, errors} ->
raise Error, message: "OXC bundle error: #{inspect(errors)}", errors: errors
end
end
defp normalize_transform_options(opts) do
%{
jsx: normalize_jsx_runtime(Keyword.get(opts, :jsx, :automatic)),
jsx_factory: Keyword.get(opts, :jsx_factory, ""),
jsx_fragment: Keyword.get(opts, :jsx_fragment, ""),
import_source: Keyword.get(opts, :import_source, ""),
target: Keyword.get(opts, :target, ""),
sourcemap: Keyword.get(opts, :sourcemap, false)
}
end
defp normalize_minify_options(opts) do
%{mangle: Keyword.get(opts, :mangle, true)}
end
defp normalize_bundle_options(opts) do
%{
entry: Keyword.get(opts, :entry, ""),
cwd: Keyword.get(opts, :cwd, ""),
format: opts |> Keyword.get(:format, :iife) |> Atom.to_string(),
exports: opts |> Keyword.get(:exports, :auto) |> Atom.to_string(),
minify: Keyword.get(opts, :minify, false),
treeshake: Keyword.get(opts, :treeshake, false),
banner: Keyword.get(opts, :banner),
footer: Keyword.get(opts, :footer),
preamble: Keyword.get(opts, :preamble),
define: Keyword.get(opts, :define, %{}),
module_types: normalize_module_types(Keyword.get(opts, :module_types, %{})),
external: Keyword.get(opts, :external, []),
preserve_entry_signatures:
normalize_preserve_entry_signatures(Keyword.get(opts, :preserve_entry_signatures)),
conditions: Keyword.get(opts, :conditions, []),
main_fields: Keyword.get(opts, :main_fields, []),
modules: Keyword.get(opts, :modules, []),
sourcemap: Keyword.get(opts, :sourcemap, false),
drop_console: Keyword.get(opts, :drop_console, false),
jsx: normalize_jsx_runtime(Keyword.get(opts, :jsx, :automatic)),
jsx_factory: Keyword.get(opts, :jsx_factory, ""),
jsx_fragment: Keyword.get(opts, :jsx_fragment, ""),
import_source: Keyword.get(opts, :import_source, ""),
target: Keyword.get(opts, :target, "")
}
end
defp normalize_preserve_entry_signatures(nil), do: ""
defp normalize_preserve_entry_signatures(false), do: "false"
defp normalize_preserve_entry_signatures(value) when is_atom(value), do: Atom.to_string(value)
defp normalize_preserve_entry_signatures(value) when is_binary(value), do: value
defp normalize_preserve_entry_signatures(_value), do: ""
defp normalize_module_types(types) when is_map(types) do
Map.new(types, fn {ext, loader} -> {to_string(ext), to_string(loader)} end)
end
defp normalize_jsx_runtime(runtime) when is_atom(runtime), do: Atom.to_string(runtime)
defp normalize_jsx_runtime(runtime) when is_binary(runtime), do: runtime
defp normalize_jsx_runtime(_runtime), do: "automatic"
defp normalize_native_result(result) when is_map(result), do: atomize_term_keys(result)
defp normalize_native_result(result), do: result
# Safe to use String.to_atom/1 here: ESTree has a fixed, bounded set of
# property names and node types. Untrusted user input (JS source code)
# only affects string *values*, not map keys — those come from OXC's
# serializer which emits a known set of ESTree field names.
defp atomize_term_keys(map) when is_map(map) do
Map.new(map, fn {key, value} ->
atom_key = if is_binary(key), do: String.to_atom(key), else: key
{atom_key, atomize_value(atom_key, value)}
end)
end
defp atomize_term_keys(list) when is_list(list), do: Enum.map(list, &atomize_term_keys/1)
defp atomize_term_keys(value), do: value
defp atomize_value(:type, value) when is_binary(value), do: to_snake_atom(value)
defp atomize_value(:kind, value) when is_binary(value), do: to_snake_atom(value)
defp atomize_value(_key, value), do: atomize_term_keys(value)
defp to_snake_atom(value) do
value |> Macro.underscore() |> String.to_atom()
end
# ── AST Traversal ──
# ── Codegen ──
@doc """
Generate JavaScript source code from an AST map.
Takes an ESTree AST (as returned by `parse/2` or constructed manually)
and produces formatted JavaScript source code using OXC's code generator.
Handles operator precedence, indentation, and semicolon insertion.
## Examples
iex> ast = OXC.parse!("const x = 1 + 2", "test.js")
iex> {:ok, js} = OXC.codegen(ast)
iex> js =~ "const x = 1 + 2"
true
iex> ast = %{type: :program, body: [
...> %{type: :variable_declaration, kind: :const, declarations: [
...> %{type: :variable_declarator,
...> id: %{type: :identifier, name: "x"},
...> init: %{type: :literal, value: 42}}
...> ]}
...> ]}
iex> {:ok, js} = OXC.codegen(ast)
iex> js =~ "const x = 42"
true
"""
@spec codegen(ast()) :: {:ok, String.t()} | {:error, [error()]}
def codegen(ast) do
case OXC.Native.codegen(deatomize_ast(ast)) do
{:ok, code} -> {:ok, code}
{:error, errors} -> {:error, atomize_term_keys(errors)}
end
end
@doc """
Like `codegen/1` but raises on errors.
"""
@spec codegen!(ast()) :: String.t()
def codegen!(ast) do
case codegen(ast) do
{:ok, code} ->
code
{:error, errors} ->
raise Error, message: "OXC codegen error: #{inspect(errors)}", errors: errors
end
end
@doc """
Substitute `$placeholders` in an AST with provided values.
Walks the AST and replaces any identifier node whose name starts with `$`
with the corresponding value from `bindings`.
Binding values can be:
* A string or iodata — replaced as an identifier name
* `{:literal, value}` — replaced with a literal node (string, number,
boolean, nil, map, or list — maps and lists are converted recursively
into JS object/array expressions)
* `{:expr, code}` — parsed as a JavaScript expression from a binary or iodata
* A map with `:type` — spliced as a raw AST node
## Examples
iex> {:ok, ast} = OXC.parse("const x = $value", "t.js")
iex> ast = OXC.bind(ast, value: {:literal, 42})
iex> OXC.codegen!(ast) =~ "const x = 42"
true
iex> {:ok, ast} = OXC.parse("const $name = 1", "t.js")
iex> ast = OXC.bind(ast, name: "myVar")
iex> OXC.codegen!(ast) =~ "const myVar = 1"
true
"""
@spec bind(ast(), keyword()) :: ast()
def bind(ast, bindings) when is_list(bindings) do
lookup = Map.new(bindings, fn {k, v} -> {"$#{k}", resolve_binding(v)} end)
postwalk(ast, fn
%{type: :identifier, name: "$" <> _ = name} = node ->
case Map.get(lookup, name) do
nil -> node
{:rename, new_name} -> %{node | name: new_name}
{:node, ast_node} -> ast_node
end
node ->
node
end)
end
defp resolve_binding(value) when is_binary(value), do: {:rename, value}
defp resolve_binding(value) when is_list(value), do: {:rename, IO.iodata_to_binary(value)}
defp resolve_binding({:literal, lit}), do: {:node, literal_to_ast(lit)}
defp resolve_binding({:expr, code}) when is_binary(code) or is_list(code),
do: {:node, parse_expression!(code)}
defp resolve_binding(%{type: _} = node), do: {:node, node}
@doc """
Replace `$placeholder` statements, properties, or elements with a list of nodes.
Finds expression statements, shorthand object properties, or array elements
whose identifier name starts with `$` and replaces them with the provided
nodes. Accepts a single item or a list. Strings and iodata items are
auto-parsed as JS.
## Examples
iex> {:ok, ast} = OXC.parse("function f() { $body }", "t.js")
iex> ast = OXC.splice(ast, :body, ["const x = 1;", "return x;"])
iex> js = OXC.codegen!(ast)
iex> js =~ "const x = 1" and js =~ "return x"
true
iex> {:ok, ast} = OXC.parse("const obj = {a: 1, $rest}", "t.js")
iex> ast = OXC.splice(ast, :rest, ["b: 2", "c: 3"])
iex> js = OXC.codegen!(ast)
iex> js =~ "b: 2" and js =~ "c: 3"
true
"""
@spec splice(ast(), atom(), ast() | iodata() | [ast() | iodata()]) :: ast()
def splice(ast, name, replacement) when is_atom(name) do
placeholder = "$#{name}"
items = List.wrap(replacement)
postwalk(ast, fn
%{type: :program, body: body} = node ->
%{node | body: splice_statements(body, placeholder, items)}
%{type: :block_statement, body: body} = node ->
%{node | body: splice_statements(body, placeholder, items)}
%{type: :object_expression, properties: props} = node ->
%{node | properties: splice_properties(props, placeholder, items)}
%{type: :array_expression, elements: elems} = node ->
%{node | elements: splice_elements(elems, placeholder, items)}
%{type: type, body: body} = node when type in [:function_body, :class_body] ->
%{node | body: splice_statements(body, placeholder, items)}
node ->
node
end)
end
defp splice_statements(stmts, placeholder, items) do
Enum.flat_map(stmts, fn
%{type: :expression_statement, expression: %{type: :identifier, name: ^placeholder}} ->
Enum.map(items, &resolve_splice_statement/1)
other ->
[other]
end)
end
defp splice_properties(props, placeholder, items) do
Enum.flat_map(props, fn
%{type: :property, shorthand: true, key: %{type: :identifier, name: ^placeholder}} ->
Enum.map(items, &resolve_splice_property/1)
other ->
[other]
end)
end
defp splice_elements(elems, placeholder, items) do
Enum.flat_map(elems, fn
%{type: :identifier, name: ^placeholder} ->
Enum.map(items, &resolve_splice_element/1)
other ->
[other]
end)
end
defp resolve_splice_statement(item) when is_binary(item) or is_list(item) do
item = IO.iodata_to_binary(item)
case parse(item, "splice.js") do
{:ok, %{body: [stmt]}} ->
stmt
_ ->
%{body: [%{body: %{body: [stmt]}}]} = parse!("function _(){" <> item <> "}", "splice.js")
stmt
end
end
defp resolve_splice_statement(%{type: _} = node), do: node
defp resolve_splice_property(item) when is_binary(item) or is_list(item) do
ast = parse!(["({", item, "})"], "splice.js")
[%{type: :expression_statement, expression: expr}] = ast.body
props =
case expr do
%{type: :parenthesized_expression, expression: %{properties: p}} -> p
%{type: :object_expression, properties: p} -> p
%{properties: p} -> p
end
[prop] = props
prop
end
defp resolve_splice_property(%{type: _} = node), do: node
defp resolve_splice_element(item) when is_binary(item) or is_list(item) do
parse_expression!(item)
end
defp resolve_splice_element(%{type: _} = node), do: node
defp parse_expression!(code) do
ast = parse!(code, "expr.js")
case ast.body do
[%{type: :expression_statement, expression: expr}] -> expr
_ -> raise Error, message: "Expected a single expression: #{code}", errors: []
end
end
defp literal_to_ast(value) when is_binary(value), do: %{type: :literal, value: value}
defp literal_to_ast(value) when is_number(value), do: %{type: :literal, value: value}
defp literal_to_ast(value) when is_boolean(value), do: %{type: :literal, value: value}
defp literal_to_ast(nil), do: %{type: :literal, value: nil}
defp literal_to_ast(map) when is_map(map) do
%{
type: :object_expression,
properties:
Enum.map(map, fn {k, v} ->
%{
type: :property,
key: %{type: :identifier, name: to_string(k)},
value: literal_to_ast(v),
kind: :init,
shorthand: false,
computed: false,
method: false
}
end)
}
end
defp literal_to_ast(list) when is_list(list) do
%{type: :array_expression, elements: Enum.map(list, &literal_to_ast/1)}
end
# Convert atom keys/values back to strings for the Rust NIF
defp deatomize_ast(map) when is_map(map) do
Map.new(map, fn {key, value} ->
str_key = if is_atom(key), do: deatomize_key(key), else: key
{str_key, deatomize_value(key, value)}
end)
end
defp deatomize_ast(list) when is_list(list), do: Enum.map(list, &deatomize_ast/1)
defp deatomize_ast(value), do: value
defp deatomize_key(:super_class), do: :superClass
defp deatomize_key(key), do: key
defp deatomize_value(:type, value) when is_atom(value), do: value
defp deatomize_value(:kind, value) when is_atom(value), do: value
defp deatomize_value(_key, value), do: deatomize_ast(value)
@doc """
Walk an AST tree, calling `fun` on every node (any map with a `:type` key).
Descends into all map values and list elements to reach nested AST
nodes, including maps without a `:type` key (which are skipped for
the callback but still traversed).
## Examples
iex> {:ok, ast} = OXC.parse("const x = 1", "test.js")
iex> OXC.walk(ast, fn
...> %{type: :identifier, name: name} -> send(self(), {:id, name})
...> _ -> :ok
...> end)
iex> receive do {:id, name} -> name end
"x"
"""
@spec walk(ast() | [ast()], (map() -> any())) :: :ok
def walk(nodes, fun) when is_list(nodes) and is_function(fun, 1) do
Enum.each(nodes, &walk(&1, fun))
end
def walk(node, fun) when is_map(node) and is_function(fun, 1) do
if Map.has_key?(node, :type), do: fun.(node)
node
|> Map.values()
|> Enum.each(fn
child when is_map(child) -> walk(child, fun)
children when is_list(children) -> Enum.each(children, &walk_child(&1, fun))
_ -> :ok
end)
end
def walk(_node, _fun), do: :ok
defp walk_child(node, fun) when is_map(node), do: walk(node, fun)
defp walk_child(_node, _fun), do: :ok
@doc """
Depth-first post-order traversal, like `Macro.postwalk/2`.
Visits every AST node (map with a `:type` key). Children are visited
first, then the node itself. The callback returns the (possibly modified)
node.
Accepts a single AST node or a list of nodes.
## Examples
iex> {:ok, ast} = OXC.parse("const x = 1", "test.js")
iex> OXC.postwalk(ast, fn
...> %{type: :identifier, name: "x"} = node -> %{node | name: "y"}
...> node -> node
...> end)
iex> :ok
:ok
"""
@spec postwalk(ast() | [ast()], (map() -> map())) :: map() | [map()]
def postwalk(nodes, fun) when is_list(nodes) and is_function(fun, 1) do
Enum.map(nodes, fn
n when is_map(n) -> postwalk(n, fun)
n -> n
end)
end
def postwalk(node, fun) when is_map(node) and is_function(fun, 1) do
updated =
Map.new(node, fn
{k, child} when is_map(child) ->
{k, postwalk(child, fun)}
{k, children} when is_list(children) ->
{k,
Enum.map(children, fn
c when is_map(c) -> postwalk(c, fun)
c -> c
end)}
pair ->
pair
end)
if Map.has_key?(updated, :type), do: fun.(updated), else: updated
end
def postwalk(node, _fun), do: node
@doc """
Depth-first post-order traversal with accumulator, like `Macro.postwalk/3`.
The callback receives each AST node and the accumulator, and must return
`{node, acc}`. Use this to collect data while traversing.
Accepts a single AST node or a list of nodes.
## Examples
iex> source = "import { ref } from 'vue'\\nimport a from './utils'"
iex> {:ok, ast} = OXC.parse(source, "test.ts")
iex> {_ast, patches} = OXC.postwalk(ast, [], fn
...> %{type: :import_declaration, source: %{value: "vue"} = src} = node, patches ->
...> {node, [%{start: src.start, end: src.end, change: "'/@vendor/vue.js'"} | patches]}
...> node, patches ->
...> {node, patches}
...> end)
iex> OXC.patch_string(source, patches)
"import { ref } from '/@vendor/vue.js'\\nimport a from './utils'"
"""
@spec postwalk(ast() | [ast()], acc, (map(), acc -> {map(), acc})) :: {map() | [map()], acc}
when acc: term()
def postwalk(nodes, acc, fun) when is_list(nodes) and is_function(fun, 2) do
Enum.map_reduce(nodes, acc, fn
node, a when is_map(node) -> postwalk(node, a, fun)
node, a -> {node, a}
end)
end
def postwalk(node, acc, fun) when is_map(node) and is_function(fun, 2) do
{updated, acc} =
Enum.reduce(Map.keys(node), {node, acc}, fn key, {n, a} ->
case Map.fetch!(n, key) do
child when is_map(child) ->
{new_child, a} = postwalk(child, a, fun)
{Map.put(n, key, new_child), a}
children when is_list(children) ->
{new_children, a} =
Enum.map_reduce(children, a, fn
child, a when is_map(child) -> postwalk(child, a, fun)
child, a -> {child, a}
end)
{Map.put(n, key, new_children), a}
_ ->
{n, a}
end
end)
if Map.has_key?(updated, :type), do: fun.(updated, acc), else: {updated, acc}
end
def postwalk(node, acc, _fun), do: {node, acc}
@doc """
Collect AST nodes that match a filter function.
The function receives each node (map with `:type` key) and should return
`{:keep, value}` to include it in results, or `:skip` to exclude it.
## Examples
iex> {:ok, ast} = OXC.parse("const x = y + z", "test.js")
iex> OXC.collect(ast, fn
...> %{type: :identifier, name: name} -> {:keep, name}
...> _ -> :skip
...> end)
["x", "y", "z"]
"""
@spec collect(ast(), (map() -> {:keep, any()} | :skip)) :: [any()]
def collect(node, fun) do
node
|> do_collect(fun, [])
|> Enum.reverse()
end
defp do_collect(node, fun, acc) when is_map(node) do
acc =
if Map.has_key?(node, :type) do
case fun.(node) do
{:keep, value} -> [value | acc]
:skip -> acc
end
else
acc
end
Enum.reduce(Map.values(node), acc, fn
child, a when is_map(child) ->
do_collect(child, fun, a)
children, a when is_list(children) ->
Enum.reduce(children, a, &do_collect_child(&1, fun, &2))
_, a ->
a
end)
end
defp do_collect(_node, _fun, acc), do: acc
defp do_collect_child(node, fun, acc) when is_map(node), do: do_collect(node, fun, acc)
defp do_collect_child(_node, _fun, acc), do: acc
# ── Source Patching ──
@type patch :: %{start: non_neg_integer(), end: non_neg_integer(), change: iodata()}
@doc """
Apply patches to source code, like `Sourceror.patch_string/2`.
Each patch is a map with `:start` (byte offset), `:end` (byte offset),
and `:change` (replacement iodata). Patches are applied in reverse
offset order so that earlier patches don't shift later offsets.
When multiple patches target the same `{start, end}` range, only the
first one is applied and duplicates are silently dropped.
Use with `postwalk/3` to collect patches from the AST, then apply
them to the original source string.
## Examples
iex> OXC.patch_string("hello world", [%{start: 6, end: 11, change: "elixir"}])
"hello elixir"
iex> source = "import { ref } from 'vue'"
iex> OXC.patch_string(source, [%{start: 20, end: 25, change: "'/@vendor/vue.js'"}])
"import { ref } from '/@vendor/vue.js'"
"""
@spec patch_string(source(), [patch()]) :: String.t()
def patch_string(source, patches) do
source = IO.iodata_to_binary(source)
{chunks, offset} =
patches
|> Enum.uniq_by(fn %{start: s, end: e} -> {s, e} end)
|> Enum.sort_by(fn %{start: s} -> s end)
|> Enum.reduce({[], 0}, fn %{start: s, end: e, change: replacement}, {chunks, offset} ->
chunk = binary_part(source, offset, s - offset)
{[replacement, chunk | chunks], e}
end)
tail = binary_part(source, offset, byte_size(source) - offset)
IO.iodata_to_binary(Enum.reverse([tail | chunks]))
end
end