Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
79a891a
wip
vadosnaprimer Mar 23, 2026
aedd2a3
Add support for generating LuaCATS definitions for the Lua API
kalimag Aug 21, 2023
e605a18
Add missing numeric types
kalimag Oct 24, 2025
fabb10c
Throw error when definition file is executed as a script
kalimag Oct 24, 2025
554d0cf
Add support for bytes-as-string parameters
kalimag Oct 24, 2025
4301a88
Apply suggestions from code review
vadosnaprimer Mar 23, 2026
fa6748b
add examples and hawk info
vadosnaprimer Mar 23, 2026
6d9f4ee
line endings
vadosnaprimer Mar 23, 2026
535c79f
move editor/ide integration to its own menu
vadosnaprimer Mar 23, 2026
fd7e68b
does "Braces XOR single-line" mean this?
vadosnaprimer Mar 23, 2026
d729148
braces
vadosnaprimer Mar 24, 2026
9a33894
name the definitions file according to Lua Language Server spec
vadosnaprimer Mar 24, 2026
687b481
proper Program Files folder search
vadosnaprimer Mar 24, 2026
f11aa9c
Refactor example formatting
kalimag Mar 24, 2026
ae30e6b
Preamble, line-ending stuff
kalimag Mar 24, 2026
9951d79
Make `LuaCanvas` local
kalimag Mar 25, 2026
3c4d8c2
Refactor `GetLuaType(ParameterInfo)`
kalimag Mar 25, 2026
cb486da
Enumerate valid drawingSurface values
kalimag Mar 25, 2026
d8f7719
It's free braces
kalimag Mar 25, 2026
03bbaab
Make class static
kalimag Mar 25, 2026
928d55a
Make class static Part II
kalimag Mar 25, 2026
f772809
split into individual files?
vadosnaprimer Mar 27, 2026
6cb96a2
Remove return statement from definition files
kalimag Mar 29, 2026
6667a7b
Include default parameter values
kalimag Mar 29, 2026
db4ce34
Fix code style
kalimag Mar 29, 2026
eb210ae
Rename color aliases
kalimag Mar 30, 2026
3b79e1f
Fix name collision with `userdata`
kalimag Mar 30, 2026
12da1a4
Mark zero-indexed return values
kalimag Apr 2, 2026
29b153e
Handle zero-indexed parameters
kalimag Apr 12, 2026
3350d27
Support Nullable Reference Type annotations
kalimag Apr 15, 2026
1db4234
Revert "wip" - unrelated submodule change
kalimag Apr 15, 2026
06bdc0a
Switch to NRT extensions
kalimag Apr 17, 2026
f1cf53f
Adjust annotation whitespace and comment
kalimag Apr 19, 2026
a48d075
Sort color union
kalimag Apr 20, 2026
4c08946
Annotate more specific `createcanvas` return type
kalimag Apr 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
294 changes: 294 additions & 0 deletions src/BizHawk.Client.Common/lua/Documentation/LuaCatsGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
#nullable enable

#pragma warning disable MA0136 // Raw String contains an implicit end of line character, line endings will be normalized

using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.RegularExpressions;
using BizHawk.Common;
using BizHawk.Common.ReflectionExtensions;
using NLua;

namespace BizHawk.Client.Common;

