Skip to main content

lib/oxc/lint.ex

defmodule OXC.Lint do
  @moduledoc """
  Lint JavaScript/TypeScript source with oxlint's built-in rules
  and optional custom Elixir rules.

  Combines native Rust performance for 650+ standard rules with
  the ability to write project-specific rules in Elixir using
  the same AST that `OXC.parse/2` returns.

  ## Examples

      {:ok, diags} = OXC.Lint.run("debugger;", "test.js",
        rules: %{"no-debugger" => :deny})

      {:ok, []} = OXC.Lint.run("export const x = 1;\\n", "test.ts")
  """

  @type severity :: :allow | :warn | :deny
  @type diagnostic :: %{
          rule: String.t(),
          message: String.t(),
          severity: severity(),
          span: {non_neg_integer(), non_neg_integer()},
          labels: [{non_neg_integer(), non_neg_integer()}],
          help: String.t() | nil
        }

  @doc """
  Lint source code with oxlint's built-in rules and optional custom rules.

  Pass a list of files with `type_aware: true` to run TypeScript type-aware
  rules through `tsgolint` headless mode:

      OXC.Lint.run(["lib/app.ts"],
        type_aware: true,
        tsgolint: "tsgolint",
        rules: %{"typescript/no-floating-promises" => :deny})

  ## Options

    * `:rules` — map of rule names to severity (`:deny`, `:warn`, `:allow`).
      Rule names follow oxlint conventions: `"eqeqeq"`, `"react/no-danger"`,
      `"typescript/no-explicit-any"`, etc.

    * `:plugins` — list of built-in plugin atoms to enable.
      Default: oxlint defaults (eslint correctness rules).
      Available: `:react`, `:typescript`, `:unicorn`, `:import`, `:jsdoc`,
      `:jest`, `:vitest`, `:jsx_a11y`, `:nextjs`, `:react_perf`, `:promise`,
      `:node`, `:vue`, `:oxc`

    * `:fix` — compute fix suggestions. Default: `false`

    * `:custom_rules` — list of `{module, severity}` tuples for Elixir rules.
      Each module must implement the `OXC.Lint.Rule` behaviour.

    * `:settings` — arbitrary map passed to custom rule context.

  ## Examples

      # Built-in rules only
      {:ok, diags} = OXC.Lint.run("debugger;", "test.js",
        rules: %{"no-debugger" => :deny})

      # With specific plugins and rules
      {:ok, diags} = OXC.Lint.run(source, "app.tsx",
        plugins: [:react, :typescript],
        rules: %{"no-console" => :warn, "react/no-danger" => :deny}
      )

      # With custom Elixir rules
      {:ok, diags} = OXC.Lint.run(source, "app.ts",
        custom_rules: [{MyApp.NoConsoleLog, :warn}]
      )
  """
  @spec run([String.t()], keyword()) :: {:ok, [diagnostic()]} | {:error, [String.t()]}
  @spec run(iodata(), String.t(), keyword()) :: {:ok, [diagnostic()]} | {:error, [String.t()]}
  def run(files, opts) when is_list(files) and is_list(opts) do
    if Keyword.get(opts, :type_aware, false) do
      OXC.Lint.TypeAware.run(files, opts)
    else
      {:error, ["OXC.Lint.run/2 with a file list requires type_aware: true"]}
    end
  end

  def run(source, filename, opts \\ []) do
    source = IO.iodata_to_binary(source)
    plugins = opts |> Keyword.get(:plugins, []) |> Enum.map(&to_string/1)
    fix = Keyword.get(opts, :fix, false)

    rules =
      opts
      |> Keyword.get(:rules, %{})
      |> Enum.map(fn {name, severity} -> {to_string(name), severity_to_string(severity)} end)

    custom_rules = Keyword.get(opts, :custom_rules, [])
    settings = Keyword.get(opts, :settings, %{})

    case OXC.Lint.Native.lint(source, filename, plugins, rules, fix) do
      {:ok, builtin_diags} ->
        custom =
          case custom_rules do
            [] -> []
            rules -> run_custom_rules(rules, source, filename, settings)
          end

        {:ok, builtin_diags ++ custom}

      {:error, errors} ->
        {:error, errors}
    end
  end

  @doc """
  Like `run/3` but raises on errors.
  """
  @spec run!(iodata(), String.t(), keyword()) :: [diagnostic()]
  def run!(source, filename, opts \\ []) do
    case run(source, filename, opts) do
      {:ok, diags} ->
        diags

      {:error, errors} ->
        raise OXC.Error, message: "OXC lint error: #{inspect(errors)}", errors: errors
    end
  end

  defp severity_to_string(:deny), do: "deny"
  defp severity_to_string(:warn), do: "warn"
  defp severity_to_string(:allow), do: "allow"

  defp run_custom_rules(rules, source, filename, settings) do
    case OXC.parse(source, filename) do
      {:ok, ast} ->
        context = %{source: source, filename: filename, settings: settings}

        Enum.flat_map(rules, fn {module, severity} ->
          meta = module.meta()

          module.run(ast, context)
          |> Enum.map(fn diag ->
            %{
              rule: meta.name,
              message: diag.message,
              severity: severity,
              span: Map.get(diag, :span, {0, 0}),
              labels: Map.get(diag, :labels, []),
              help: Map.get(diag, :help)
            }
          end)
        end)

      {:error, _} ->
        []
    end
  end
end