Hyprland/meta/generateLuaStubs.py
Vaxry 1681bea42d
config/lua: add simple layout API (#14258)
Adds a simple layout lua api
2026-05-06 13:56:12 +01:00

805 lines
27 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Iterable
@dataclass
class ApiNode:
methods: set[str] = field(default_factory=set)
children: dict[str, "ApiNode"] = field(default_factory=dict)
@dataclass
class ObjectClass:
name: str
methods: set[str] = field(default_factory=set)
fields: dict[str, str] = field(default_factory=dict)
def read_text(path: Path) -> str:
return path.read_text(encoding="utf-8")
def find_matching_brace(text: str, open_brace_idx: int) -> int:
depth = 0
in_string = False
string_char = ""
escaped = False
for i in range(open_brace_idx, len(text)):
c = text[i]
if in_string:
if escaped:
escaped = False
continue
if c == "\\":
escaped = True
continue
if c == string_char:
in_string = False
continue
if c in ('"', "'"):
in_string = True
string_char = c
continue
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return i
raise ValueError("Unbalanced braces while parsing C++ source")
def extract_function_bodies(source: str, header_pattern: re.Pattern[str]) -> list[tuple[re.Match[str], str]]:
out: list[tuple[re.Match[str], str]] = []
for match in header_pattern.finditer(source):
open_idx = source.find("{", match.end() - 1)
if open_idx < 0:
continue
close_idx = find_matching_brace(source, open_idx)
out.append((match, source[open_idx + 1 : close_idx]))
return out
def merge_node(dst: ApiNode, src: ApiNode) -> None:
dst.methods |= src.methods
for key, child in src.children.items():
if key not in dst.children:
dst.children[key] = child
else:
merge_node(dst.children[key], child)
def parse_binding_tree(root: Path) -> tuple[ApiNode, set[str]]:
lua_dir = root / "src/config/lua"
root_node = ApiNode()
callable_namespaces: set[str] = set()
register_header = re.compile(
r"void\s+(?:(?:Config::Lua::Bindings::)?Internal::)?register\w+Bindings\s*\([^)]*\)\s*\{", re.MULTILINE
)
set_fn = re.compile(r'(?:Internal::)?set(?:Mgr)?Fn\(\s*L\s*,(?:\s*mgr\s*,)?\s*"([^"]+)"\s*,')
set_field = re.compile(r'lua_setfield\(L,\s*-2,\s*"([^"]+)"\s*\);')
for cpp in sorted(lua_dir.rglob("*.cpp")):
source = read_text(cpp)
for _, body in extract_function_bodies(source, register_header):
local_root = ApiNode()
stack: list[ApiNode] = [local_root]
if re.search(
r'lua_setfield\(L,\s*-2,\s*"__call"\s*\);.*?lua_setfield\(L,\s*-2,\s*"([^"]+)"\s*\);',
body,
flags=re.DOTALL,
):
for ns in re.findall(
r'lua_setfield\(L,\s*-2,\s*"__call"\s*\);.*?lua_setfield\(L,\s*-2,\s*"([^"]+)"\s*\);',
body,
flags=re.DOTALL,
):
callable_namespaces.add(ns)
for raw_line in body.splitlines():
line = raw_line.strip()
if not line:
continue
if "lua_newtable(L)" in line:
stack.append(ApiNode())
continue
m = set_fn.search(line)
if m:
stack[-1].methods.add(m.group(1))
continue
if "lua_setmetatable(L" in line:
if len(stack) > 1:
stack.pop()
continue
m = set_field.search(line)
if m:
field_name = m.group(1)
if field_name == "__call":
continue
if len(stack) > 1:
node = stack.pop()
if field_name in stack[-1].children:
merge_node(stack[-1].children[field_name], node)
else:
stack[-1].children[field_name] = node
merge_node(root_node, local_root)
return root_node, callable_namespaces
def parse_object_classes(root: Path) -> dict[str, ObjectClass]:
objects_dir = root / "src/config/lua/objects"
mt_regex = re.compile(r'static constexpr const char\* MT = "([^"]+)";')
index_header = re.compile(r"static int\s+\w*Index\s*\(lua_State\* L\)\s*\{", re.MULTILINE)
cond_regex = re.compile(r"(?:if|else\s+if)\s*\(([^)]*\bkey\b[^)]*)\)")
push_class_regex = re.compile(r"Objects::CLua([A-Za-z0-9_]+)::push")
out: dict[str, ObjectClass] = {}
for cpp in sorted(objects_dir.glob("*.cpp")):
source = read_text(cpp)
mt_match = mt_regex.search(source)
if not mt_match:
continue
mt_name = mt_match.group(1)
if not mt_name.startswith("HL."):
continue
class_name = mt_name
obj = ObjectClass(name=class_name)
bodies = extract_function_bodies(source, index_header)
if not bodies:
out[class_name] = obj
continue
body = bodies[0][1]
cond_matches = list(cond_regex.finditer(body))
for i, cond in enumerate(cond_matches):
start = cond.start()
end = cond_matches[i + 1].start() if i + 1 < len(cond_matches) else len(body)
segment = body[start:end]
keys = re.findall(r'"([^"]+)"', cond.group(1))
if not keys:
continue
is_method = "lua_pushcfunction" in segment
if is_method:
for key in keys:
obj.methods.add(key)
continue
inferred_types: set[str] = set()
if "lua_pushboolean" in segment:
inferred_types.add("boolean")
if "lua_pushstring" in segment or "lua_pushfstring" in segment:
inferred_types.add("string")
if "lua_pushinteger" in segment:
inferred_types.add("integer")
if "lua_pushnumber" in segment:
inferred_types.add("number")
if "lua_newtable" in segment:
inferred_types.add("table")
if "lua_pushnil" in segment:
inferred_types.add("nil")
for pushed in push_class_regex.findall(segment):
inferred_types.add(f"HL.{pushed}")
if not inferred_types:
type_str = "any"
else:
ordered = sorted(inferred_types, key=lambda t: (t == "nil", t))
type_str = "|".join(ordered)
for key in keys:
if key in obj.fields:
existing = set(obj.fields[key].split("|"))
merged = existing | set(type_str.split("|"))
obj.fields[key] = "|".join(sorted(merged, key=lambda t: (t == "nil", t)))
else:
obj.fields[key] = type_str
out[class_name] = obj
return out
def lua_type_from_config_ctor(ctor: str) -> str:
mapping = {
"CLuaConfigBool": "boolean",
"CLuaConfigInt": "integer|boolean",
"CLuaConfigFloat": "number|boolean",
"CLuaConfigString": "string",
"CLuaConfigColor": "string",
"CLuaConfigVec2": "HL.Vec2Like",
"CLuaConfigCssGap": "integer|HL.CssGap",
"CLuaConfigFontWeight": "integer|string",
"CLuaConfigGradient": "string|HL.Gradient",
}
return mapping.get(ctor, "any")
def parse_config_values(root: Path) -> dict[str, str]:
cfg = root / "src/config/values/ConfigValues.cpp"
source = read_text(cfg)
pattern = re.compile(r'MS<([A-Za-z0-9_]+)>\("([^"]+)"')
type_map = {
"Bool": "boolean",
"Int": "integer|boolean",
"Float": "number|boolean",
"String": "string",
"Color": "string",
"Vec2": "HL.Vec2Like",
"CssGap": "integer|HL.CssGap",
"FontWeight": "integer|string",
"Gradient": "string|HL.Gradient",
}
out: dict[str, str] = {}
for vtype, key in pattern.findall(source):
out[key.replace(":", ".").replace("-", "_")] = type_map.get(vtype, "any")
return out
def extract_initializer_body(source: str, array_name: str) -> str:
marker = f"{array_name}[]"
idx = source.find(marker)
if idx < 0:
return ""
eq_idx = source.find("=", idx)
if eq_idx < 0:
return ""
open_idx = source.find("{", eq_idx)
if open_idx < 0:
return ""
close_idx = find_matching_brace(source, open_idx)
return source[open_idx + 1 : close_idx]
def parse_descriptor_fields(root: Path) -> dict[str, dict[str, str]]:
source = read_text(root / "src/config/lua/bindings/LuaBindingsConfigRules.cpp")
arrays = {
"MONITOR_FIELDS": "HL.MonitorSpec",
"DEVICE_FIELDS": "HL.DeviceSpec",
"WORKSPACE_RULE_FIELDS": "HL.WorkspaceRuleSpec",
"WINDOW_RULE_EFFECT_DESCS": "HL.WindowRuleSpec",
"LAYER_RULE_EFFECT_DESCS": "HL.LayerRuleSpec",
}
entry_regex = re.compile(
r'\{\s*"([^"]+)"\s*,\s*\[\]\(\)\s*->\s*ILuaConfigValue\*\s*\{\s*return\s+new\s+([A-Za-z0-9_]+)\((.*?)\);\s*\}',
re.DOTALL,
)
out: dict[str, dict[str, str]] = {class_name: {} for class_name in arrays.values()}
for array_name, class_name in arrays.items():
body = extract_initializer_body(source, array_name)
if not body:
continue
for name, ctor, _ in entry_regex.findall(body):
out[class_name][name] = lua_type_from_config_ctor(ctor)
# required / conventional fields not included in descriptor arrays
out["HL.MonitorSpec"]["output"] = "string"
out["HL.DeviceSpec"]["name"] = "string"
out["HL.WorkspaceRuleSpec"]["workspace"] = "string"
out["HL.WorkspaceRuleSpec"]["enabled"] = "boolean"
out["HL.WorkspaceRuleSpec"]["layout_opts"] = "table<string, string|number|boolean>"
out["HL.WindowRuleSpec"]["name"] = "string"
out["HL.WindowRuleSpec"]["enabled"] = "boolean"
out["HL.WindowRuleSpec"]["match"] = "table<string, string|number|boolean>"
out["HL.LayerRuleSpec"]["name"] = "string"
out["HL.LayerRuleSpec"]["enabled"] = "boolean"
out["HL.LayerRuleSpec"]["match"] = "table<string, string|boolean>"
return out
def parse_known_events(root: Path) -> list[str]:
source = read_text(root / "src/config/lua/LuaEventHandler.cpp")
block_match = re.search(
r"static const std::unordered_set<std::string> EVENTS = \{(.*?)\};",
source,
flags=re.DOTALL,
)
if not block_match:
return []
events = sorted(set(re.findall(r'"([^"]+)"', block_match.group(1))))
return events
def helper_to_lua_type(helper: str) -> str:
mapping = {
"Str": "string",
"Num": "number",
"Bool": "boolean",
"Monitor": "HL.MonitorSelector",
"Workspace": "HL.WorkspaceSelector",
"Window": "HL.WindowSelector",
"MonitorSelector": "string",
"WorkspaceSelector": "string",
"WindowSelector": "string",
}
return mapping.get(helper, "any")
def query_struct_to_type(struct_name: str) -> str:
name = struct_name
if name.startswith("S") and len(name) > 1:
name = name[1:]
if name.endswith("Query"):
name = name + "Filter"
return f"HL.{name}"
def parse_query_filter_types(root: Path) -> tuple[dict[str, dict[str, str]], dict[str, str]]:
source = read_text(root / "src/config/lua/bindings/LuaBindingsQuery.cpp")
parse_header = re.compile(
r"static void\s+(\w+)\s*\(\s*lua_State\* L,\s*int idx,\s*const char\* fnName,\s*(\w+)&\s*\w+\s*\)\s*\{"
)
query_types: dict[str, dict[str, str]] = {}
parse_fn_to_type: dict[str, str] = {}
for m, body in extract_function_bodies(source, parse_header):
parse_fn = m.group(1)
struct_name = m.group(2)
type_name = query_struct_to_type(struct_name)
parse_fn_to_type[parse_fn] = type_name
query_types.setdefault(type_name, {})
direct_assign = re.finditer(
r'query\.([A-Za-z_][A-Za-z0-9_]*)\s*=\s*Internal::tableOpt([A-Za-z_]+)\(L,\s*idx,\s*"([^"]+)"',
body,
)
for dm in direct_assign:
field_name = dm.group(3)
helper = dm.group(2)
query_types[type_name][field_name] = helper_to_lua_type(helper)
helper_calls = re.finditer(r'Internal::tableOpt([A-Za-z_]+)\(L,\s*idx,\s*"([^"]+)"', body)
for hm in helper_calls:
helper = hm.group(1)
field_name = hm.group(2)
query_types[type_name].setdefault(field_name, helper_to_lua_type(helper))
api_overrides: dict[str, str] = {}
for parse_fn, type_name in parse_fn_to_type.items():
for api in re.findall(rf'{parse_fn}\(L,\s*1,\s*"([^"]+)"\s*,', source):
if api == "hl.get_windows":
api_overrides[api] = f"fun(filters?: {type_name}): HL.Window[]"
elif api == "hl.get_layers":
api_overrides[api] = f"fun(filters?: {type_name}): HL.LayerSurface[]"
else:
api_overrides[api] = f"fun(filters?: {type_name}): any"
return query_types, api_overrides
def namespace_class_name(path: list[str]) -> str:
if not path:
return "HL.API"
parts = [p[:1].upper() + p[1:] for p in path]
return f"HL.{''.join(parts)}Namespace"
def format_union_alias(name: str, values: Iterable[str]) -> list[str]:
values = list(values)
if not values:
return []
lines = [f"---@alias {name}"]
for value in values:
lines.append(f'---| "{value}"')
return lines
def emit_class_block(class_name: str, fields: list[tuple[str, str, bool]], operator_call: str | None = None) -> list[str]:
lines = [f"---@class {class_name}"]
if operator_call:
lines.append(f"---@operator call:{operator_call}")
for field_name, type_name, optional in fields:
if field_name.startswith("[") and field_name.endswith("]"):
# preformatted index field, e.g. [string]
lines.append(f"---@field {field_name} {type_name}")
continue
if re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", field_name):
suffix = "?" if optional else ""
lines.append(f"---@field {field_name}{suffix} {type_name}")
continue
quoted = field_name.replace("'", "\\'")
type_with_optional = f"{type_name}|nil" if optional else type_name
lines.append(f"---@field ['{quoted}'] {type_with_optional}")
local_name = "__" + class_name.replace(".", "_")
lines.append(f"local {local_name} = {{}}")
return lines
def generate_stub(root: Path) -> str:
api_tree, callable_namespaces = parse_binding_tree(root)
object_classes = parse_object_classes(root)
config_values = parse_config_values(root)
descriptor_classes = parse_descriptor_fields(root)
events = parse_known_events(root)
query_types, query_overrides = parse_query_filter_types(root)
api_signatures: dict[str, str] = {
"hl.on": "fun(event: HL.EventName, cb: fun(...)): HL.EventSubscription",
"hl.bind": "fun(keys: string, dispatcher: HL.Dispatcher|function, opts?: HL.BindOptions): HL.Keybind",
"hl.dispatch": "fun(dispatcher: HL.Dispatcher|function): any",
"hl.define_submap": "fun(name: string, reset_or_fn: string|function, fn?: function): nil",
"hl.timer": "fun(callback: function, opts: HL.TimerOptions): HL.Timer",
"hl.config": "fun(config: table): nil",
"hl.get_config": "fun(key: HL.ConfigKey|string): any, string?",
"hl.device": "fun(spec: HL.DeviceSpec): nil",
"hl.monitor": "fun(spec: HL.MonitorSpec): nil",
"hl.window_rule": "fun(spec: HL.WindowRuleSpec): HL.WindowRule",
"hl.layer_rule": "fun(spec: HL.LayerRuleSpec): HL.LayerRule",
"hl.workspace_rule": "fun(spec: HL.WorkspaceRuleSpec): nil",
"hl.permission": "fun(spec: HL.PermissionSpec): nil",
"hl.gesture": "fun(spec: HL.GestureSpec): nil",
"hl.get_windows": "fun(filters?: HL.WindowQueryFilter): HL.Window[]",
"hl.get_window": "fun(selector: HL.WindowSelector): HL.Window|nil",
"hl.get_active_window": "fun(): HL.Window|nil",
"hl.get_urgent_window": "fun(): HL.Window|nil",
"hl.get_workspaces": "fun(): HL.Workspace[]",
"hl.get_workspace": "fun(selector: HL.WorkspaceSelector): HL.Workspace|nil",
"hl.get_active_workspace": "fun(monitor?: HL.MonitorSelector): HL.Workspace|nil",
"hl.get_active_special_workspace": "fun(monitor?: HL.MonitorSelector): HL.Workspace|nil",
"hl.get_monitors": "fun(): HL.Monitor[]",
"hl.get_monitor": "fun(selector: HL.MonitorSelector): HL.Monitor|nil",
"hl.get_active_monitor": "fun(): HL.Monitor|nil",
"hl.get_monitor_at": "fun(x: number|HL.Vec2, y?: number): HL.Monitor|nil",
"hl.get_monitor_at_cursor": "fun(): HL.Monitor|nil",
"hl.get_layers": "fun(filters?: HL.LayerQueryFilter): HL.LayerSurface[]",
"hl.get_workspace_windows": "fun(workspace: HL.WorkspaceSelector): HL.Window[]",
"hl.get_cursor_pos": "fun(): HL.Vec2|nil",
"hl.get_last_window": "fun(): HL.Window|nil",
"hl.get_last_workspace": "fun(monitor?: HL.MonitorSelector): HL.Workspace|nil",
"hl.get_current_submap": "fun(): string",
"hl.notification.create": "fun(opts?: HL.NotificationOptions): HL.Notification",
"hl.notification.get": "fun(): HL.Notification[]",
"hl.layout.register": "fun(name: string, provider: HL.LayoutProvider): nil",
"hl.exec_cmd": "fun(cmd: string, rules?: table<string, string|number|boolean>): nil",
}
api_signatures.update(query_overrides)
lines: list[str] = []
lines.append("-- This file is autogenerated. Do not edit by hand.")
lines.append("-- Generator: scripts/generateLuaStubs.py")
lines.append("---@meta")
lines.append("")
lines.extend(format_union_alias("HL.EventName", events))
lines.append("")
lines.extend(format_union_alias("HL.ConfigKey", sorted(config_values.keys())))
lines.append("")
lines.append("---@alias HL.MonitorSelector string|integer|HL.Monitor")
lines.append("---@alias HL.WorkspaceSelector string|integer|HL.Workspace")
lines.append("---@alias HL.WindowSelector string|integer|HL.Window")
lines.append("---@alias HL.Vec2Like HL.Vec2|{x:number, y:number}|{number, number}|string")
lines.append("---@alias HL.CssGap integer|{top?:integer, right?:integer, bottom?:integer, left?:integer}")
lines.append("---@alias HL.Gradient string|{colors:string[], angle?:number}")
lines.append("")
lines.append("---@class HL.Dispatcher")
lines.append("local __HL_Dispatcher = {}")
lines.append("")
lines.extend(
emit_class_block(
"HL.Vec2",
[
("x", "number", False),
("y", "number", False),
],
)
)
lines.append("")
lines.extend(
emit_class_block(
"HL.Box",
[
("x", "number", False),
("y", "number", False),
("w", "number", False),
("h", "number", False),
],
)
)
lines.append("")
lines.extend(
emit_class_block(
"HL.LayoutTarget",
[
("index", "integer", False),
("window", "HL.Window|nil", False),
("box", "HL.Box", False),
("place", "fun(self: HL.LayoutTarget, box: HL.Box): nil", False),
("set_box", "fun(self: HL.LayoutTarget, box: HL.Box): nil", False),
],
)
)
lines.append("")
lines.extend(
emit_class_block(
"HL.LayoutContext",
[
("area", "HL.Box", False),
("targets", "HL.LayoutTarget[]", False),
("grid_cell", "fun(self: HL.LayoutContext, i: integer, cols: integer, rows?: integer): HL.Box", False),
("column", "fun(self: HL.LayoutContext, i: integer, n: integer): HL.Box", False),
("row", "fun(self: HL.LayoutContext, i: integer, n: integer): HL.Box", False),
("split", "fun(self: HL.LayoutContext, box: HL.Box, side: 'left'|'right'|'top'|'bottom'|'up'|'down', ratio: number): HL.Box", False),
],
)
)
lines.append("")
lines.extend(
emit_class_block(
"HL.LayoutProvider",
[
("recalculate", "fun(ctx: HL.LayoutContext): nil", False),
("layout_msg", "fun(ctx: HL.LayoutContext, msg: string): boolean|string|nil", True),
],
)
)
lines.append("")
lines.extend(
emit_class_block(
"HL.BindOptions",
[
("repeating", "boolean", True),
("locked", "boolean", True),
("release", "boolean", True),
("non_consuming", "boolean", True),
("transparent", "boolean", True),
("ignore_mods", "boolean", True),
("dont_inhibit", "boolean", True),
("long_press", "boolean", True),
("submap_universal", "boolean", True),
("click", "boolean", True),
("drag", "boolean", True),
("description", "string", True),
("desc", "string", True),
("device", "{inclusive?: boolean, list?: string[]}", True),
],
)
)
lines.append("")
lines.extend(
emit_class_block(
"HL.TimerOptions",
[
("timeout", "integer", False),
("type", '"repeat"|"oneshot"', False),
],
)
)
lines.append("")
lines.extend(
emit_class_block(
"HL.GestureSpec",
[
("fingers", "integer", False),
("direction", "string", False),
("action", "string", False),
("mods", "string", True),
("scale", "number", True),
("mode", "string", True),
("zoom_level", "number", True),
("workspace_name", "string", True),
("disable_inhibit", "boolean", True),
],
)
)
lines.append("")
lines.extend(
emit_class_block(
"HL.PermissionSpec",
[
("binary", "string", False),
("type", "string", False),
("allow", "string", False),
],
)
)
lines.append("")
lines.extend(
emit_class_block(
"HL.NotificationOptions",
[
("color", "string", True),
("timeout", "number", True),
("icon", "integer", True),
("font_size", "number", True),
],
)
)
lines.append("")
for class_name in sorted(query_types.keys()):
fields = [(name, typ, True) for name, typ in sorted(query_types[class_name].items())]
lines.extend(emit_class_block(class_name, fields))
lines.append("")
for class_name in sorted(descriptor_classes.keys()):
required_fields = {
("HL.MonitorSpec", "output"),
("HL.DeviceSpec", "name"),
("HL.WorkspaceRuleSpec", "workspace"),
}
fields: list[tuple[str, str, bool]] = []
for key, typ in sorted(descriptor_classes[class_name].items()):
optional = (class_name, key) not in required_fields
fields.append((key, typ, optional))
lines.extend(emit_class_block(class_name, fields))
lines.append("")
for class_name in sorted(object_classes.keys()):
obj = object_classes[class_name]
fields: list[tuple[str, str, bool]] = []
for key in sorted(obj.methods):
fields.append((key, f"fun(self: {class_name}, ...): any", False))
for key, typ in sorted(obj.fields.items()):
if key in obj.methods:
continue
fields.append((key, typ, False))
lines.extend(emit_class_block(class_name, fields))
lines.append("")
def emit_namespace(node: ApiNode, path: list[str]) -> None:
class_name = namespace_class_name(path)
fields: list[tuple[str, str, bool]] = []
full_prefix = "hl" + ("." + ".".join(path) if path else "")
for method in sorted(node.methods):
full_name = f"{full_prefix}.{method}"
default_method_type = "fun(...): HL.Dispatcher" if path and path[0] == "dsp" else "fun(...): any"
method_type = api_signatures.get(full_name, default_method_type)
fields.append((method, method_type, False))
for child_name in sorted(node.children.keys()):
fields.append((child_name, namespace_class_name(path + [child_name]), False))
if path == ["plugin"]:
fields.append(("[string]", "any", False))
operator_call = None
if path and path[-1] in callable_namespaces:
operator_call = "fun(...): any"
lines.extend(emit_class_block(class_name, fields, operator_call=operator_call))
lines.append("")
for child_name in sorted(node.children.keys()):
emit_namespace(node.children[child_name], path + [child_name])
emit_namespace(api_tree, [])
lines.append("---@type HL.API")
lines.append("hl = {}")
lines.append("")
# include a tiny map of config key value types for users who query values dynamically
lines.append("---@class HL.ConfigValueTypes")
for key, typ in sorted(config_values.items()):
lines.append(f"---@field ['{key}'] {typ}")
lines.append("local __HL_ConfigValueTypes = {}")
lines.append("")
return "\n".join(lines)
def write_if_changed(path: Path, content: str) -> bool:
if path.exists():
existing = read_text(path)
if existing == content:
return False
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return True
def main() -> int:
parser = argparse.ArgumentParser(description="Generate LuaLS stubs for Hyprland Lua config API")
parser.add_argument(
"--root",
type=Path,
default=Path(__file__).resolve().parents[1],
help="Repository root",
)
parser.add_argument(
"--output",
type=Path,
default=None,
help="Output .lua stub path (defaults to ./meta/hl.meta.lua)",
)
parser.add_argument(
"--check",
action="store_true",
help="Check mode: fail if output differs from generated content",
)
args = parser.parse_args()
root = args.root.resolve()
output = args.output.resolve() if args.output else root / "meta/hl.meta.lua"
content = generate_stub(root)
if args.check:
if not output.exists():
print(f"[lua-stubs] missing generated file: {output}", file=sys.stderr)
return 1
existing = read_text(output)
if existing != content:
print(f"[lua-stubs] generated stubs are out of date: {output}", file=sys.stderr)
return 1
print(f"[lua-stubs] up to date: {output}")
return 0
changed = write_if_changed(output, content)
state = "updated" if changed else "unchanged"
print(f"[lua-stubs] {state}: {output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())