/// <summary>
/// Generates API definitions in the LuaCATS format.
/// </summary>
/// <remarks>
/// See https://luals.github.io/wiki/annotations
/// </remarks>
internal static class LuaCatsGenerator
{
private static readonly Dictionary<Type, string> TypeConversions = new()
{
[typeof(object)] = "any",
[typeof(byte)] = "integer",
[typeof(sbyte)] = "integer",
[typeof(int)] = "integer",
[typeof(uint)] = "integer",
[typeof(short)] = "integer",
[typeof(ushort)] = "integer",
[typeof(long)] = "integer",
[typeof(ulong)] = "integer",
[typeof(float)] = "number",
[typeof(double)] = "number",
[typeof(decimal)] = "number",
[typeof(string)] = "string",
[typeof(bool)] = "boolean",
[typeof(byte[])] = "string",
[typeof(Memory<byte>)] = "string",
[typeof(ReadOnlyMemory<byte>)] = "string",
[typeof(LuaFunction)] = "function",
[typeof(LuaTable)] = "table",
[typeof(System.Drawing.Color)] = "dotnetcolor",
};

private const string Classes = """
---@class dotnetcolor : userdata

---A color in one of the following formats:
--- - Number in the format `0xAARRGGBB`
--- - String in the format `"#RRGGBB"` or `"#AARRGGBB"`
--- - A CSS3/X11 color name e.g. `"blue"`, `"palegoldenrod"`
--- - Color created with `forms.createcolor`
---@alias color dotnetcolor | integer | string

---@alias surface
---| "emucore" # Draw on the emulated screen. Resolution depends on emulated system and game. Drawing is scaled with the rest of the display.
---| "client" # Draw on the BizHawk window. Resolution depends on the window size. Drawing is not scaled.
""";

private const string Preamble = """
-- https://tasvideos.org/Bizhawk

error("This is a definition file for Lua Language Server and not a usable script")

---@meta _
""";

private static string? GetHardcodedType(ParameterInfo parameter)
{
// Technically any string parameter can be passed a number in BizHawk's Lua API, but let's just focus on the ones where it's commonly used
// like `gui.text` and `forms.settext` instead of polluting the entire API surface
if (parameter.Name is "message" or "caption" && parameter.ParameterType == typeof(string))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't want to encourage this. There are enough footguns in calling gui.* already with Lua not having named arguments.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing this will likely cause a lot of unnecessary warnings

Copy link
Copy Markdown
Member

@YoshiRulz YoshiRulz Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think type coercion warnings are good, but then I also consider dynamic languages like Lua to always be inferior to statically-typed languages. Is passing numbers for strings idiomatic in general, or only in concatenation?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, number-to-string conversion is specifically a feature in BizHawk's Lua bindings, not a feature of the Lua runtime. That's why LLS warns about it unless the parameter definition allows numbers.

But reading numbers from memory and passing them to gui functions is a pretty common pattern in BizHawk scripts, so I added this special case for those functions to avoid warnings in existing, working scripts. I have a test directory with the bundled Lua scripts and some from the Lua scripts catalog, and removing this code causes 73 additional warnings, 16 of which are in BizHawk's own scripts (for example).

I have no strong opinions on deprecating this behavior, but currently it's valid so the definitions say it's valid.

{
return "string|number";
}

if (parameter.Member.DeclaringType.Name == "GuiLuaLibrary" && parameter.Name == "surfaceName" && parameter.ParameterType == typeof(string))
{
return "surface";
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should introduce new attributes if this grows to any more enums.

}

return null;
}

public static void Generate(LuaDocumentation docs, string path)
{
var sb0 = new StringBuilder();

sb0.AppendLine($"-- Lua functions available in EmuHawk {VersionInfo.MainVersion}");
sb0.AppendLine(Preamble);
sb0.AppendLine();
sb0.AppendLine(Classes);
sb0.AppendLine();

File.WriteAllText(Path.Combine(path, "classes.d.lua"), sb0.ToString().ReplaceLineEndings());

foreach (var libraryGroup in docs.GroupBy(func => func.Library).OrderBy(group => group.Key))
Comment thread
YoshiRulz marked this conversation as resolved.
{
string library = libraryGroup.Key;
string libraryDescription = libraryGroup.First().LibraryDescription;
var libraryType = libraryGroup.First().Method.DeclaringType;
var filePath = Path.Combine(path, library + ".d.lua");
var sb = new StringBuilder();

sb.AppendLine($"-- Lua functions available in EmuHawk {VersionInfo.MainVersion}");
sb.AppendLine(Preamble);
sb.AppendLine();

if (!string.IsNullOrEmpty(libraryDescription))
{
sb.AppendLine(FormatMarkdown(libraryDescription));
}

sb.AppendLine($"---@class {SafeLibraryTypeName(library)}");
if (!typeof(LuaLibraryBase).IsAssignableFrom(libraryType)) sb.Append("local "); // don't make LuaCanvas global
sb.AppendLine($"{library} = {{}}");
sb.AppendLine();

foreach (var func in libraryGroup.OrderBy(func => func.Name))
{
if (!string.IsNullOrEmpty(func.Description))
{
sb.AppendLine(FormatMarkdown(func.Description));
}

if (func.Example != null)
{
sb.AppendLine("---");
sb.AppendLine("---Example:");
sb.AppendLine("---");
sb.AppendLine(FormatMarkdown(func.Example, "---\t"));
}

if (func.IsDeprecated)
{
sb.AppendLine("---@deprecated");
}

foreach (var parameter in func.Method.GetParameters())
{
if (IsParams(parameter))
{
sb.Append("---@vararg");
}
else
{
sb.Append($"---@param {parameter.Name}");
if (parameter.HasDefaultValue || (parameter.IsNRTOrNullableT() ?? true))
{
sb.Append('?');
}
}

sb.Append(' ');
sb.Append(GetLuaType(parameter));
if (IsZeroIndexed(parameter))
{
sb.Append(" Zero-indexed array.");
}
if (parameter.HasDefaultValue && parameter.DefaultValue is not null and not "")
{
sb.Append($" Defaults to `{FormatValue(parameter.DefaultValue)}`");
}
sb.AppendLine();
}

if (func.Method.ReturnType != typeof(void))
{
sb.Append("---@return ");
var luaType = GetLuaType(func.Method.ReturnParameter);
var nilable = func.Method.ReturnParameter.IsNRTOrNullableT() ?? true;
var wrapType = nilable && luaType.IndexOfAny([ ':', '|' ]) != -1; // ? is ambiguous on complex types like `string|int` or `fun(): string`
if (wrapType) sb.Append('(');
sb.Append(luaType);
if (wrapType) sb.Append(')');
if (nilable) sb.Append('?');
if (IsZeroIndexed(func.Method.ReturnParameter))
{
sb.Append(" # Zero-indexed array.");
}
sb.AppendLine();
}

sb.Append($"function {library}.{func.Name}(");

foreach (var parameter in func.Method.GetParameters())
{
if (parameter.Position > 0)
{
sb.Append(", ");
}
sb.Append(IsParams(parameter) ? "..." : parameter.Name);
}

sb.AppendLine(") end");
sb.AppendLine();
}
File.WriteAllText(filePath, sb.ToString().ReplaceLineEndings());
}
}

private static string FormatMarkdown(string value, string prefix = "---")
{
// prefix every line
value = Regex.Replace(value, "^", prefix, RegexOptions.Multiline);
// replace {{wiki markup}} with `markdown`
value = Regex.Replace(value, "{{(.+?)}}", "`$1`");
// replace wiki image markup with markdown
value = Regex.Replace(value, @"\[(?<url>.+?)\|alt=(?<alt>.+?)\]", "![${alt}](${url})");
return value;
}

private static string FormatValue(object value) => value switch
{
string str => $"\"{str}\"",
true => "true",
false => "false",
null => "nil",
_ => value.ToString(),
};

/// <summary>
/// Avoid name collisions with existing Lua types.
/// Only for the <c>@class</c> annotation, not the name of the global.
/// <see href="https://luals.github.io/wiki/annotations/#documenting-types" />
/// </summary>
private static string SafeLibraryTypeName(string name) => name switch
{
"userdata" => $"biz{name}",
_ => name,
};

private static string GetLuaType(ParameterInfo parameter)
{
if (GetOverrideType(parameter) is string overrideType)
{
return overrideType;
}

if (GetHardcodedType(parameter) is string hardcodedType)
{
return hardcodedType;
}

if (parameter.GetCustomAttribute<LuaColorParamAttribute>() is not null)
{
return "color"; // see Preamble
}

if (parameter.ParameterType.IsArray && IsParams(parameter))
{
// no [] array modifier for varargs
return GetLuaType(parameter.ParameterType.GetElementType());
}

return GetLuaType(parameter.ParameterType);
}

private static string GetLuaType(Type type)
{
// try this twice, before and after extracting the array/nullable type
if (TypeConversions.TryGetValue(type, out string luaType))
{
return luaType;
}

if (type.IsArray)
{
return GetLuaType(type.GetElementType()) + "[]";
}

if (Nullable.GetUnderlyingType(type) is Type underlyingType)
{
type = underlyingType;
}

if (TypeConversions.TryGetValue(type, out luaType))
{
return luaType;
}

throw new NotSupportedException($"Unknown type {type.FullName} used in API. Generator must be updated to handle this.");
}

private static bool IsParams(ParameterInfo parameter) => parameter.GetCustomAttribute<ParamArrayAttribute>() is not null;

private static bool IsZeroIndexed(ParameterInfo parameter) => parameter.GetCustomAttribute<LuaZeroIndexedAttribute>() is not null;

private static string? GetOverrideType(ParameterInfo parameter) => parameter.GetCustomAttribute<LuaCatsTypeAttribute>()?.Type;
}
10 changes: 10 additions & 0 deletions src/BizHawk.Client.Common/lua/LuaAttributes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,14 @@ public LuaLibraryAttribute(bool released)
/// </summary>
[AttributeUsage(AttributeTargets.ReturnValue | AttributeTargets.Parameter)]
public sealed class LuaZeroIndexedAttribute : Attribute { }

