Skip to content

Commit 3ac948b

Browse files
committed
Release 0.8.0
2 parents 18c0687 + 21f2d02 commit 3ac948b

13 files changed

Lines changed: 2536 additions & 10 deletions

File tree

.github/workflows/release.yml

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ jobs:
3636
with:
3737
target: ${{ matrix.job.target }}
3838

39-
- name: Build the project
39+
- name: Build oxc_ex_nif
4040
id: build-crate
4141
uses: philss/rustler-precompiled-action@v1.1.4
4242
with:
@@ -48,14 +48,35 @@ jobs:
4848
project-dir: "."
4949
cross-version: "from-source"
5050

51+
- name: Build oxc_lint_nif
52+
if: matrix.job.target != 'x86_64-unknown-linux-musl'
53+
id: build-lint-crate
54+
uses: philss/rustler-precompiled-action@v1.1.4
55+
with:
56+
project-name: oxc_lint_nif
57+
project-version: ${{ env.PROJECT_VERSION }}
58+
target: ${{ matrix.job.target }}
59+
nif-version: ${{ matrix.nif }}
60+
use-cross: ${{ matrix.job.use-cross }}
61+
project-dir: "native/oxc_lint_nif"
62+
cross-version: "from-source"
63+
5164
- name: Artifact upload
5265
uses: actions/upload-artifact@v4
5366
with:
5467
name: ${{ steps.build-crate.outputs.file-name }}
5568
path: ${{ steps.build-crate.outputs.file-path }}
5669

70+
- name: Artifact upload (lint)
71+
if: matrix.job.target != 'x86_64-unknown-linux-musl'
72+
uses: actions/upload-artifact@v4
73+
with:
74+
name: ${{ steps.build-lint-crate.outputs.file-name }}
75+
path: ${{ steps.build-lint-crate.outputs.file-path }}
76+
5777
- name: Publish archives and packages
5878
uses: softprops/action-gh-release@v2
5979
with:
6080
files: |
6181
${{ steps.build-crate.outputs.file-path }}
82+
${{ steps.build-lint-crate.outputs.file-path }}

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
## 0.8.0
4+
5+
### Added
6+
7+
- `OXC.Lint.run/3` — lint JS/TS source with oxlint's 650+ built-in rules via a Rust NIF. Supports all oxlint plugins (react, typescript, unicorn, import, jsdoc, jest, vitest, jsx-a11y, nextjs, promise, node, vue) and configurable rule severities.
8+
- `OXC.Lint.Rule` behaviour — write custom lint rules in Elixir that operate on the parsed ESTree AST. Rules use `OXC.walk/2`, `OXC.collect/2`, or `OXC.postwalk/3` for traversal and return diagnostics with spans.
9+
- Built-in and custom rules run together in a single `OXC.Lint.run/3` call.
10+
311
## 0.7.2
412

513
### Added
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
%{
2+
}

