""" This script generates docs/plugin-meta.lua. It accepts no arguments It assumes comments look like: /** * Thing * * @lua@param thing boolean * @lua@returns boolean * @exposed name */ - Do not have any useful info on '/**' and '*/' lines. - Class members are not allowed to have non-@command lines and commands different from @lua@field Only entire comment blocks are used. One comment block can describe at most one entity (function/class/enum). Blocks without commands are ignored. Valid commands are: 1. @exposeenum [dotted.name.in_lua.last_part] Define a table with keys of the enum. Values behind those keys aren't written on purpose. 2. @exposed [c2.name] Generates a function definition line from the last `@lua@param`s. 3. @lua[@command] Writes [@command] to the file as a comment, usually this is @class, @param, @return, ... @lua@class and @lua@field have special treatment when it comes to generation of spacing new lines Non-command lines of comments are written with a space after '---' """ from io import TextIOWrapper from pathlib import Path import re from typing import Optional BOILERPLATE = """ ---@meta Chatterino2 -- This file is automatically generated from src/controllers/plugins/LuaAPI.hpp by the scripts/make_luals_meta.py script -- This file is intended to be used with LuaLS (https://luals.github.io/). -- Add the folder this file is in to "Lua.workspace.library". c2 = {} """ repo_root = Path(__file__).parent.parent lua_api_file = repo_root / "src" / "controllers" / "plugins" / "LuaAPI.hpp" lua_meta = repo_root / "docs" / "plugin-meta.lua" print("Writing to", lua_meta.relative_to(repo_root)) def strip_line(line: str): return re.sub(r"^/\*\*|^\*|\*/$", "", line).strip() def is_comment_start(line: str): return line.startswith("/**") def is_enum_class(line: str): return line.startswith("enum class") def is_class(line: str): return line.startswith(("class", "struct")) class Reader: lines: list[str] line_idx: int def __init__(self, lines: list[str]) -> None: self.lines = lines self.line_idx = 0 def line_no(self) -> int: """Returns the current line number (starting from 1)""" return self.line_idx + 1 def has_next(self) -> bool: """Returns true if there are lines left to read""" return self.line_idx < len(self.lines) def peek_line(self) -> Optional[str]: """Reads the line the cursor is at""" if self.has_next(): return self.lines[self.line_idx].strip() return None def next_line(self) -> Optional[str]: """Consumes and returns one line""" if self.has_next(): self.line_idx += 1 return self.lines[self.line_idx - 1].strip() return None def next_doc_comment(self) -> Optional[list[str]]: """Reads a documentation comment (/** ... */) and advances the cursor""" lines = [] # find the start while (line := self.next_line()) is not None and not is_comment_start(line): pass if line is None: return None stripped = strip_line(line) if stripped: lines.append(stripped) if stripped.endswith("*/"): return lines if lines else None while (line := self.next_line()) is not None: if line.startswith("*/"): break stripped = strip_line(line) if not stripped: continue if stripped.startswith("@"): lines.append(stripped) continue if not lines: lines.append(stripped) else: lines[-1] += "\n--- " + stripped return lines if lines else None def read_class_body(self) -> list[list[str]]: """The reader must be at the first line of the class/struct body. All comments inside the class are returned.""" items = [] nesting = -1 # for the opening brace while (line := self.peek_line()) is not None: if line.startswith("};") and nesting == 0: self.next_line() break if not is_comment_start(line): nesting += line.count("{") - line.count("}") self.next_line() continue doc = self.next_doc_comment() if not doc: break items.append(doc) return items def read_enum_variants(self) -> list[str]: """The reader must be before an enum class definition (possibly with some comments before). It returns all variants.""" items = [] is_comment = False while (line := self.peek_line()) is not None and not line.startswith("};"): self.next_line() if is_comment: if line.endswith("*/"): is_comment = False continue if line.startswith("/*"): is_comment = True continue if line.startswith("//"): continue if line.endswith("};"): # oneline declaration opener = line.find("{") + 1 closer = line.find("}") items = [ line.split("=", 1)[0].strip() for line in line[opener:closer].split(",") ] break if line.startswith("enum class"): continue items.append(line.rstrip(",")) return items def finish_class(out, name): out.write(f"{name} = {{}}\n") def printmsg(path: Path, line: int, message: str): print(f"{path.relative_to(repo_root)}:{line} {message}") def panic(path: Path, line: int, message: str): printmsg(path, line, message) exit(1) def write_func(path: Path, line: int, comments: list[str], out: TextIOWrapper): if not comments[0].startswith("@"): out.write(f"--- {comments[0]}\n---\n") comments = comments[1:] params: list[str] = [] for comment in comments[:-1]: if not comment.startswith("@lua"): panic(path, line, f"Invalid function specification - got '{comment}'") if comment.startswith("@lua@param"): params.append(comment.split(" ", 2)[1]) out.write(f"---{comment.removeprefix('@lua')}\n") if not comments[-1].startswith("@exposed "): panic(path, line, f"Invalid function exposure - got '{comments[-1]}'") name = comments[-1].split(" ", 1)[1] printmsg(path, line, f"function {name}") lua_params = ", ".join(p.removesuffix("?") for p in params) out.write(f"function {name}({lua_params}) end\n\n") def read_file(path: Path, out: TextIOWrapper): print("Reading", path.relative_to(repo_root)) with path.open("r") as f: lines = f.read().splitlines() reader = Reader(lines) while reader.has_next(): doc_comment = reader.next_doc_comment() if not doc_comment: break header_comment = None if not doc_comment[0].startswith("@"): if len(doc_comment) == 1: continue header_comment = doc_comment[0] header = doc_comment[1:] else: header = doc_comment # enum if header[0].startswith("@exposeenum "): if len(header) > 1: panic( path, reader.line_no(), f"Invalid enum exposure - one command expected, got {len(header)}", ) name = header[0].split(" ", 1)[1] printmsg(path, reader.line_no(), f"enum {name}") variants = reader.read_enum_variants() vtypes = [] for variant in variants: vtype = f'{name}.{variant}' vtypes.append(vtype) out.write(f'---@alias {vtype} "{vtype}"\n') out.write(f"---@alias {name} {'|'.join(vtypes)}\n") if header_comment: out.write(f"--- {header_comment}\n") out.write("---@type { ") out.write( ", ".join( [f"{variant}: {typ}" for variant, typ in zip(variants,vtypes)] ) ) out.write(" }\n") out.write(f"{name} = {{}}\n\n") continue # class elif header[0].startswith("@lua@class "): name = header[0].split(" ", 1)[1] classname = name.split(":")[0].strip() printmsg(path, reader.line_no(), f"class {classname}") if header_comment: out.write(f"--- {header_comment}\n") out.write(f"---@class {name}\n") # inline class if len(header) > 1: for field in header[1:]: if not field.startswith("@lua@field "): panic( path, reader.line_no(), f"Invalid inline class exposure - all lines must be fields, got '{field}'", ) out.write(f"---{field.removeprefix('@lua')}\n") out.write("\n") continue # class definition # save functions for later (print fields first) funcs = [] for comment in reader.read_class_body(): if comment[-1].startswith("@exposed "): funcs.append(comment) continue if len(comment) > 1 or not comment[0].startswith("@lua"): continue out.write(f"---{comment[0].removeprefix('@lua')}\n") if funcs: # only define global if there are functions on the class out.write(f"{classname} = {{}}\n\n") else: out.write("\n") for func in funcs: write_func(path, reader.line_no(), func, out) continue # global function elif header[-1].startswith("@exposed "): write_func(path, reader.line_no(), doc_comment, out) continue else: for comment in header: inline_command(path, reader.line_no(), comment, out) def inline_command(path: Path, line: int, comment: str, out: TextIOWrapper): if comment.startswith("@includefile "): filename = comment.split(" ", 1)[1] out.write(f"-- Begin src/{filename}\n\n") read_file(repo_root / "src" / filename, out) out.write(f"-- End src/{filename}\n\n") elif comment.startswith("@lua@class"): panic( path, line, "Unexpected @lua@class command. @lua@class must be placed at the start of the comment block!", ) elif comment.startswith("@lua@"): out.write(f'---{comment.replace("@lua", "", 1)}\n') if __name__ == "__main__": with lua_meta.open("w") as output: output.write(BOILERPLATE[1:]) # skip the newline after triple quote read_file(lua_api_file, output)