refactor: improve LuaLS generator (#5283)

This commit is contained in:
nerix 2024-03-30 22:11:52 +01:00 committed by GitHub
parent d4b8feac7d
commit c1bd5d11d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 311 additions and 168 deletions

View file

@ -179,7 +179,7 @@
- Dev: Added the ability to show `ChannelView`s without a `Split`. (#4747) - Dev: Added the ability to show `ChannelView`s without a `Split`. (#4747)
- Dev: Refactor Args to be less of a singleton. (#5041) - Dev: Refactor Args to be less of a singleton. (#5041)
- Dev: Channels without any animated elements on screen will skip updates from the GIF timer. (#5042, #5043, #5045) - Dev: Channels without any animated elements on screen will skip updates from the GIF timer. (#5042, #5043, #5045)
- Dev: Autogenerate docs/plugin-meta.lua. (#5055) - Dev: Autogenerate docs/plugin-meta.lua. (#5055, #5283)
- Dev: Changed Ubuntu & AppImage builders to statically link Qt. (#5151) - Dev: Changed Ubuntu & AppImage builders to statically link Qt. (#5151)
- Dev: Refactor `NetworkPrivate`. (#5063) - Dev: Refactor `NetworkPrivate`. (#5063)
- Dev: Refactor `Paths` & `Updates`, focusing on reducing their singletoniability. (#5092, #5102) - Dev: Refactor `Paths` & `Updates`, focusing on reducing their singletoniability. (#5092, #5102)

View file

@ -6,22 +6,14 @@
c2 = {} c2 = {}
---@class IWeakResource ---@alias c2.LogLevel integer
---@type { Debug: c2.LogLevel, Info: c2.LogLevel, Warning: c2.LogLevel, Critical: c2.LogLevel }
--- Returns true if the channel this object points to is valid.
--- If the object expired, returns false
--- If given a non-Channel object, it errors.
---@return boolean
function IWeakResource:is_valid() end
---@alias LogLevel integer
---@type { Debug: LogLevel, Info: LogLevel, Warning: LogLevel, Critical: LogLevel }
c2.LogLevel = {} c2.LogLevel = {}
---@alias EventType integer ---@alias c2.EventType integer
---@type { CompletionRequested: EventType } ---@type { CompletionRequested: c2.EventType }
c2.EventType = {} c2.EventType = {}
---@class CommandContext ---@class CommandContext
---@field words string[] The words typed when executing the command. For example `/foo bar baz` will result in `{"/foo", "bar", "baz"}`. ---@field words string[] The words typed when executing the command. For example `/foo bar baz` will result in `{"/foo", "bar", "baz"}`.
---@field channel Channel The channel the command was executed in. ---@field channel Channel The channel the command was executed in.
@ -29,20 +21,31 @@ c2.EventType = {}
---@class CompletionList ---@class CompletionList
---@field values string[] The completions ---@field values string[] The completions
---@field hide_others boolean Whether other completions from Chatterino should be hidden/ignored. ---@field hide_others boolean Whether other completions from Chatterino should be hidden/ignored.
-- Now including data from src/common/Channel.hpp.
---@class CompletionEvent
---@field query string The word being completed
---@field full_text_content string Content of the text input
---@field cursor_position integer Position of the cursor in the text input in unicode codepoints (not bytes)
---@field is_first_word boolean True if this is the first word in the input
-- Begin src/common/Channel.hpp
---@alias ChannelType integer ---@alias ChannelType integer
---@type { None: ChannelType } ---@type { None: ChannelType, Direct: ChannelType, Twitch: ChannelType, TwitchWhispers: ChannelType, TwitchWatching: ChannelType, TwitchMentions: ChannelType, TwitchLive: ChannelType, TwitchAutomod: ChannelType, TwitchEnd: ChannelType, Irc: ChannelType, Misc: ChannelType }
ChannelType = {} ChannelType = {}
-- Back to src/controllers/plugins/LuaAPI.hpp.
-- Now including data from src/controllers/plugins/api/ChannelRef.hpp. -- End src/common/Channel.hpp
--- This enum describes a platform for the purpose of searching for a channel.
--- Currently only Twitch is supported because identifying IRC channels is tricky. -- Begin src/controllers/plugins/api/ChannelRef.hpp
---@alias Platform integer ---@alias Platform integer
--- This enum describes a platform for the purpose of searching for a channel.
--- Currently only Twitch is supported because identifying IRC channels is tricky.
---@type { Twitch: Platform } ---@type { Twitch: Platform }
Platform = {} Platform = {}
---@class Channel: IWeakResource
---@class Channel
Channel = {}
--- Returns true if the channel this object points to is valid. --- Returns true if the channel this object points to is valid.
--- If the object expired, returns false --- If the object expired, returns false
@ -82,11 +85,9 @@ function Channel:add_system_message(message) end
--- Compares the channel Type. Note that enum values aren't guaranteed, just --- Compares the channel Type. Note that enum values aren't guaranteed, just
--- that they are equal to the exposed enum. --- that they are equal to the exposed enum.
--- ---
---@return bool ---@return boolean
function Channel:is_twitch_channel() end function Channel:is_twitch_channel() end
--- Twitch Channel specific functions
--- Returns a copy of the channel mode settings (subscriber only, r9k etc.) --- Returns a copy of the channel mode settings (subscriber only, r9k etc.)
--- ---
---@return RoomModes ---@return RoomModes
@ -119,15 +120,10 @@ function Channel:is_mod() end
---@return boolean ---@return boolean
function Channel:is_vip() end function Channel:is_vip() end
--- Misc
---@return string ---@return string
function Channel:__tostring() end function Channel:__tostring() end
--- Static functions
--- Finds a channel by name. --- Finds a channel by name.
---
--- Misc channels are marked as Twitch: --- Misc channels are marked as Twitch:
--- - /whispers --- - /whispers
--- - /mentions --- - /mentions
@ -142,19 +138,15 @@ function Channel.by_name(name, platform) end
--- Finds a channel by the Twitch user ID of its owner. --- Finds a channel by the Twitch user ID of its owner.
--- ---
---@param string id ID of the owner of the channel. ---@param id string ID of the owner of the channel.
---@return Channel? ---@return Channel?
function Channel.by_twitch_id(string) end function Channel.by_twitch_id(id) end
---@class RoomModes ---@class RoomModes
---@field unique_chat boolean You might know this as r9kbeta or robot9000. ---@field unique_chat boolean You might know this as r9kbeta or robot9000.
---@field subscriber_only boolean ---@field subscriber_only boolean
---@field emotes_only boolean Whether or not text is allowed in messages. ---@field emotes_only boolean Whether or not text is allowed in messages. Note that "emotes" here only means Twitch emotes, not Unicode emoji, nor 3rd party text-based emotes
---@field follower_only number? Time in minutes you need to follow to chat or nil.
--- Note that "emotes" here only means Twitch emotes, not Unicode emoji, nor 3rd party text-based emotes
---@field unique_chat number? Time in minutes you need to follow to chat or nil.
---@field slow_mode number? Time in seconds you need to wait before sending messages or nil. ---@field slow_mode number? Time in seconds you need to wait before sending messages or nil.
---@class StreamStatus ---@class StreamStatus
@ -164,7 +156,8 @@ function Channel.by_twitch_id(string) end
---@field title string Stream title or last stream title ---@field title string Stream title or last stream title
---@field game_name string ---@field game_name string
---@field game_id string ---@field game_id string
-- Back to src/controllers/plugins/LuaAPI.hpp.
-- End src/controllers/plugins/api/ChannelRef.hpp
--- Registers a new command called `name` which when executed will call `handler`. --- Registers a new command called `name` which when executed will call `handler`.
--- ---
@ -176,12 +169,12 @@ function c2.register_command(name, handler) end
--- Registers a callback to be invoked when completions for a term are requested. --- Registers a callback to be invoked when completions for a term are requested.
--- ---
---@param type "CompletionRequested" ---@param type "CompletionRequested"
---@param func fun(query: string, full_text_content: string, cursor_position: integer, is_first_word: boolean): CompletionList The callback to be invoked. ---@param func fun(event: CompletionEvent): CompletionList The callback to be invoked.
function c2.register_callback(type, func) end function c2.register_callback(type, func) end
--- Writes a message to the Chatterino log. --- Writes a message to the Chatterino log.
--- ---
---@param level LogLevel The desired level. ---@param level c2.LogLevel The desired level.
---@param ... any Values to log. Should be convertible to a string with `tostring()`. ---@param ... any Values to log. Should be convertible to a string with `tostring()`.
function c2.log(level, ...) end function c2.log(level, ...) end

View file

@ -12,25 +12,26 @@ It assumes comments look like:
- Do not have any useful info on '/**' and '*/' lines. - 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 - Class members are not allowed to have non-@command lines and commands different from @lua@field
When this scripts sees "@brief", any further lines of the comment will be ignored 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: Valid commands are:
1. @exposeenum [dotted.name.in_lua.last_part] 1. @exposeenum [dotted.name.in_lua.last_part]
Define a table with keys of the enum. Values behind those keys aren't Define a table with keys of the enum. Values behind those keys aren't
written on purpose. written on purpose.
This generates three lines: 2. @exposed [c2.name]
- An type alias of [last_part] to integer, Generates a function definition line from the last `@lua@param`s.
- A type description that describes available values of the enum, 3. @lua[@command]
- A global table definition for the num
2. @lua[@command]
Writes [@command] to the file as a comment, usually this is @class, @param, @return, ... 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 @lua@class and @lua@field have special treatment when it comes to generation of spacing new lines
3. @exposed [c2.name]
Generates a function definition line from the last `@lua@param`s.
Non-command lines of comments are written with a space after '---' Non-command lines of comments are written with a space after '---'
""" """
from io import TextIOWrapper
from pathlib import Path from pathlib import Path
import re
from typing import Optional
BOILERPLATE = """ BOILERPLATE = """
---@meta Chatterino2 ---@meta Chatterino2
@ -41,14 +42,6 @@ BOILERPLATE = """
c2 = {} c2 = {}
---@class IWeakResource
--- Returns true if the channel this object points to is valid.
--- If the object expired, returns false
--- If given a non-Channel object, it errors.
---@return boolean
function IWeakResource:is_valid() end
""" """
repo_root = Path(__file__).parent.parent repo_root = Path(__file__).parent.parent
@ -58,116 +51,274 @@ lua_meta = repo_root / "docs" / "plugin-meta.lua"
print("Writing to", lua_meta.relative_to(repo_root)) print("Writing to", lua_meta.relative_to(repo_root))
def process_file(target, out): def strip_line(line: str):
print("Reading from", target.relative_to(repo_root)) return re.sub(r"^/\*\*|^\*|\*/$", "", line).strip()
with target.open("r") as f:
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 = []
while (line := self.peek_line()) is not None:
if line.startswith("};"):
self.next_line()
break
if not is_comment_start(line):
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 = []
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(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() lines = f.read().splitlines()
# Are we in a doc comment? reader = Reader(lines)
comment: bool = False while reader.has_next():
# This is set when @brief is encountered, making the rest of the comment be doc_comment = reader.next_doc_comment()
# ignored if not doc_comment:
ignore_this_comment: bool = False
# Last `@lua@param`s seen - for @exposed generation
last_params_names: list[str] = []
# Are we in a `@lua@class` definition? - makes newlines around @lua@class and @lua@field prettier
is_class = False
# The name of the next enum in lua world
expose_next_enum_as: str | None = None
# Name of the current enum in c++ world, used to generate internal typenames for
current_enum_name: str | None = None
for line_num, line in enumerate(lines):
line = line.strip()
loc = f'{target.relative_to(repo_root)}:{line_num}'
if line.startswith("enum class "):
line = line.removeprefix("enum class ")
temp = line.split(" ", 2)
current_enum_name = temp[0]
if not expose_next_enum_as:
print(
f"{loc} Skipping enum {current_enum_name}, there wasn't a @exposeenum command"
)
current_enum_name = None
continue
current_enum_name = expose_next_enum_as.split(".", 1)[-1]
out.write("---@alias " + current_enum_name + " integer\n")
out.write("---@type { ")
# temp[1] is '{'
if len(temp) == 2: # no values on this line
continue
line = temp[2]
if current_enum_name is not None:
for i, tok in enumerate(line.split(" ")):
if tok == "};":
break break
entry = tok.removesuffix(",") header_comment = None
if i != 0: if not doc_comment[0].startswith("@"):
out.write(", ") if len(doc_comment) == 1:
out.write(entry + ": " + current_enum_name)
out.write(" }\n" f"{expose_next_enum_as} = {{}}\n")
print(f"{loc} Wrote enum {expose_next_enum_as} => {current_enum_name}")
current_enum_name = None
expose_next_enum_as = None
continue continue
header_comment = doc_comment[0]
if line.startswith("/**"): header = doc_comment[1:]
comment = True else:
continue header = doc_comment
elif "*/" in line:
comment = False # include block
ignore_this_comment = False if header[0].startswith("@includefile "):
for comment in header:
if not is_class: if not comment.startswith("@includefile "):
out.write("\n") panic(
continue path,
if not comment: reader.line_no(),
continue f"Invalid include block - got line '{comment}'",
if ignore_this_comment: )
continue filename = comment.split(" ", 1)[1]
line = line.replace("*", "", 1).lstrip() out.write(f"-- Begin src/{filename}\n\n")
if line == "": read_file(repo_root / "src" / filename, out)
out.write("---\n") out.write(f"-- End src/{filename}\n\n")
elif line.startswith('@brief '): continue
# Doxygen comment, on a C++ only method
ignore_this_comment = True # enum
elif line.startswith("@exposeenum "): if header[0].startswith("@exposeenum "):
expose_next_enum_as = line.split(" ", 1)[1] if len(header) > 1:
elif line.startswith("@exposed "): panic(
exp = line.replace("@exposed ", "", 1) path,
params = ", ".join(last_params_names) reader.line_no(),
out.write(f"function {exp}({params}) end\n") f"Invalid enum exposure - one command expected, got {len(header)}",
print(f"{loc} Wrote function {exp}(...)") )
last_params_names = [] name = header[0].split(" ", 1)[1]
elif line.startswith("@includefile "): printmsg(path, reader.line_no(), f"enum {name}")
filename = line.replace("@includefile ", "", 1) out.write(f"---@alias {name} integer\n")
output.write(f"-- Now including data from src/{filename}.\n") if header_comment:
process_file(repo_root / 'src' / filename, output) out.write(f"--- {header_comment}\n")
output.write(f'-- Back to {target.relative_to(repo_root)}.\n') out.write("---@type { ")
elif line.startswith("@lua"): out.write(
command = line.replace("@lua", "", 1) ", ".join(
if command.startswith("@param"): [f"{variant}: {name}" for variant in reader.read_enum_variants()]
last_params_names.append(command.split(" ", 2)[1]) )
elif command.startswith("@class"): )
print(f"{loc} Writing {command}") out.write(" }\n")
if is_class: out.write(f"{name} = {{}}\n\n")
out.write("\n") continue
is_class = True
elif not command.startswith("@field"): # class
is_class = False if header[0].startswith("@lua@class "):
name = header[0].split(" ", 1)[1]
out.write("---" + command + "\n") 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: else:
if is_class:
is_class = False
out.write("\n") out.write("\n")
# note the space difference from the branch above for func in funcs:
out.write("--- " + line + "\n") write_func(path, reader.line_no(), func, out)
continue
# global function
if header[-1].startswith("@exposed "):
write_func(path, reader.line_no(), doc_comment, out)
continue
with lua_meta.open("w") as output: if __name__ == "__main__":
with lua_meta.open("w") as output:
output.write(BOILERPLATE[1:]) # skip the newline after triple quote output.write(BOILERPLATE[1:]) # skip the newline after triple quote
process_file(lua_api_file, output) read_file(lua_api_file, output)

View file

@ -106,7 +106,7 @@ int c2_register_callback(lua_State *L);
/** /**
* Writes a message to the Chatterino log. * Writes a message to the Chatterino log.
* *
* @lua@param level LogLevel The desired level. * @lua@param level c2.LogLevel The desired level.
* @lua@param ... any Values to log. Should be convertible to a string with `tostring()`. * @lua@param ... any Values to log. Should be convertible to a string with `tostring()`.
* @exposed c2.log * @exposed c2.log
*/ */

View file

@ -21,7 +21,7 @@ enum class LPlatform {
}; };
/** /**
* @lua@class Channel: IWeakResource * @lua@class Channel
*/ */
struct ChannelRef { struct ChannelRef {
static void createMetatable(lua_State *L); static void createMetatable(lua_State *L);
@ -100,7 +100,7 @@ public:
* Compares the channel Type. Note that enum values aren't guaranteed, just * Compares the channel Type. Note that enum values aren't guaranteed, just
* that they are equal to the exposed enum. * that they are equal to the exposed enum.
* *
* @lua@return bool * @lua@return boolean
* @exposed Channel:is_twitch_channel * @exposed Channel:is_twitch_channel
*/ */
static int is_twitch_channel(lua_State *L); static int is_twitch_channel(lua_State *L);
@ -193,7 +193,7 @@ public:
/** /**
* Finds a channel by the Twitch user ID of its owner. * Finds a channel by the Twitch user ID of its owner.
* *
* @lua@param string id ID of the owner of the channel. * @lua@param id string ID of the owner of the channel.
* @lua@return Channel? * @lua@return Channel?
* @exposed Channel.by_twitch_id * @exposed Channel.by_twitch_id
*/ */
@ -216,13 +216,12 @@ struct LuaRoomModes {
bool subscriber_only = false; bool subscriber_only = false;
/** /**
* @lua@field emotes_only boolean Whether or not text is allowed in messages. * @lua@field emotes_only boolean Whether or not text is allowed in messages. Note that "emotes" here only means Twitch emotes, not Unicode emoji, nor 3rd party text-based emotes
* Note that "emotes" here only means Twitch emotes, not Unicode emoji, nor 3rd party text-based emotes
*/ */
bool emotes_only = false; bool emotes_only = false;
/** /**
* @lua@field unique_chat number? Time in minutes you need to follow to chat or nil. * @lua@field follower_only number? Time in minutes you need to follow to chat or nil.
*/ */
std::optional<int> follower_only; std::optional<int> follower_only;
/** /**