lib/oxc/lint.ex

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
defmodule OXC.Lint do
2+
@moduledoc """
3+
Lint JavaScript/TypeScript source with oxlint's built-in rules
4+
and optional custom Elixir rules.
5+
6+
Combines native Rust performance for 650+ standard rules with
7+
the ability to write project-specific rules in Elixir using
8+
the same AST that `OXC.parse/2` returns.
9+
10+
## Examples
11+
12+
{:ok, diags} = OXC.Lint.run("debugger;", "test.js",
13+
rules: %{"no-debugger" => :deny})
14+
15+
{:ok, []} = OXC.Lint.run("export const x = 1;\\n", "test.ts")
16+
"""
17+
18+
@type severity :: :allow | :warn | :deny
19+
@type diagnostic :: %{
20+
rule: String.t(),
21+
message: String.t(),
22+
severity: severity(),
23+
span: {non_neg_integer(), non_neg_integer()},
24+
labels: [{non_neg_integer(), non_neg_integer()}],
25+
help: String.t() | nil
26+
}
27+
28+
@doc """
29+
Lint source code with oxlint's built-in rules and optional custom rules.
30+
31+
## Options
32+
33+
* `:rules` — map of rule names to severity (`:deny`, `:warn`, `:allow`).
34+
Rule names follow oxlint conventions: `"eqeqeq"`, `"react/no-danger"`,
35+
`"typescript/no-explicit-any"`, etc.
36+
37+
* `:plugins` — list of built-in plugin atoms to enable.
38+
Default: oxlint defaults (eslint correctness rules).
39+
Available: `:react`, `:typescript`, `:unicorn`, `:import`, `:jsdoc`,
40+
`:jest`, `:vitest`, `:jsx_a11y`, `:nextjs`, `:react_perf`, `:promise`,
41+
`:node`, `:vue`, `:oxc`
42+
43+
* `:fix` — compute fix suggestions. Default: `false`
44+
45+
* `:custom_rules` — list of `{module, severity}` tuples for Elixir rules.
46+
Each module must implement the `OXC.Lint.Rule` behaviour.
47+
48+
* `:settings` — arbitrary map passed to custom rule context.
49+
50+
## Examples
51+
52+
# Built-in rules only
53+
{:ok, diags} = OXC.Lint.run("debugger;", "test.js",
54+
rules: %{"no-debugger" => :deny})
55+
56+
# With specific plugins and rules
57+
{:ok, diags} = OXC.Lint.run(source, "app.tsx",
58+
plugins: [:react, :typescript],
59+
rules: %{"no-console" => :warn, "react/no-danger" => :deny}
60+
)
61+
62+
# With custom Elixir rules
63+
{:ok, diags} = OXC.Lint.run(source, "app.ts",
64+
custom_rules: [{MyApp.NoConsoleLog, :warn}]
65+
)
66+
"""
67+
@spec run(String.t(), String.t(), keyword()) :: {:ok, [diagnostic()]} | {:error, [String.t()]}
68+
def run(source, filename, opts \\ []) do
69+
plugins = opts |> Keyword.get(:plugins, []) |> Enum.map(&to_string/1)
70+
fix = Keyword.get(opts, :fix, false)
71+
72+
rules =
73+
opts
74+
|> Keyword.get(:rules, %{})
75+
|> Enum.map(fn {name, severity} -> {to_string(name), severity_to_string(severity)} end)
76+
77+
custom_rules = Keyword.get(opts, :custom_rules, [])
78+
settings = Keyword.get(opts, :settings, %{})
79+
80+
case OXC.Lint.Native.lint(source, filename, plugins, rules, fix) do
81+
{:ok, builtin_diags} ->
82+
custom =
83+
case custom_rules do
84+
[] -> []
85+
rules -> run_custom_rules(rules, source, filename, settings)
86+
end
87+
88+
{:ok, builtin_diags ++ custom}
89+
90+
{:error, errors} ->
91+
{:error, errors}
92+
end
93+
end
94+
95+
defp severity_to_string(:deny), do: "deny"
96+
defp severity_to_string(:warn), do: "warn"
97+
defp severity_to_string(:allow), do: "allow"
98+
99+
defp run_custom_rules(rules, source, filename, settings) do
100+
case OXC.parse(source, filename) do
101+
{:ok, ast} ->
102+
context = %{source: source, filename: filename, settings: settings}
103+
104+
Enum.flat_map(rules, fn {module, severity} ->
105+
meta = module.meta()
106+
107+
module.run(ast, context)
108+
|> Enum.map(fn diag ->
109+
%{
110+
rule: meta.name,
111+
message: diag.message,
112+
severity: severity,
113+
span: Map.get(diag, :span, {0, 0}),
114+
labels: Map.get(diag, :labels, []),
115+
help: Map.get(diag, :help)
116+
}
117+
end)
118+
end)
119+
120+
{:error, _} ->
121+
[]
122+
end
123+
end
124+
end

lib/oxc/lint/native.ex

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
defmodule OXC.Lint.Native do
2+
@moduledoc false
3+
4+
version = Mix.Project.config()[:version]
5+
source_root = Path.expand("../../..", __DIR__)
6+
7+
local_test_build =
8+
Mix.env() == :test and
9+
File.exists?(Path.join(source_root, "test/test_helper.exs")) and
10+
File.dir?(Path.join(source_root, ".git"))
11+
12+
use RustlerPrecompiled,
13+
otp_app: :oxc,
14+
crate: "oxc_lint_nif",
15+
base_url: "https://github.com/elixir-volt/oxc_ex/releases/download/v#{version}",
16+
force_build: local_test_build or System.get_env("OXC_EX_BUILD") in ["1", "true"],
17+
targets: ~w(
18+
aarch64-apple-darwin
19+
aarch64-unknown-linux-gnu
20+
x86_64-apple-darwin
21+
x86_64-unknown-linux-gnu
22+
),
23+
version: version
24+
25+
@spec lint(String.t(), String.t(), [String.t()], [{String.t(), String.t()}], boolean()) ::
26+
{:ok, [map()]} | {:error, [String.t()]}
27+
def lint(_source, _filename, _plugins, _rules, _fix), do: :erlang.nif_error(:nif_not_loaded)
28+
end

