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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@

Notable changes. Format loosely follows [Keep a Changelog](https://keepachangelog.com/).

## [Unreleased]

### Fixed

- **`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
per-device `device:epic-mouse-v1 { ... }` block, which Hyprland's own
default config ships as a commented example — the leftover table tripped
`convert`'s own post-convert validation, reporting an issue on a file the
converter had in fact handled exactly as designed. The explanatory
`-- TODO(hyprvalidate convert):` comment is unchanged; only the dead
table is gone. Found running the converter against a real config from
the wild.

## [0.2.0] — 2026-08-17

### Added
Expand Down
22 changes: 20 additions & 2 deletions hyprvalidate/converter/mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,12 @@ def _flatten_config_block(schema, block: Block, prefix: str, notes: list[TodoNot
_set_nested(result, d.key, value)
for sub in block.blocks:
sub_path = f"{prefix}.{sub.name}" if prefix else sub.name
result[sub.name] = _flatten_config_block(schema, sub, sub_path, notes)
sub_result = _flatten_config_block(schema, sub, sub_path, notes)
# A sub-block whose every key was TODO'd out contributes nothing but
# an empty table. The note already records what couldn't be
# converted, so don't also emit `touchpad = {}`.
if sub_result:
result[sub.name] = sub_result
return result


Expand Down Expand Up @@ -445,8 +450,21 @@ def _convert_items(schema, hf: HyprlangFile) -> list[ConvertedItem]:
module = _spec_block_module(stmt.name)
else:
inner = _flatten_config_block(schema, stmt, stmt.name, notes)
call = hlcall("hl.config", {stmt.name: inner})
module = _config_section_module(stmt.name)
if not inner:
# Nothing survived conversion - either the source block
# was empty, or every key in it was TODO'd out. Emitting
# `hl.config({ <name> = {} })` sets nothing, and when the
# block name itself is unresolvable (e.g. a per-device
# `device:foo` block) it's output that fails our own
# post-convert checker. Same call as the plugin case
# above: keep the notes, drop the dead table.
if notes:
items.append(ConvertedItem(
None, _combine_notes(notes), module, stmt.line
))
continue
call = hlcall("hl.config", {stmt.name: inner})
items.append(ConvertedItem(call, _combine_notes(notes), module, stmt.line))
continue

Expand Down
2 changes: 1 addition & 1 deletion site/bundle.js

Large diffs are not rendered by default.

36 changes: 36 additions & 0 deletions tests/test_converter_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,42 @@ def test_real_config_conversion_checker_findings_are_only_the_known_joke_value()
assert "animations.enabled" in findings[0].message


def test_fully_todod_block_emits_no_empty_config_table():
"""A per-device block has an unresolvable name, so every key in it is
unknown and gets TODO'd. The leftover
`hl.config({ ["device:epic-mouse-v1"] = {} })` set nothing AND tripped
the converter's own post-convert checker - reporting an issue on a file
it had handled exactly as designed. Found converting a real config from
the wild; Hyprland's own default template ships this block as an
example, so it's widespread."""
schema = _schema()
hf = parse("device:epic-mouse-v1 {\n sensitivity = -0.5\n}\n")
out = convert(schema, hf)
assert "hl.config" not in out
assert "TODO(hyprvalidate convert)" in out
assert "device:epic-mouse-v1" in out
# The point of the fix: the converter's own output validates clean.
assert checker.check(schema, reader.parse(out)) == []


def test_empty_source_block_emits_nothing():
"""`misc {}` with only a comment inside sets nothing - don't emit a
no-op table for it either."""
schema = _schema()
out = convert(schema, parse("misc {\n # just a comment\n}\n"))
assert out.strip() == ""


def test_partially_todod_block_still_emits_what_resolved():
"""Pruning must only drop empty leftovers, never keys that converted."""
schema = _schema()
hf = parse("general {\n gaps_in = 4\n not_a_real_key = 1\n}\n")
out = convert(schema, hf)
assert "gaps_in = 4" in out
assert "not_a_real_key" in out # kept as a TODO
assert "hl.config" in out


if __name__ == "__main__":
for name, fn in list(globals().items()):
if name.startswith("test_"):
Expand Down
Loading