# OXC
Elixir bindings for the [OXC](https://oxc.rs) JavaScript toolchain via Rust NIFs.
Parse, transform, minify, lint, and generate JavaScript/TypeScript at native speed.
## Features
- **Parse** JS/TS/JSX/TSX into ESTree AST (maps with atom keys, snake_case types)
- **Codegen** — serialize AST maps back to JavaScript source via OXC's code generator
- **Bind** — substitute `$placeholders` in parsed AST (quasiquoting for JS)
- **Transform** TypeScript → JavaScript, JSX → `createElement`/`jsx` calls
- **Minify** with dead code elimination, constant folding, and variable mangling
- **Lint** with 650+ built-in oxlint rules + custom Elixir rules
- **Bundle** multiple TS/JS modules into a single IIFE with dependency resolution
- **Rewrite specifiers** — rewrite import/export paths in a single pass
- **Collect imports** — typed import analysis (static/dynamic, import/export/export_all)
- **Walk/Collect** helpers for AST traversal and node filtering
- **Postwalk** with accumulator for AST-based source patching (like `Macro.postwalk/3`)
- **Patch string** — apply byte-offset patches to source (like `Sourceror.patch_string/2`)
- **Import extraction** — fast NIF-level import specifier extraction
## Installation
```elixir
def deps do
[
{:oxc, "~> 0.8.0"}
]
end
```
Precompiled NIFs are available for macOS (aarch64, x86_64) and Linux (aarch64, x86_64, musl).
Building from source requires a Rust toolchain (`rustup` recommended).
## Usage
### Parse
```elixir
{:ok, ast} = OXC.parse("const x = 1 + 2", "test.js")
ast.type
# :program
[stmt] = ast.body
stmt.expression
# %{type: :binary_expression, operator: "+", left: %{value: 1}, right: %{value: 2}}
```
File extension determines the dialect — `.js`, `.jsx`, `.ts`, `.tsx`:
```elixir
{:ok, ast} = OXC.parse("const x: number = 42", "test.ts")
{:ok, ast} = OXC.parse("<App />", "component.tsx")
```
AST node `:type` and `:kind` values are snake_case atoms (e.g. `:import_declaration`, `:variable_declaration`, `:const`).
### Codegen
Generate JavaScript source from an AST map — the inverse of `parse/2`.
Uses OXC's code generator for correct operator precedence, formatting, and semicolons:
```elixir
{:ok, ast} = OXC.parse("const x = 1 + 2", "test.js")
{:ok, js} = OXC.codegen(ast)
# "const x = 1 + 2;\n"
```
Construct AST by hand and generate JS:
```elixir
ast = %{type: :program, body: [
%{type: :function_declaration,
id: %{type: :identifier, name: "add"},
params: [%{type: :identifier, name: "a"}, %{type: :identifier, name: "b"}],
body: %{type: :block_statement, body: [
%{type: :return_statement, argument: %{type: :binary_expression, operator: "+",
left: %{type: :identifier, name: "a"}, right: %{type: :identifier, name: "b"}}}
]}}
]}
OXC.codegen!(ast)
# "function add(a, b) {\n\treturn a + b;\n}\n"
```
### Bind (Quasiquoting)
Parse a JS template with `$placeholders`, substitute values, and generate code.
Like Elixir's `quote`/`unquote` but for JavaScript:
```elixir
js =
OXC.parse!("const $name = $value", "t.js")
|> OXC.bind(name: "count", value: {:literal, 0})
|> OXC.codegen!()
# "const count = 0;\n"
```
Binding values can be:
- A string — replaces the identifier name
- `{:literal, value}` — replaces with a literal node (string, number, boolean, nil)
- A map with `:type` — splices a raw AST node
```elixir
# Splice an AST node
expr = %{type: :binary_expression, operator: "+",
left: %{type: :literal, value: 1},
right: %{type: :literal, value: 2}}
js =
OXC.parse!("const result = $expr", "t.js")
|> OXC.bind(expr: expr)
|> OXC.codegen!()
# "const result = 1 + 2;\n"
```
Use `.js`/`.ts` files as templates with full editor support:
```elixir
# priv/templates/api-client.js — real JS, full syntax highlighting
# import { z } from "zod";
# export const $schema = z.object($fields);
# export async function $listFn(params = {}) { ... }
template = File.read!("priv/templates/api-client.js")
ast = OXC.parse!(template, "api-client.js")
js =
ast
|> OXC.bind(schema: "userSchema", listFn: "listUsers", ...)
|> OXC.codegen!()
```
### Transform
Strip TypeScript types and transform JSX:
```elixir
{:ok, js} = OXC.transform("const x: number = 42", "test.ts")
# "const x = 42;\n"
{:ok, js} = OXC.transform("<App />", "app.tsx")
# Uses automatic JSX runtime by default
{:ok, js} = OXC.transform("<App />", "app.jsx", jsx: :classic)
# Uses React.createElement
```
With source maps:
```elixir
{:ok, %{code: js, sourcemap: map}} = OXC.transform(code, "app.ts", sourcemap: true)
```
Target specific environments:
```elixir
{:ok, js} = OXC.transform("const x = a ?? b", "test.js", target: "es2019")
# Nullish coalescing lowered to ternary
```
Custom JSX import source (Vue, Preact, etc.):
```elixir
{:ok, js} = OXC.transform("<div />", "app.jsx", import_source: "vue")
# Imports from vue/jsx-runtime instead of react/jsx-runtime
```
### Minify
```elixir
{:ok, min} = OXC.minify("const x = 1 + 2; console.log(x);", "test.js")
# Constants folded, whitespace removed, variables mangled
{:ok, min} = OXC.minify(code, "test.js", mangle: false)
# Compress without renaming variables
```
### Lint
Lint JavaScript/TypeScript with oxlint's 650+ built-in rules:
```elixir
{:ok, diags} = OXC.Lint.run("x == y", "test.js",
rules: %{"eqeqeq" => :deny})
# [%{rule: "eqeqeq", message: "Require the use of === and !==", severity: :deny, ...}]
{:ok, []} = OXC.Lint.run("export const x = 1;\n", "test.ts")
```
Enable specific plugins:
```elixir
{:ok, diags} = OXC.Lint.run(source, "app.tsx",
plugins: [:react, :typescript],
rules: %{"no-console" => :warn, "react/no-danger" => :deny})
```
Available plugins: `:react`, `:typescript`, `:unicorn`, `:import`, `:jsdoc`,
`:jest`, `:vitest`, `:jsx_a11y`, `:nextjs`, `:react_perf`, `:promise`,
`:node`, `:vue`, `:oxc`.
#### Custom Elixir Rules
Write project-specific lint rules in Elixir using the same AST from `OXC.parse/2`:
```elixir
defmodule MyApp.NoConsoleLog do
@behaviour OXC.Lint.Rule
@impl true
def meta do
%{name: "my-app/no-console-log",
description: "Disallow console.log in production code",
category: :restriction, fixable: false}
end
@impl true
def run(ast, _context) do
OXC.collect(ast, fn
%{type: :call_expression,
callee: %{type: :member_expression,
object: %{type: :identifier, name: "console"},
property: %{type: :identifier, name: "log"}},
start: start, end: stop} ->
{:keep, %{span: {start, stop}, message: "Unexpected console.log"}}
_ -> :skip
end)
end
end
{:ok, diags} = OXC.Lint.run(source, "app.ts",
custom_rules: [{MyApp.NoConsoleLog, :warn}])
```
### Import Extraction
Fast NIF-level extraction of import specifiers — skips full AST serialization:
```elixir
{:ok, imports} = OXC.imports("import { ref } from 'vue'\nimport { h } from 'preact'", "test.ts")
# ["vue", "preact"]
```
Type-only imports are excluded automatically:
```elixir
{:ok, imports} = OXC.imports("import type { Ref } from 'vue'\nimport { ref } from 'vue'", "test.ts")
# ["vue"]
```
### Typed Import Analysis
Collect imports with type information, byte offsets, and kind:
```elixir
source = "import { ref } from 'vue'\nexport { foo } from './foo'\nimport('./lazy')"
{:ok, imports} = OXC.collect_imports(source, "test.js")
# [
# %{specifier: "vue", type: :static, kind: :import, start: 20, end: 25},
# %{specifier: "./foo", type: :static, kind: :export, start: 47, end: 54},
# %{specifier: "./lazy", type: :dynamic, kind: :import, start: 62, end: 70}
# ]
```
### Rewrite Specifiers
Rewrite import/export specifiers in a single pass without AST walking:
```elixir
source = "import { ref } from 'vue'\nimport a from './utils'"
{:ok, result} = OXC.rewrite_specifiers(source, "test.js", fn
"vue" -> {:rewrite, "/@vendor/vue.js"}
_ -> :keep
end)
# "import { ref } from '/@vendor/vue.js'\nimport a from './utils'"
```
Handles `ImportDeclaration`, `ExportNamedDeclaration`, `ExportAllDeclaration`, and dynamic `import()`.
### Validate
Fast syntax check without building an AST:
```elixir
OXC.valid?("const x = 1", "test.js")
# true
OXC.valid?("const = ;", "bad.js")
# false
```
### AST Traversal
```elixir
{:ok, ast} = OXC.parse("import a from 'a'; import b from 'b'; const x = 1;", "test.js")
# Walk every node
OXC.walk(ast, fn
%{type: :identifier, name: name} -> IO.puts(name)
_ -> :ok
end)
# Collect specific nodes
imports = OXC.collect(ast, fn
%{type: :import_declaration} = node -> {:keep, node}
_ -> :skip
end)
```
### AST Postwalk and Source Patching
Rewrite source code by walking the AST and collecting byte-offset patches:
```elixir
source = "import { ref } from 'vue'\nimport { h } from 'preact'"
{:ok, ast} = OXC.parse(source, "test.ts")
{_ast, patches} =
OXC.postwalk(ast, [], fn
%{type: :import_declaration, source: %{value: "vue", start: s, end: e}}, acc ->
{nil, [%{start: s, end: e, change: "'/@vendor/vue.js'"} | acc]}
node, acc ->
{node, acc}
end)
rewritten = OXC.patch_string(source, patches)
# "import { ref } from '/@vendor/vue.js'\nimport { h } from 'preact'"
```
`postwalk/2` visits nodes depth-first (children before parent), like `Macro.postwalk/2`.
`postwalk/3` adds an accumulator for collecting data during traversal.
`patch_string/2` applies patches in reverse offset order so positions stay valid.
All traversal functions (`walk/2`, `postwalk/2`, `postwalk/3`) accept either a single AST node or a list of nodes.
### Bundle
Bundle multiple TypeScript/JavaScript modules into a single IIFE script.
Treats the provided files as a virtual project, resolves their imports,
transforms TS/JSX, and bundles the result:
```elixir
files = [
{"event.ts", "export class Event { type: string; constructor(t: string) { this.type = t } }"},
{"target.ts", "import { Event } from './event'\nexport class Target extends Event {}"}
]
{:ok, js} = OXC.bundle(files, entry: "target.ts")
```
Options:
```elixir
# Minify with variable mangling
{:ok, js} = OXC.bundle(files, entry: "target.ts", minify: true)
# Tree-shaking (remove unused exports)
{:ok, js} = OXC.bundle(files, entry: "target.ts", treeshake: true)
# Inject code at the top of the IIFE body
{:ok, js} = OXC.bundle(files, entry: "app.ts", preamble: "const { ref } = Vue;")
# Compile-time replacements (like esbuild/Bun define)
{:ok, js} = OXC.bundle(files, entry: "target.ts", define: %{"process.env.NODE_ENV" => ~s("production")})
# Source maps
{:ok, %{code: js, sourcemap: map}} = OXC.bundle(files, entry: "target.ts", sourcemap: true)
# Output format: :iife (default), :esm, or :cjs
{:ok, js} = OXC.bundle(files, entry: "target.ts", format: :esm)
# Remove console.* calls
{:ok, js} = OXC.bundle(files, entry: "target.ts", minify: true, drop_console: true)
# Target-specific downleveling
{:ok, js} = OXC.bundle(files, entry: "target.ts", target: "es2020")
# Banner and footer
{:ok, js} = OXC.bundle(files, entry: "target.ts", banner: "/* MIT */", footer: "/* v1.0 */")
```
### Bang Variants
All functions have bang variants that raise `OXC.Error` on failure:
```elixir
ast = OXC.parse!("const x = 1", "test.js")
js = OXC.transform!("const x: number = 42", "test.ts")
min = OXC.minify!("const x = 1 + 2;", "test.js")
js = OXC.codegen!(ast)
imports = OXC.imports!("import { ref } from 'vue'", "test.ts")
```
### Error Handling
All functions return `{:ok, result}` or `{:error, errors}` where errors are
maps with a `:message` key:
```elixir
{:error, [%{message: "Expected a semicolon or ..."}]} = OXC.parse("const = ;", "bad.js")
```
## How It Works
OXC is a collection of high-performance JavaScript tools written in Rust.
This library wraps `oxc_parser`, `oxc_transformer`, `oxc_minifier`,
`oxc_transformer_plugins`, `oxc_codegen`, and `oxc_linter` via
[Rustler](https://github.com/rusterlium/rustler) NIFs, and uses
Rolldown/OXC for `bundle/2`.
All NIF calls run on the dirty CPU scheduler so they don't block the BEAM.
For **parse**, the parser produces ESTree JSON via OXC's serializer,
Rustler encodes it as BEAM terms, and the Elixir wrapper normalizes
AST keys to atoms with snake_case type values.
For **codegen**, the reverse happens: the Elixir AST map (BEAM terms) is
read directly by the NIF via Rustler's Term API, reconstructed into OXC's
arena-allocated AST using `AstBuilder`, and then emitted as JavaScript
via `oxc_codegen`.
For **lint**, oxlint's built-in rules run natively in Rust. Custom rules
written in Elixir receive the same parsed AST and run in the BEAM.
## License
MIT