lib/oxc/lint/rule.ex

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
defmodule OXC.Lint.Rule do
2+
@moduledoc """
3+
Behaviour for custom lint rules in Elixir.
4+
5+
Rules receive the parsed ESTree AST (from `OXC.parse/2`) and return
6+
diagnostics. Use `OXC.walk/2`, `OXC.collect/2`, or `OXC.postwalk/3`
7+
for traversal.
8+
9+
## Example
10+
11+
defmodule MyApp.NoConsoleLog do
12+
@behaviour OXC.Lint.Rule
13+
14+
@impl true
15+
def meta do
16+
%{
17+
name: "my-app/no-console-log",
18+
description: "Disallow console.log in production code",
19+
category: :restriction,
20+
fixable: false
21+
}
22+
end
23+
24+
@impl true
25+
def run(ast, _context) do
26+
OXC.collect(ast, fn
27+
%{
28+
type: :call_expression,
29+
callee: %{
30+
type: :member_expression,
31+
object: %{type: :identifier, name: "console"},
32+
property: %{type: :identifier, name: "log"}
33+
},
34+
start: start,
35+
end: stop
36+
} ->
37+
{:keep, %{span: {start, stop}, message: "Unexpected console.log"}}
38+
39+
_ ->
40+
:skip
41+
end)
42+
end
43+
end
44+
"""
45+
46+
@type meta :: %{
47+
name: String.t(),
48+
description: String.t(),
49+
category:
50+
:correctness | :suspicious | :pedantic | :perf | :style | :restriction | :nursery,
51+
fixable: boolean()
52+
}
53+
54+
@type context :: %{
55+
source: String.t(),
56+
filename: String.t(),
57+
settings: map()
58+
}
59+
60+
@type diagnostic :: %{
61+
required(:span) => {non_neg_integer(), non_neg_integer()},
62+
required(:message) => String.t(),
63+
optional(:help) => String.t() | nil,
64+
optional(:labels) => [{non_neg_integer(), non_neg_integer()}],
65+
optional(:fix) => String.t() | nil
66+
}
67+
68+
@callback meta() :: meta()
69+
@callback run(ast :: map(), context :: context()) :: [diagnostic()]
70+
end

mix.exs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
defmodule OXC.MixProject do
22
use Mix.Project
33

4-
@version "0.7.2"
4+
@version "0.8.0"
55
@source_url "https://github.com/elixir-volt/oxc_ex"
66

77
def project do
@@ -37,7 +37,7 @@ defmodule OXC.MixProject do
3737
"OXC" => "https://oxc.rs"
3838
},
3939
files:
40-
~w(lib native/oxc_ex_nif/src native/oxc_ex_nif/Cargo.toml Cargo.toml Cargo.lock .formatter.exs mix.exs README.md LICENSE checksum-*.exs)
40+
~w(lib native/oxc_ex_nif/src native/oxc_ex_nif/Cargo.toml native/oxc_lint_nif/src native/oxc_lint_nif/Cargo.toml native/oxc_lint_nif/Cargo.lock Cargo.toml Cargo.lock .formatter.exs mix.exs README.md LICENSE checksum-*.exs)
4141
]
4242
end
4343

native/oxc_ex_nif/src/parse.rs

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,8 @@ impl TransformOutput {
8282
}),
8383
)
8484
.encode(env),
85-
TransformOutput::Error(errors) => {
86-
crate::error::error_to_term(env, errors).unwrap_or_else(|_| atoms::error().encode(env))
87-
}
85+
TransformOutput::Error(errors) => crate::error::error_to_term(env, errors)
86+
.unwrap_or_else(|_| atoms::error().encode(env)),
8887
}
8988
}
9089
}
@@ -163,10 +162,7 @@ pub fn parse<'a>(env: Env<'a>, source: &str, filename: &str) -> NifResult<Term<'
163162
let json: Value = match deserializer.into_iter().next() {
164163
Some(Ok(v)) => v,
165164
Some(Err(e)) => {
166-
return error_to_term(
167-
env,
168-
&[format!("Failed to deserialize ESTree JSON: {e}")],
169-
)
165+
return error_to_term(env, &[format!("Failed to deserialize ESTree JSON: {e}")])
170166
}
171167
None => return error_to_term(env, &["Empty ESTree JSON output".to_string()]),
172168
};

native/oxc_lint_nif/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/target

0 commit comments

Comments
 (0)