#!/usr/bin/env python3
"""Compile rules.json into C RULE(...) macros for dwm.
Tag numbers are 1-based (tag 1 .. NUMTAGS). Monitor is intentionally omitted:
windows land on selmon (wherever you focused / put the pointer).
Usage:
./gen-rules.py [rules.json] > rules.gen.h
./gen-rules.py rules.json rules.gen.h
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
# Fields we never emit from JSON (per design).
BLOCKED = frozenset({"monitor", "mon"})
def c_string(s: str) -> str:
"""Escape a Python str as a C string literal."""
out = []
for ch in s:
if ch == "\\":
out.append("\\\\")
elif ch == '"':
out.append('\\"')
elif ch == "\n":
out.append("\\n")
elif ch == "\t":
out.append("\\t")
elif ord(ch) < 32:
out.append(f"\\x{ord(ch):02x}")
else:
out.append(ch)
return '"' + "".join(out) + '"'
def tag_mask(tag: int) -> str:
if not isinstance(tag, int) or tag < 1:
raise ValueError(f"tag must be integer >= 1, got {tag!r}")
# tag 1 => 1 << 0, tag 9 => 1 << 8
return f"1 << {tag - 1}"
def rule_to_c(rule: dict, index: int) -> str:
if not isinstance(rule, dict):
raise ValueError(f"rule[{index}] must be an object")
for bad in BLOCKED:
if bad in rule:
raise ValueError(
f"rule[{index}]: '{bad}' is not allowed "
"(select the monitor yourself before launching)"
)
parts: list[str] = []
if "class" in rule and rule["class"] is not None:
parts.append(f".class = {c_string(str(rule['class']))}")
if "instance" in rule and rule["instance"] is not None:
parts.append(f".instance = {c_string(str(rule['instance']))}")
if "title" in rule and rule["title"] is not None:
parts.append(f".title = {c_string(str(rule['title']))}")
if "wintype" in rule and rule["wintype"] is not None:
# Expect short name like DIALOG, or full if already WTYPE-prefixed handling
wt = str(rule["wintype"])
if wt.startswith("WTYPE ") or wt.startswith("_NET_"):
parts.append(f".wintype = {c_string(wt)}")
else:
parts.append(f'.wintype = WTYPE "{wt}"')
if "tag" in rule and rule["tag"] is not None:
parts.append(f".tags = {tag_mask(int(rule['tag']))}")
if rule.get("floating"):
parts.append(".isfloating = 1")
if rule.get("terminal"):
parts.append(".isterminal = 1")
if rule.get("noswallow"):
parts.append(".noswallow = 1")
if "floatpos" in rule and rule["floatpos"] is not None:
parts.append(f".floatpos = {c_string(str(rule['floatpos']))}")
if not parts:
raise ValueError(f"rule[{index}]: empty rule (need class/instance/title/tag/...)")
comment = rule.get("comment")
line = "RULE(" + ", ".join(parts) + ")"
if comment:
# single-line C comment only
safe = str(comment).replace("*/", "* /").replace("\n", " ")
return f"/* {safe} */\n{line}"
return line
def generate(rules_path: Path) -> str:
data = json.loads(rules_path.read_text(encoding="utf-8"))
if not isinstance(data, list):
raise ValueError("rules.json must be a JSON array")
lines = [
"/* Generated by gen-rules.py from rules.json — do not edit by hand. */",
"/* Edit rules.json, then rebuild dwm. Monitor is never set (selmon). */",
"",
]
for i, rule in enumerate(data):
lines.append(rule_to_c(rule, i))
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def main(argv: list[str]) -> int:
if len(argv) >= 2 and argv[1] in ("-h", "--help"):
print(__doc__.strip())
return 0
src = Path(argv[1] if len(argv) >= 2 else "rules.json")
if not src.is_file():
print(f"gen-rules: not found: {src}", file=sys.stderr)
return 1
try:
text = generate(src)
except (json.JSONDecodeError, ValueError, TypeError) as e:
print(f"gen-rules: {e}", file=sys.stderr)
return 1
if len(argv) >= 3:
out = Path(argv[2])
out.write_text(text, encoding="utf-8")
else:
sys.stdout.write(text)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))