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
91 changes: 91 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,99 @@ Notable changes. Format loosely follows [Keep a Changelog](https://keepachangelo

## [Unreleased]

## [0.3.0] — 2026-08-28

### Added

- **`check --fix`** applies the corrections that have exactly one right
answer — wrapping a bare-identifier string value in quotes
(`possible_missing_quotes`), adding `()` to a referenced-but-uncalled
dispatcher factory (`uncalled_dispatcher`) — directly to the file, then
re-checks. Applied as targeted character-offset patches against the
original source (new `hyprvalidate/fixer.py`), never a full
AST-regenerate-and-reprint, so everything outside the flagged span —
comments, formatting, unrelated code — survives untouched; a user's
hand-written config isn't `convert`'s freshly-generated output. Findings
with no single correct fix (unknown symbols/keys, type/arity mismatches,
duplicate binds) are still reported, never guessed at. Before writing,
the patched source is run through the luac gate and re-checked — same
discipline `convert` already applies to its own output — so a fix that
would produce invalid Lua, or reveal a fresh problem (e.g. a dispatcher
that turns out to need arguments once actually called), is surfaced
instead of silently written over.

- **A ready-to-copy GitHub Actions workflow** for validating a dotfiles
repo's Lua config in CI, with no Hyprland installation needed:
[`examples/ci/validate-hyprland-lua.yml`](examples/ci/validate-hyprland-lua.yml).
Pins the installed `hyprvalidate` version and its `schema.json` snapshot
to the same git tag, so the tool and the schema it validates against are
always in sync. Also fixes the README's own CI section, which had gone
stale: it referenced `pipx install git+https://...` from before the PyPI
release, and ran `hyprvalidate check ... --stub schema.json` without ever
saying where that file comes from. Two jobs: a read-only `check` gate on
pull requests (safe for fork PRs — no write access needed), and a
`check --fix` job on push that commits and pushes any mechanical fixes
straight back onto the branch, still failing the job if something
unfixable remains. Guards against re-triggering itself on its own
fix commit.

- **`check` now catches unquoted-string config values that would silently
evaluate to `nil`** — e.g. `accel_profile = flat` instead of
`accel_profile = "flat"`. This is the exact real-world bug in
[hyprwm/Hyprland#15727](https://github.com/hyprwm/Hyprland/discussions/15727):
a user's config migration dropped the quotes, `accel_profile` silently
became nothing, and their mouse sensitivity changed — found only because
a stranger happened to read their config on GitHub. The check fires only
when the bare identifier is never assigned anywhere in the file (as a
local, global, for-loop variable, or function parameter) — an identifier
that genuinely is a variable reference to a defined value is left alone,
same conservative stance as the rest of this module. New finding kind
`possible_missing_quotes`, checked in both `hl.config` blocks and
spec-table arguments (`hl.monitor`, `hl.device`, etc).

- **`check` now catches two error classes Hyprland's own issue
[hyprwm/Hyprland#15871](https://github.com/hyprwm/Hyprland/issues/15871)
("Improve lua error reporting") calls out as currently undetected:**
- **Uncalled dispatcher factory reference** — `hl.bind("SUPER + Q",
hl.dsp.window.close)` (missing the trailing `()`). This type-checks
under the stub's `HL.Dispatcher|function` union, since a bare function
reference *is* a `function` — nothing previously caught it. New finding
kind `uncalled_dispatcher`.
- **Duplicate key binds** — the same key combo bound more than once
(`hl.bind("SUPER + G", ...)` appearing twice), silently shadowing one
of the two at runtime. Resolves string-literal `keys` arguments,
including `..`-concatenation of only string literals; a `keys`
expression built from a variable (the common `mainMod .. " + Q"`
pattern) can't be resolved statically and is skipped rather than
guessed at. New finding kind `duplicate_bind`. Caught four genuine,
previously-undetected duplicate binds in this project's own vendored
real-world test fixture (`$mainMod+G/M/V` and `$mainMod+ALT+space`,
each bound twice to different actions).

Both checks are derived entirely from the schema (no hand-maintained
per-dispatcher data) — consistent with this project's approach of never
hardcoding what the stub can tell it directly. A third case from the
same issue, arity-checking `dsp.*` dispatcher-builder calls (e.g.
`hl.dsp.exec_raw("...", {float = true})`), is **not** implemented: the
stub types every `dsp.*` factory as untyped `fun(...)` with no argument
count at all, so checking it would require a separately-maintained table
of per-dispatcher arities — the exact drift risk this project exists to
avoid (see `docs/COMPARISON.md`).

### Fixed

- **`packaging/PKGBUILD` was pinned to `pkgver=0.1.0`** even after the
0.2.0 release — stale since that release, unrelated to anything else in
this changelog entry, noticed and fixed while bumping the version here.

- **`check` crashed with `AttributeError` on a config containing a
colon-method definition** (`function T:method() end`), found while
building the checks above. `Method` (that definition node) was grouped
with `Call` for call-detection, but has no `.func` attribute — only
`Invoke` (an actual `obj:bar()` call) does. Real Hyprland configs don't
define OOP-style methods, so this never surfaced in practice, but the
checker shouldn't crash on any syntactically valid Lua.

- **`convert` emitted empty `hl.config({ <block> = {} })` tables** for blocks
whose every key was TODO'd out (or that were empty in the source). These
set nothing, and when the block name itself was unresolvable — e.g. a
Expand Down
44 changes: 41 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,11 @@ hyprvalidate check ~/.config/hypr # a whole directory
```

Catches unknown dispatchers, invalid config keys, wrong value types, bad call
arity, and invalid fields inside a spec table:
arity, invalid fields inside a spec table, dispatcher factories referenced
without being called (`hl.dsp.window.close` instead of
`hl.dsp.window.close()`), duplicate key binds, and unquoted string values
that would silently evaluate to `nil` (`accel_profile = flat` instead of
`accel_profile = "flat"`):

```console
$ hyprvalidate check hyprland.lua
Expand All @@ -134,13 +138,47 @@ hyprland.lua:47: [type_mismatch] 'animations.enabled' expects boolean, got strin
**Exit codes:** `0` clean · `1` schema findings · `2` invalid Lua, unparseable
input, or a missing schema. Scriptable in CI.

`--fix` applies the fixes that have exactly one correct answer — adding
quotes, adding `()` to a bare dispatcher reference — in place, then
re-checks:

```console
$ hyprvalidate check hyprland.lua --fix
hyprland.lua: fixed 2 issue(s)
[uncalled_dispatcher] hl.bind: argument 2 ('hl.dsp.window.close') is a dispatcher factory reference, not a call - did you mean 'hl.dsp.window.close(...)'?
[possible_missing_quotes] 'input.accel_profile' is set to the bare identifier 'flat', which isn't defined anywhere in this file and would evaluate to nil - did you mean the string "flat"?

fixed 2 issue(s).
```

Findings with no single correct fix — an unknown dispatcher/config key
(which real name was meant?), a type or arity mismatch, two binds fighting
over the same key combo — are still reported, never guessed at. Same as
`convert`: a fix that would produce invalid Lua, or reveal a fresh problem
(e.g. a dispatcher that turns out to need arguments once it's actually
called), is surfaced, not silently written over.

### Validating dotfiles in CI (no Hyprland needed)

`--stub` accepts a `schema.json` snapshot as well as the live stub, so you can
check a dotfiles repo on a runner that has no compositor installed:
check a dotfiles repo on a runner that has no compositor installed. A ready
GitHub Actions workflow is at
[`examples/ci/validate-hyprland-lua.yml`](examples/ci/validate-hyprland-lua.yml);
copy it into `.github/workflows/` and point `CONFIG_PATH` at your config. It's
two jobs, split by trigger:

- **on a pull request** — read-only `check`, fails the PR on findings. Works
the same for a same-repo branch or a fork; no write access needed, no
surprise commits landing mid-review.
- **on a push** — `check --fix`, and if that changed anything, commits and
pushes the fix straight back onto the branch. Safe to automate because the
two things it fixes (missing quotes, uncalled dispatcher factories) have
exactly one correct answer; anything else it finds still fails the job
instead of being pushed.

```yaml
- run: pipx install git+https://github.com/Paritsingla7/hyprvalidate.git
- run: pipx install hyprvalidate
- run: curl -fsSLo schema.json https://raw.githubusercontent.com/Paritsingla7/hyprvalidate/v0.3.0/schema.json
- run: hyprvalidate check hypr/ --stub schema.json
```

Expand Down
118 changes: 118 additions & 0 deletions examples/ci/validate-hyprland-lua.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# Validate a Hyprland Lua config in CI, without installing Hyprland itself -
# and auto-fix what can be safely auto-fixed.
#
# Copy this file into your dotfiles repo as .github/workflows/validate-hyprland-lua.yml
# and adjust CONFIG_PATH below to point at your config. That's the whole setup.
#
# The hyprvalidate version and the schema.json snapshot are pinned to the
# same git tag on purpose - they're extracted from the same Hyprland stub,
# so validating against a schema.json from a different version than the
# installed tool would silently check your config against dispatchers/config
# keys that may not match what that tool version actually understands.
# Bump HYPRVALIDATE_VERSION when you want a newer schema; the two always
# move together.
#
# Two jobs, deliberately split by trigger:
# - `validate` (pull_request): read-only. Never pushes anything, so it
# works the same whether the PR is from a branch in this repo or a
# fork - no write permissions needed, no surprise commits landing on a
# PR mid-review.
# - `validate-and-autofix` (push): runs `check --fix`. The two fixes
# hyprvalidate applies (missing quotes, uncalled dispatcher factories)
# have exactly one correct answer, so committing them automatically is
# safe - anything it *can't* safely fix (duplicate binds, unknown
# names, type mismatches) is left reported and still fails the job.
# Needs `contents: write` to push the fix commit back onto the branch.

name: Validate Hyprland Lua config

on:
push:
paths:
- "**/*.lua"
pull_request:
paths:
- "**/*.lua"

env:
HYPRVALIDATE_VERSION: "0.3.0"
# Path to your config - a single .lua file or a directory (e.g. from
# `hyprvalidate convert --split`). Every *.lua file in a directory is
# checked.
CONFIG_PATH: "hypr"

jobs:
validate:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.x"

- name: Install hyprvalidate
run: pipx install "hyprvalidate==${HYPRVALIDATE_VERSION}"

- name: Fetch schema snapshot for this hyprvalidate version
run: >
curl -fsSL
"https://raw.githubusercontent.com/Paritsingla7/hyprvalidate/v${HYPRVALIDATE_VERSION}/schema.json"
-o schema.json

- name: Check config against Hyprland's real API
run: hyprvalidate check "${CONFIG_PATH}" --stub schema.json

validate-and-autofix:
if: >
github.event_name == 'push' &&
!contains(github.event.head_commit.message, '[hyprvalidate-autofix]')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.ref_name }}

- uses: actions/setup-python@v5
with:
python-version: "3.x"

- name: Install hyprvalidate
run: pipx install "hyprvalidate==${HYPRVALIDATE_VERSION}"

- name: Fetch schema snapshot for this hyprvalidate version
run: >
curl -fsSL
"https://raw.githubusercontent.com/Paritsingla7/hyprvalidate/v${HYPRVALIDATE_VERSION}/schema.json"
-o schema.json

# continue-on-error: unfixable findings make this exit non-zero, but
# the commit step below still needs to run so any *mechanical* fixes
# get pushed even when something else still needs a human. The job
# is failed explicitly in the last step instead.
- name: Fix what can be safely fixed
id: fix
continue-on-error: true
run: hyprvalidate check "${CONFIG_PATH}" --stub schema.json --fix

- name: Commit and push any fixes
run: |
if ! git diff --quiet -- "${CONFIG_PATH}"; then
git config user.name "hyprvalidate[bot]"
git config user.email "actions@users.noreply.github.com"
git add -- "${CONFIG_PATH}"
git commit -m "hyprvalidate: auto-fix config [hyprvalidate-autofix]

Missing quotes and/or uncalled dispatcher factories - the two
corrections hyprvalidate --fix applies automatically because
they have exactly one correct answer. Anything else it found is
reported in this job's log, not guessed at."
git push origin "HEAD:${{ github.ref_name }}"
fi

- name: Fail if unfixable findings remain
if: steps.fix.outcome == 'failure'
run: exit 1
Loading
Loading