/// <summary>
/// Specifies a custom type to be used in LuaCATS annotations, for documentation purposes only.
/// See <see cref="LuaCatsGenerator.Classes" /> and <see href="https://luals.github.io/wiki/annotations/#documenting-types" /> for possible values.
/// </summary>
[AttributeUsage(AttributeTargets.ReturnValue | AttributeTargets.Parameter)]
public sealed class LuaCatsTypeAttribute(string Type) : Attribute
{
public string Type { get; } = Type;
}
}
5 changes: 5 additions & 0 deletions src/BizHawk.Client.Common/lua/LuaDocumentation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,11 @@ public string ToNotepadPlusPlusAutoComplete()
{
return ""; // TODO
}

public void ToLuaLanguageServerDefinitions(string path)
{
LuaCatsGenerator.Generate(this, path);
}
}

public class LibraryFunction
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,7 @@ public void Text(
description: "Creates a dedicated canvas window, returning a table containing some callbacks for drawing. These are the LuaCanvas functions in the API reference."
+ " The width and height parameters determine the size of the canvas."
+ " If the x and y parameters are both nil/unset, the form (window) will appear at the default position. If both are specified, the form will be positioned at (x, y) on the screen.")] // technically x can be specified w/o y but let's leave that as UB
[return: LuaCatsType("LuaCanvas")]
public LuaTable CreateCanvas(int width, int height, int? x = null, int? y = null)
{
var canvas = new LuaCanvas(APIs.Emulation, PathEntries, width, height, x, y, _th, LogOutputCallback);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,17 +46,19 @@ public void InstallBizLua(TextEditors editor, LuaDocumentation docs)

private string AppDataFolder => Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);

private string ProgramFilesFolder => Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);

private bool IsSublimeInstalled()
{
// The most likely location of the app, eventually we should consider looking through the registry or installed apps as a more robust way to detect it;
string exePath = @"C:\Program Files\Sublime Text 2\sublime_text.exe";
string exePath = Path.Combine(ProgramFilesFolder, @"Sublime Text 2\sublime_text.exe");
return File.Exists(exePath);
}

private bool IsNotepadInstalled()
{
// The most likely location of the app, eventually we should consider looking through the registry or installed apps as a more robust way to detect it;
string exePath = @"C:\Program Files (x86)\Notepad++\notepad++.exe";
string exePath = Path.Combine(ProgramFilesFolder, @"Notepad++\notepad++.exe");
Comment thread
vadosnaprimer marked this conversation as resolved.
return File.Exists(exePath);
}

Expand Down
Loading
Loading