#!/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))