~kris/suckless

dwm

dwm/gen-rules.py -rw-r--r-- 4.2 KiB
9d111695 — Kris Yotam chore: sync local state after restore (push updates, no pull) a month ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
#!/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))