Skip to main content

lib/oxc.ex

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