Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .reach.exs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ logic = [
"Volt.HTMLEntry",
"Volt.JS.AST",
"Volt.JS.Check",
"Volt.JS.Lint.Config",
"Volt.JS.Extensions",
"Volt.JS.Format",
"Volt.JS.Discovery",
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

### Added

- Support root-relative, ordered lint overrides for rules, environments, and globals in both lint commands, including per-component TypeScript rule selection in type-aware checks ([#35](https://github.com/elixir-volt/volt/issues/35)).
- Add `Volt.build/1` as the library API for complete frontend builds with one typed result and merged manifest for scripts, styles, chunks, and emitted assets.
- Isolate Tailwind scanner and CSS state by profile and stylesheet root while sharing one supervised compiler runtime.
- Allow Tailwind roots to configure their logical output name, development URL, and source globs.
Expand All @@ -22,6 +23,7 @@

### Fixed

- Apply configured environments and globals in `mix volt.lint`, matching `mix volt.js.check`.
- Allow JavaScript formatting and checks when the bundle format is configured as `:esm`, without treating the format atom as formatter options ([#36](https://github.com/elixir-volt/volt/pull/36), fixes [#34](https://github.com/elixir-volt/volt/issues/34)).
- Handle filesystem watcher events for the watched root itself when normalizing macOS path aliases.
- Apply Vue scoped styles without capturing component-module bindings such as `Object`.
Expand Down
36 changes: 36 additions & 0 deletions guides/features/formatting-and-linting.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,42 @@ config :volt, :lint,

Lint-specific `:root`, `:sources`, and `:ignore` values override the build source set without changing which files the formatter checks. This is useful when canonical fixtures must be linted but retain their original formatting.

### Per-file overrides

Both `mix volt.lint` and `mix volt.js.check` resolve the same per-file settings:

```elixir
config :volt, :lint,
root: ".",
sources: ["assets/**/*.js", "scripts/**/*.js"],
plugins: [:typescript, :unicorn],
env: [:browser],
rules: %{"correctness" => :deny, "unicorn/no-null" => :deny},
overrides: [
%{
files: ["assets/colocated/**/*.js"],
rules: %{"unicorn/filename-case" => :allow}
},
%{
files: ["assets/js/dom.js"],
rules: %{"unicorn/no-null" => :allow}
},
%{
files: ["scripts/**/*.js"],
env: %{browser: false, node: true},
globals: %{"BuildContext" => :readonly}
}
]
```

- `:files` is a non-empty list of globs relative to the lint root (`:root` under `:lint`, or the build root, which defaults to `assets`). Use `**/*.js` for nested files; `*.js` matches only the root level. Brace alternatives such as `**/*.{js,ts}` are supported.
- Overrides change settings for discovered files; they do not add files or change `:sources`/`:ignore`. Already-discovered dotfiles can match override globs.
- All matching overrides apply in declaration order. Later values win for each rule, environment or global; unrelated inherited entries remain intact. Rule severities are `:allow`, `:warn`, or `:deny`.
- Environment lists enable names; maps can enable or disable them. Set an inherited global to `:off` to remove it, or use `:readonly`/`:writable` to set its access.
- Override entries may be maps or keyword lists and support only `:files`, `:rules`, `:env`, and `:globals`. Plugins and custom rules remain run-wide; `mix volt.lint --plugin` retains its precedence over configured plugins.

Type-aware checks group files by their effective TypeScript rules. Vue/Svelte script overrides match the original component path, not the generated virtual filename. Every group retains the complete set of extracted script sources, and diagnostics are mapped back to component paths. Environment/global overrides apply to syntax linting, not to TypeScript's project-level compiler configuration. File discovery and formatting settings are unchanged.

### Custom Rules

Custom lint rules can be written in Elixir using the `OXC.Lint.Rule` behaviour — see the [oxc docs](https://hexdocs.pm/oxc/OXC.Lint.Rule.html).
Expand Down
26 changes: 16 additions & 10 deletions lib/mix/tasks/volt/lint.ex
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,17 @@ defmodule Mix.Tasks.Volt.Lint do
},
custom_rules: [
{MyApp.NoConsoleLog, :warn}
],
overrides: [
%{
files: ["colocated/**/*.js"],
rules: %{"unicorn/filename-case" => :allow}
}
]

Overrides are relative to the lint root and apply in order. They merge
individual `:rules`, `:env`, and `:globals` entries; later matches win.
Both lint commands share these settings. Plugins remain run-wide.
"""
use Mix.Task

Expand Down Expand Up @@ -75,8 +85,7 @@ defmodule Mix.Tasks.Volt.Lint do
cli_plugins -> Enum.map(cli_plugins, &String.to_atom/1)
end

rules = Keyword.get(config, :rules, %{})
custom_rules = Keyword.get(config, :custom_rules, [])
lint_config = Volt.JS.Lint.Config.new(config, Volt.Config.build().root)
fix = Keyword.get(parsed, :fix, false)

files = Volt.JS.Discovery.files(tool: :lint, only: ~w".js .ts .jsx .tsx")
Expand All @@ -85,21 +94,18 @@ defmodule Mix.Tasks.Volt.Lint do
Mix.shell().info("No lintable files found")
:ok
else
results = lint_files(files, plugins, rules, custom_rules, fix)
results = lint_files(files, lint_config, plugins, fix)
print_results(results, files)
end
end

defp lint_files(files, plugins, rules, custom_rules, fix) do
defp lint_files(files, config, plugins, fix) do
Enum.flat_map(files, fn file ->
source = File.read!(file)
options = Volt.JS.Lint.Config.options(config, file)
options = Keyword.merge(options, plugins: plugins, fix: fix)

case OXC.Lint.run(source, file,
plugins: plugins,
rules: rules,
custom_rules: custom_rules,
fix: fix
) do
case OXC.Lint.run(source, file, options) do
{:ok, diags} ->
Enum.map(diags, &Map.put(&1, :file, file))

Expand Down
59 changes: 29 additions & 30 deletions lib/volt/js/check.ex
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,13 @@ defmodule Volt.JS.Check do

def lint(files, opts \\ []) do
config = Application.get_env(:volt, :lint, [])
rules = Keyword.get(config, :rules, %{})
lint_config = Volt.JS.Lint.Config.new(config, Volt.Config.build().root)

if opts[:type_aware] do
ast_lint(Enum.filter(files, &type_aware_file?/1), config, rules) ++
type_aware_lint(files, config, typescript_rules(rules), opts)
ast_lint(Enum.filter(files, &type_aware_file?/1), lint_config) ++
type_aware_lint(files, config, lint_config, opts)
else
ast_lint(files, config, rules)
ast_lint(files, lint_config)
end
end

Expand All @@ -55,50 +55,49 @@ defmodule Volt.JS.Check do
def lint_error_message(message) when is_binary(message), do: message
def lint_error_message(message), do: inspect(message)

defp ast_lint(files, config, rules) do
plugins = Keyword.get(config, :plugins, [:typescript])
custom_rules = Keyword.get(config, :custom_rules, [])
env = Keyword.get(config, :env, [])
globals = Keyword.get(config, :globals, %{})

defp ast_lint(files, config) do
Enum.flat_map(files, fn file ->
source = File.read!(file)
options = Volt.JS.Lint.Config.options(config, file)

case OXC.Lint.run(source, file,
plugins: plugins,
rules: rules,
env: env,
globals: globals,
custom_rules: custom_rules
) do
case OXC.Lint.run(source, file, options) do
{:ok, diagnostics} -> Enum.map(diagnostics, &Map.put(&1, :file, file))
{:error, errors} -> Enum.map(errors, &lint_error(&1, file))
end
end)
end

defp type_aware_lint(files, config, rules, opts) do
defp type_aware_lint(files, config, lint_config, opts) do
{files, source_overrides, source_files} = type_aware_inputs(files, config)

lint_opts =
common_opts =
[
type_aware: true,
type_check: opts[:type_check] == true,
rules: rules,
source_overrides: Map.merge(source_overrides, Keyword.get(config, :source_overrides, %{}))
] ++ type_aware_options(config)

case run_type_aware_lint(files, lint_opts) do
{:ok, diagnostics} ->
Enum.map(diagnostics, fn diagnostic ->
diagnostic
|> restore_sfc_file(source_files)
|> promote_type_check_diagnostic(opts)
end)
files
|> Enum.group_by(fn file ->
original = Map.get(source_files, Path.expand(file), file)

{:error, errors} ->
Enum.map(errors, &lint_error/1)
end
lint_config
|> Volt.JS.Lint.Config.options(original)
|> Keyword.fetch!(:rules)
|> typescript_rules()
end)
|> Enum.sort_by(fn {rules, _files} -> rules end)
|> Enum.flat_map(fn {rules, batch} ->
case run_type_aware_lint(batch, Keyword.put(common_opts, :rules, rules)) do
{:ok, diagnostics} ->
Enum.map(diagnostics, fn diagnostic ->
diagnostic |> restore_sfc_file(source_files) |> promote_type_check_diagnostic(opts)
end)

{:error, errors} ->
Enum.map(errors, &lint_error/1)
end
end)
end

defp type_aware_inputs(files, config) do
Expand Down
108 changes: 108 additions & 0 deletions lib/volt/js/lint/config.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
defmodule Volt.JS.Lint.Config do
@moduledoc """
Resolves lint options for a source file without reading the filesystem.

Override globs are relative to the lint discovery root. Every matching override
is applied in declaration order; later entries replace individual rules,
environments and globals, not their entire maps. Environment lists enable
names; environment maps can also disable inherited names.
"""

@enforce_keys [:root, :options, :overrides]
defstruct [:root, :options, :overrides]

@type t :: %__MODULE__{
root: String.t(),
options: keyword(),
overrides: [{[GlobEx.t()], keyword()}]
}

@doc "Compiles a lint configuration, using the build root unless lint sets its own root."
@spec new(keyword(), String.t()) :: t()
def new(config, root) do
options =
[plugins: [:typescript], custom_rules: [], fix: false]
|> Keyword.merge(Keyword.take(config, [:plugins, :custom_rules, :fix]))
|> Keyword.merge(maps(config))

overrides =
Enum.map(Keyword.get(config, :overrides, []), fn override ->
override = Map.new(override)
unknown = Map.keys(override) -- [:files, :rules, :env, :globals]

if unknown != [] do
raise ArgumentError, "unsupported lint override keys: #{inspect(unknown)}"
end

globs =
case Map.fetch(override, :files) do
{:ok, [_ | _] = files} ->
Enum.map(files, &compile_glob!/1)

_ ->
raise ArgumentError,
"lint override :files must be a non-empty list of relative globs"
end

{globs, maps(Map.to_list(override))}
end)

%__MODULE__{
root: Path.expand(Keyword.get(config, :root, root)),
options: options,
overrides: overrides
}
end

@doc "Returns effective OXC syntax-lint options for the original source path."
@spec options(t(), String.t()) :: keyword()
def options(%__MODULE__{} = config, file) do
relative = file |> Path.expand() |> Path.relative_to(config.root)

if Path.type(relative) == :relative and List.first(Path.split(relative)) != ".." do
relative = relative |> Path.split() |> Enum.join("/")

Enum.reduce(config.overrides, config.options, &apply_override(&1, relative, &2))
else
config.options
end
end

defp apply_override({globs, options}, relative, inherited) do
if Enum.any?(globs, &GlobEx.match?(&1, relative)) do
Keyword.merge(inherited, options, fn _key, base, override -> Map.merge(base, override) end)
else
inherited
end
end

defp maps(config) do
[
rules: names(Keyword.get(config, :rules, %{})),
globals: names(Keyword.get(config, :globals, %{})),
env: environments(Keyword.get(config, :env, []))
]
end

defp names(map), do: Map.new(map, fn {name, value} -> {to_string(name), value} end)

defp environments(env) when is_list(env), do: Map.new(env, &{to_string(&1), true})
defp environments(env) when is_map(env), do: names(env)

defp compile_glob!(pattern) when is_binary(pattern) do
if Path.type(pattern) != :relative or ".." in Path.split(pattern) do
raise ArgumentError,
"lint override globs must stay relative to the lint root: #{inspect(pattern)}"
end

pattern
|> Path.split()
|> Enum.reject(&(&1 == "."))
|> Enum.join("/")
|> GlobEx.compile!(match_dot: true)
end

defp compile_glob!(pattern) do
raise ArgumentError, "lint override glob must be a string, got: #{inspect(pattern)}"
end
end
1 change: 1 addition & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ defmodule Volt.MixProject do
Volt.JS.AST,
Volt.JS.Extensions,
Volt.JS.Discovery,
Volt.JS.Lint.Config,
Volt.JS.ImportExtractor,
Volt.JS.ImportExtractor.Result,
Volt.JS.Package,
Expand Down
Loading
Loading