33f6383122
Co-authored-by: Nerixyz <nerixdev@outlook.de>
402 lines
13 KiB
Python
Executable File
402 lines
13 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
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 '---'
|
|
|
|
To insert larger fragments of documentation, comments like
|
|
/* @lua-fragment
|
|
---@class foo
|
|
...
|
|
*/
|
|
will be inserted as-is. These only work in the top-level scope. They don't work
|
|
for class bodies.
|
|
"""
|
|
|
|
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 strip_comments(line: str):
|
|
return re.sub(r"//.*|/\*.*?\*/", "", line).strip()
|
|
|
|
|
|
def is_comment_start(line: str):
|
|
return line.startswith("/**")
|
|
|
|
|
|
def is_fragment_start(line: str):
|
|
return line.startswith("/* @lua-fragment")
|
|
|
|
|
|
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_item(self) -> list[str] | str | None:
|
|
"""Finds the next documentation comment or fragment and advances the cursor"""
|
|
# find the start
|
|
while (line := self.next_line()) is not None:
|
|
if is_comment_start(line):
|
|
return self._read_doc_comment(line)
|
|
elif is_fragment_start(line):
|
|
return self._read_fragment()
|
|
|
|
return None
|
|
|
|
def _read_doc_comment(self, starting_line: str) -> Optional[list[str]]:
|
|
"""Reads a documentation comment (/** ... */) and advances the cursor"""
|
|
lines = []
|
|
line = starting_line
|
|
stripped = strip_line(line)
|
|
if stripped:
|
|
lines.append(stripped)
|
|
|
|
if line.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_fragment(self) -> Optional[str]:
|
|
"""Reads an inline fragment comment (/* @lua-fragment ... */) and advances the cursor"""
|
|
|
|
body = ""
|
|
while (line := self.next_line()) is not None:
|
|
if line.endswith("*/"):
|
|
break
|
|
body += "\n" + line
|
|
|
|
return body
|
|
|
|
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_item()
|
|
assert not isinstance(doc, str), "Fragment inside class body found"
|
|
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
|
|
waiting_for_end = 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 "):
|
|
continue
|
|
|
|
if waiting_for_end:
|
|
if line.endswith(","):
|
|
waiting_for_end = False
|
|
continue
|
|
m = re.match(r"^([\w_]+)", line)
|
|
if not m:
|
|
continue
|
|
|
|
items.append(m.group(1))
|
|
waiting_for_end = not strip_comments(line).endswith(",")
|
|
|
|
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 write_enum(
|
|
name: str,
|
|
description: str | None,
|
|
variants: list[str],
|
|
is_flags: bool,
|
|
out: TextIOWrapper,
|
|
):
|
|
if description:
|
|
out.write(f"--- {description}\n")
|
|
out.write(f"---@enum {name}\n")
|
|
out.write(f"{name} = {{\n")
|
|
|
|
def value(variant):
|
|
return "0," if is_flags else f"{{}}, ---@type {name}.{variant}"
|
|
|
|
out.write("\n".join([f" {variant} = {value(variant)}" for variant in variants]))
|
|
out.write("\n}\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_item()
|
|
if not doc_comment:
|
|
break
|
|
elif isinstance(doc_comment, str):
|
|
out.write(doc_comment)
|
|
out.write("\n")
|
|
continue
|
|
|
|
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)}",
|
|
)
|
|
args = header[0].split(" ")[1:]
|
|
if len(args) < 1 or len(args) > 2:
|
|
panic(
|
|
path,
|
|
reader.line_no(),
|
|
f"Invalid @exposeenum - expected 2 arguments, got {len(args)}",
|
|
)
|
|
name = args[0]
|
|
is_flags = len(args) >= 2 and args[1] == "[flags]"
|
|
printmsg(path, reader.line_no(), f"enum {name}")
|
|
write_enum(
|
|
name,
|
|
header_comment,
|
|
reader.read_enum_variants(),
|
|
is_flags,
|
|
out,
|
|
)
|
|
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)
|