diff --git a/WoLua/Constants/LogTag.cs b/WoLua/Constants/LogTag.cs index 7786ccc..22e1e2b 100644 --- a/WoLua/Constants/LogTag.cs +++ b/WoLua/Constants/LogTag.cs @@ -2,6 +2,7 @@ namespace PrincessRTFM.WoLua.Constants; public static class LogTag { public const string + GenerateDocs = "LUADOC", MethodTiming = "TIMER", DeprecatedApiMember = "DEPRECATION", CallbackRegistration = "CALLBACK", diff --git a/WoLua/Lua/Api/ApiBase.cs b/WoLua/Lua/Api/ApiBase.cs index 22688ff..5cadea2 100644 --- a/WoLua/Lua/Api/ApiBase.cs +++ b/WoLua/Lua/Api/ApiBase.cs @@ -10,11 +10,13 @@ namespace PrincessRTFM.WoLua.Lua.Api; using MoonSharp.Interpreter.Serialization.Json; using PrincessRTFM.WoLua.Constants; +using PrincessRTFM.WoLua.Lua.Docs; using PrincessRTFM.WoLua.Ui.Chat; public abstract class ApiBase: IDisposable { private const BindingFlags allInstance = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + [LuaDoc("If this is true, the API has been disposed of and **MUST NOT** be used. This should never happen in a running script.")] public bool Disposed { get; protected set; } = false; protected internal virtual void PreInit() { } diff --git a/WoLua/Lua/Api/GameApi.cs b/WoLua/Lua/Api/GameApi.cs index 7ef1984..76706d0 100644 --- a/WoLua/Lua/Api/GameApi.cs +++ b/WoLua/Lua/Api/GameApi.cs @@ -7,6 +7,7 @@ namespace PrincessRTFM.WoLua.Lua.Api; using PrincessRTFM.WoLua.Constants; using PrincessRTFM.WoLua.Game; using PrincessRTFM.WoLua.Lua.Api.Game; +using PrincessRTFM.WoLua.Lua.Docs; using PrincessRTFM.WoLua.Ui.Chat; // This API is for everything pertaining to the actual game, including holding more specific APIs. @@ -30,7 +31,9 @@ internal GameApi(ScriptContainer source) : base(source) { } #endregion #region Chat - public void PrintMessage(params DynValue[] messages) { + + [LuaDoc("Prints a message into the user's local chat log using the normal default colour")] + public void PrintMessage([AsLuaType(LuaType.Any), LuaDoc("Multiple values will be concatenated with a single space")] params DynValue[] messages) { if (this.Disposed) return; if (messages.Length == 0) @@ -44,7 +47,8 @@ public void PrintMessage(params DynValue[] messages) { Service.Plugin.Print(message, null, this.Owner.PrettyName); } - public void PrintError(params DynValue[] messages) { + [LuaDoc("Prints a message into the user's local chat log in red")] + public void PrintError([AsLuaType(LuaType.Any), LuaDoc("Multiple values will be concatenated with a single space")] params DynValue[] messages) { if (this.Disposed) return; @@ -56,6 +60,7 @@ public void PrintError(params DynValue[] messages) { Service.Plugin.Print(message, Foreground.Error, this.Owner.PrettyName); } + [LuaDoc("Sends text to the game as if the user had typed it into their chat box themselves")] public void SendChat(string chatline) { if (this.Disposed) return; @@ -68,6 +73,8 @@ public void SendChat(string chatline) { } #endregion + [LuaDoc("Plays one of the sixteen sound effects without printing anything to the user's chat")] + [return: LuaDoc("true if the provided sound effect ID was a valid sound, false if it wasn't, or nil if there was an internal error")] public bool? PlaySoundEffect(int id) { if (this.Disposed) return null; diff --git a/WoLua/Lua/Api/ScriptApi.cs b/WoLua/Lua/Api/ScriptApi.cs index c9c9a4d..f7dd94a 100644 --- a/WoLua/Lua/Api/ScriptApi.cs +++ b/WoLua/Lua/Api/ScriptApi.cs @@ -4,6 +4,7 @@ namespace PrincessRTFM.WoLua.Lua.Api; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; +using System.Runtime.InteropServices; using Dalamud.Interface.Internal.Notifications; @@ -15,6 +16,7 @@ namespace PrincessRTFM.WoLua.Lua.Api; using PrincessRTFM.WoLua.Constants; using PrincessRTFM.WoLua.Lua.Actions; using PrincessRTFM.WoLua.Lua.Api.Script; +using PrincessRTFM.WoLua.Lua.Docs; // This API is for all for everything that doesn't relate to the actual game itself. // It also contains script-specific and per-script functionality, like persistent storage. @@ -56,8 +58,11 @@ protected internal override void PreInit() { #region Storage + [LuaDoc("The script's persistent storage table")] public Table Storage { get; protected set; } + [LuaDoc("Completely clears the persistent storage table AND deletes the saved file on disk")] + [return: LuaDoc("false if the disk file couldn't be deleted, otherwise true (whether or not it existed before)")] public bool DeleteStorage() { if (this.Disposed) return false; @@ -75,6 +80,8 @@ public bool DeleteStorage() { } } + [LuaDoc("Save the current contents of the persistent storage to disk")] + [return: LuaDoc("true if the disk file was written successfully, false if there was an error")] public bool SaveStorage() { if (this.Disposed) return false; @@ -91,6 +98,8 @@ public bool SaveStorage() { } } + [LuaDoc("Try to load the saved persistent storage from disk, REPLACING the existing contents")] + [return: LuaDoc("true on success, false if no disk file was found, nil if an error occurred")] public bool? ReloadStorage() { if (this.Disposed) return false; @@ -119,7 +128,8 @@ public bool SaveStorage() { } } - public void SetStorage(Table replacement) { + [LuaDoc("Replace the script's existing persistent storage table with a new one")] + public void SetStorage([LuaDoc("The table to COPY as the new persistent storage")] Table replacement) { if (this.Disposed) return; @@ -136,35 +146,48 @@ public void SetStorage(Table replacement) { #region Common strings - public static string PluginCommand => Plugin.Command; + [LuaDoc(Plugin.Name + "'s core chat command")] + [SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "Documentation generation only reflects instance members")] + public string PluginCommand => Plugin.Command; + [LuaDoc("The INTERNAL name of this script, used to call it. May or may not be the same as the title.")] public string Name => this.Owner.InternalName; + [LuaDoc("The PRETTY name of this script, for display purposes **ONLY**. DO NOT attempt to call the script with this name, as it may not be recognised.")] public string Title => this.Owner.PrettyName; - public string CallSelfCommand => Service.Configuration.RegisterDirectCommands ? $"/{Service.Configuration.DirectInvocationCommandPrefix}{this.Name}" : PluginCommand + " call " + this.Name; + [LuaDoc("The command to call this script, respecting the \"direct script commands\" setting")] + public string CallSelfCommand => Service.Configuration.RegisterDirectCommands ? $"/{Service.Configuration.DirectInvocationCommandPrefix}{this.Name}" : this.PluginCommand + " call " + this.Name; #endregion #region Action queueing + [LuaDoc("How many actions are currently in this script's action queue")] public int QueueSize => this.Owner.ActionQueue.Count; + [LuaDoc("Clears this script's action queue entirely, but does NOT interrupt existing functions being run")] public void ClearQueue() => this.Owner.ActionQueue.clear(); + [LuaDoc("Queues a pause of the given number of milliseconds before following actions are processed")] public void QueueDelay(uint milliseconds) => this.Owner.ActionQueue.add(new PauseAction(milliseconds)); - public void QueueAction(Closure func, params DynValue[] arguments) - => this.Owner.ActionQueue.add(new CallbackAction(DynValue.NewClosure(func), arguments)); - public void QueueAction(CallbackFunction func, params DynValue[] arguments) - => this.Owner.ActionQueue.add(new CallbackAction(DynValue.NewCallback(func), arguments)); + [LuaDoc("Queues the execution of a function with the provided arguments (if any)")] + public void QueueAction(Closure callback, [AsLuaType(LuaType.Any), Optional] params DynValue[] arguments) + => this.Owner.ActionQueue.add(new CallbackAction(DynValue.NewClosure(callback), arguments)); + + [SkipDoc("It's a type-only overload of the above")] + public void QueueAction(CallbackFunction callback, params DynValue[] arguments) + => this.Owner.ActionQueue.add(new CallbackAction(DynValue.NewCallback(callback), arguments)); #endregion #region Clipboard [AllowNull] - public static string Clipboard { + [SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "Documentation generation only reflects instance members")] + [LuaDoc("The user's current system clipboard contents")] + public string Clipboard { get => ImGui.GetClipboardText() ?? string.Empty; set => ImGui.SetClipboardText(value ?? string.Empty); } @@ -173,6 +196,8 @@ public static string Clipboard { #region JSON + [LuaDoc("Parses a string containing a JSON object/array and turns it into a lua table")] + [return: LuaDoc("The resulting lua table, or nil if the JSON wasn't a valid object/array")] public Table? ParseJson(string jsonObject) { this.Log(jsonObject, LogTag.JsonParse); try { @@ -186,6 +211,8 @@ public static string Clipboard { } } + [LuaDoc("Serialises a lua table into a JSON object, or an array if all keys are numeric")] + [return: LuaDoc("The resulting JSON object/array as a string")] public string SerialiseJson(Table content) { this.Owner.cleanTable(content); string json = JsonTableConverter.TableToJson(content); @@ -193,6 +220,8 @@ public string SerialiseJson(Table content) { return json; } + [LuaDoc("Serialises a lua table into a JSON object, or an array if all keys are numeric")] + [return: LuaDoc("The resulting JSON object/array as a string")] public string SerializeJson(Table content) => this.SerialiseJson(content); // american spelling #endregion @@ -200,6 +229,7 @@ public string SerialiseJson(Table content) { #region Non-game Dalamud access [Obsolete("Use the DalamudApi version instead")] + [return: LuaDoc("Whether or not a plugin with the given INTERNAL name is installed and loaded")] public bool HasPlugin(string pluginName) { this.DeprecationWarning("Game.Dalamud.HasPlugin(string)"); return this.Owner.GameApi.Dalamud.HasPlugin(pluginName); @@ -214,13 +244,19 @@ private void showNotification(string content, NotificationType type, double dura this.Log($"Displaying notification (type {type}) of {content.Length:N} chars for {duration:N}ms ({initialDuration:D}ms x {durationModifier:F2})", LogTag.DebugMessage); Service.Interface.UiBuilder.AddNotification(content, $"{Plugin.Name}: {this.Title}", type, duration); } + + [LuaDoc("Displays a Dalamud popup debug notification in the lower right corner **IF** the script has debug mode enabled")] public void NotifyDebug(string content) { if (this.Debug.Enabled) this.showNotification(content, NotificationType.None); } + [LuaDoc("Displays a Dalamud popup informational notification in the lower right corner")] public void NotifyInfo(string content) => this.showNotification(content, NotificationType.Info); + [LuaDoc("Displays a Dalamud popup success notification in the lower right corner")] public void NotifySuccess(string content) => this.showNotification(content, NotificationType.Success); + [LuaDoc("Displays a Dalamud popup warning notification in the lower right corner")] public void NotifyWarning(string content) => this.showNotification(content, NotificationType.Warning, 1.1); + [LuaDoc("Displays a Dalamud popup error notification in the lower right corner")] public void NotifyError(string content) => this.showNotification(content, NotificationType.Error, 1.2); #endregion @@ -233,7 +269,7 @@ public void NotifyDebug(string content) { [MoonSharpHidden] [MoonSharpUserDataMetamethod(Metamethod.FunctionCall)] [SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Lua __call invocations pass target object as first parameter")] - public void RegisterCallbackFunction(DynValue self, DynValue func) { + public void RegisterCallbackFunction(DynValue self, [AsLuaType(LuaType.Function)] DynValue func) { if (this.Owner.SetCallback(func)) { this.Log($"Registered on-execute function [{ToUsefulString(func, true)}]", LogTag.CallbackRegistration); } diff --git a/WoLua/Lua/Docs/AsLuaTypeAttribute.cs b/WoLua/Lua/Docs/AsLuaTypeAttribute.cs new file mode 100644 index 0000000..beba1f8 --- /dev/null +++ b/WoLua/Lua/Docs/AsLuaTypeAttribute.cs @@ -0,0 +1,16 @@ +namespace PrincessRTFM.WoLua.Lua.Docs; + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; + +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter | AttributeTargets.ReturnValue)] +internal class AsLuaTypeAttribute: Attribute { + public string LuaName { get; } + + public AsLuaTypeAttribute(string luaType) { + this.LuaName = luaType; + } + public AsLuaTypeAttribute(LuaType luaType): this(luaType.LuaName()) { } +} diff --git a/WoLua/Lua/Docs/LuaDocAttribute.cs b/WoLua/Lua/Docs/LuaDocAttribute.cs new file mode 100644 index 0000000..868ab0a --- /dev/null +++ b/WoLua/Lua/Docs/LuaDocAttribute.cs @@ -0,0 +1,13 @@ +namespace PrincessRTFM.WoLua.Lua.Docs; + +using System; + +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Method | AttributeTargets.Parameter | AttributeTargets.ReturnValue)] +internal class LuaDocAttribute: Attribute { + public string Description { get; private init; } + public string[] Lines => this.Description.Split('\n', StringSplitOptions.TrimEntries); + + public LuaDocAttribute(string help) { + this.Description = help; + } +} diff --git a/WoLua/Lua/Docs/LuaType.cs b/WoLua/Lua/Docs/LuaType.cs new file mode 100644 index 0000000..0b6de63 --- /dev/null +++ b/WoLua/Lua/Docs/LuaType.cs @@ -0,0 +1,87 @@ +namespace PrincessRTFM.WoLua.Lua.Docs; + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +using MoonSharp.Interpreter; + +using PrincessRTFM.WoLua.Constants; + +[SuppressMessage("Naming", "CA1720:Identifier contains type name", Justification = "the names are defined externally")] +[Flags] +public enum LuaType: ushort { + Any = 0, + String = 1 << 0, + Boolean = 1 << 1, + Integer = 1 << 2, + Number = 1 << 3, + Table = 1 << 4, + Function = 1 << 5, + Userdata = 1 << 6, + Nil = 1 << 7, +} + +public static class LuaTypeExtensions { + public static string LuaName(this LuaType type) { + return type is LuaType.Any + ? type.ToString().ToLower() + : string.Join("|", Enum.GetValues() + .Where(t => t is not LuaType.Any && type.HasFlag(t)) + .OrderBy(t => (ushort)t) + .Select(t => t.ToString().ToLower()) + ); + } + public static string LuaName(this LuaType type, string userdataName) { + return type is LuaType.Any + ? type.ToString().ToLower() + : string.Join("|", Enum.GetValues() + .Where(t => t is not LuaType.Any && type.HasFlag(t)) + .OrderBy(t => (ushort)t) + .Select(t => t is LuaType.Userdata ? userdataName : t.ToString().ToLower()) + ); + } + + public static LuaType FromType(Type originalType) { + LuaType lua = LuaType.Any; + Type realType = originalType; + if (originalType.IsGenericType && realType.GetGenericTypeDefinition() == typeof(Nullable<>)) { + realType = originalType.GenericTypeArguments[0]; + lua = LuaType.Nil; + } + + // apparently you can't do a switch using typeof() cases, so here we fuckin' are + if (realType == typeof(DynValue)) + lua = LuaType.String | LuaType.Boolean | LuaType.Integer | LuaType.Number | LuaType.Table | LuaType.Function | LuaType.Userdata | LuaType.Nil; + else if (realType == typeof(string)) + lua |= LuaType.String; + else if (realType == typeof(bool)) + lua |= LuaType.Boolean; + else if (realType == typeof(byte) || realType == typeof(sbyte)) + lua |= LuaType.Integer; + else if (realType == typeof(short) || realType == typeof(ushort)) + lua |= LuaType.Integer; + else if (realType == typeof(int) || realType == typeof(uint)) + lua |= LuaType.Integer; + else if (realType == typeof(long) || realType == typeof(ulong)) + lua |= LuaType.Integer; + else if (realType == typeof(float) || realType == typeof(double)) + lua |= LuaType.Number; + else if (realType == typeof(Closure) || realType == typeof(CallbackFunction)) + lua |= LuaType.Function; + else if (realType == typeof(Table)) + lua |= LuaType.Table; + else if (realType == typeof(void)) + lua |= LuaType.Nil; + else if (realType.IsAssignableTo(typeof(IEnumerable)) || (realType.IsGenericType && realType.GetGenericTypeDefinition() == typeof(IEnumerable<>))) + lua |= LuaType.Table; + else if (realType.IsAssignableTo(typeof(IDictionary)) || (realType.IsGenericType && realType.GetGenericTypeDefinition() == typeof(IDictionary<,>))) + lua |= LuaType.Table; + else + lua |= LuaType.Userdata; + + return lua; + } +} diff --git a/WoLua/Lua/Docs/LuadocGenerator.cs b/WoLua/Lua/Docs/LuadocGenerator.cs new file mode 100644 index 0000000..b0ffc03 --- /dev/null +++ b/WoLua/Lua/Docs/LuadocGenerator.cs @@ -0,0 +1,242 @@ +namespace PrincessRTFM.WoLua.Lua.Docs; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; + +using MoonSharp.Interpreter; +using MoonSharp.Interpreter.Interop; + +using PrincessRTFM.WoLua.Constants; +using PrincessRTFM.WoLua.Lua.Api; + +internal static class LuadocGenerator { + private static readonly Assembly ownAssembly = typeof(LuadocGenerator).Assembly; + + public static Task GenerateLuaApiDocumentationAsync() => Task.Run(GenerateLuaApiDocumentation); + public static string GenerateLuaApiDocumentation() { + static bool propertyIsApi(PropertyInfo p) => !p.PropertyType.IsAbstract && p.PropertyType.GetCustomAttribute(true) is not null; + + Type apiBase = typeof(ApiBase); + Queue apis = new(typeof(ScriptContainer) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(propertyIsApi) + ); + HashSet documented = new(); + + StringBuilder docs = new(1024 * 32); // the first run of the dumper produced ~20k, so 32k should be good + docs.AppendLine("---@meta"); + docs.AppendLine(); + docs.AppendLine(); + docs.AppendLine(); + docs.AppendLine(); + + addType(docs, apiBase); + docs.AppendLine(); + + while (apis.TryDequeue(out PropertyInfo? prop)) { + if (prop is null) // unpossible! + continue; + + if (documented.Contains(prop.PropertyType)) + continue; + documented.Add(prop.PropertyType); + + addType(docs, prop.PropertyType, prop.GetCustomAttribute()?.Name); + + IEnumerable childApis = prop.PropertyType + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(propertyIsApi); + foreach (PropertyInfo subApi in childApis) + apis.Enqueue(subApi); + } + + return docs.ToString(); + } + + private static void addType(StringBuilder docs, Type type, string? name = null) { + MoonSharpHideMemberAttribute[] hideMembers = type.GetCustomAttributes().ToArray(); + bool includeMemberInDocs(MemberInfo m) { +#pragma warning disable IDE0046 // Convert to conditional expression + if (m.GetCustomAttribute() is not null) + return false; + if (m.GetCustomAttribute() is { Visible: false }) + return false; + if (m.GetCustomAttribute() is not null) + return false; + if (m is MethodBase { IsSpecialName: true }) + return false; + if (m.DeclaringType?.Assembly != ownAssembly) + return false; + if (hideMembers.Any(a => a.MemberName == m.Name)) + return false; + return true; +#pragma warning restore IDE0046 // Convert to conditional expression + } + static bool methodIsToString(MethodInfo m) => m.Name is "ToString" && m.GetParameters().Length is 0 && m.ReturnType == typeof(string); + static bool methodIsMeta(MethodInfo m) => methodIsToString(m) || m.GetCustomAttributes().Any(); + IEnumerable properties = type + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(includeMemberInDocs); + IEnumerable methods = type + .GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Where(includeMemberInDocs) + .Where(m => !methodIsMeta(m)); + IEnumerable metamethods = type + .GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Where(methodIsMeta); + + Service.Log.Info($"[{LogTag.GenerateDocs}] Documenting API type {type.Name}"); + + docs.Append($"---@class {type.Name}"); + if (type.BaseType?.IsAssignableTo(typeof(ApiBase)) is true) + docs.Append($": {type.BaseType.Name}"); + docs.AppendLine(); + + if (type.GetCustomAttribute() is LuaDocAttribute typeDetails) { + foreach (string line in typeDetails.Lines) + docs.AppendLine($"---{line}"); + } + + addProps(docs, properties); + + addMetamethods(docs, metamethods); + + if (!string.IsNullOrWhiteSpace(name)) + docs.AppendLine($"{name} = {{}}"); + else + docs.AppendLine($"local {type.Name} = {{}}"); + docs.AppendLine(); + + addMethods(docs, !string.IsNullOrWhiteSpace(name) ? name : type.Name, methods); + + docs.AppendLine(); + } + + private static void addMetamethods(StringBuilder docs, IEnumerable metamethods) { + foreach (MethodInfo m in metamethods) { + IEnumerable overloads = m.GetCustomAttributes().Select(a => a.Name); + + if (m.Name is "ToString") { // special handling for stringification because it's not recognised as an operation, so it has to be left out + Service.Log.Info($"[{LogTag.GenerateDocs}] Skipping stringification metamethod {m.DeclaringType!.Name}.{m.Name}"); + //docs.AppendLine("---@operator tostring:string"); + } + else { + foreach (string overload in overloads) { + Service.Log.Info($"[{LogTag.GenerateDocs}] Documenting metamethod {m.DeclaringType!.Name}.{m.Name} for {overload}"); + if (overload == Metamethod.FunctionCall) { // apparently, you should be using `@overload` to indicate call signatures for classes, rather than `@operator call` + docs.Append("---@overload fun("); + ParameterInfo[] parameters = m.GetParameters().Skip(1).ToArray(); + if (parameters.Length > 0) { + StringBuilder args = new(10 * parameters.Length); + foreach (ParameterInfo p in parameters) { + if (args.Length > 0) + args.Append(", "); + args.Append($"{p.Name}: "); + args.Append(p.GetCustomAttribute()?.LuaName ?? getLuaType(p.ParameterType)); + } + docs.Append(args); + } + docs.Append("): "); + docs.AppendLine(m.ReturnParameter.GetCustomAttribute()?.LuaName ?? getLuaType(m.ReturnType)); + } + else { // all other metamethods are handled basically the same way: `OPERATION(OTHER_TYPE):RETURN_TYPE` or just `OPERATION:RETURN_TYPE` if it's a single-operand method + ParameterInfo[] args = m.GetParameters(); + if (args.Length == 0 || args[0].ParameterType != m.DeclaringType) + continue; + docs.Append($"---@operator {overload[2..]}"); + if (args.Length == 1) { + // nop + } + else if (args.Length == 2) { + docs.Append($"({args[1].GetCustomAttribute()?.LuaName ?? getLuaType(args[1].ParameterType)})"); + } + docs.AppendLine($":{m.ReturnParameter.GetCustomAttribute()?.LuaName ?? getLuaType(m.ReturnType)}"); + } + } + } + } + } + + private static void addProps(StringBuilder docs, IEnumerable properties) { + static bool canWrite(PropertyInfo p) => p.SetMethod?.IsPublic is true; + foreach (PropertyInfo p in properties) { + Service.Log.Info($"[{LogTag.GenerateDocs}] Documenting property {p.DeclaringType!.Name}.{p.Name}"); + docs.Append("---@field "); + // there's no way to mark a field as read-only in the annotation, at least not yet + //if (!canWrite(p)) + // docs.Append("readonly "); + docs.Append($"public {p.Name} "); + docs.Append(p.GetCustomAttribute()?.LuaName ?? getLuaType(p.PropertyType)); + + // XXX temporary workaround for marking fields as read-only (will NOT be enforced by linting!) since there's no official way to tag them as such + if (!canWrite(p)) + docs.Append(" [READONLY]"); + + if (p.GetCustomAttribute() is LuaDocAttribute detail) + docs.Append($" {detail.Description}"); + else if (p.PropertyType.IsAssignableTo(typeof(ApiBase))) + docs.Append($" Provides access to the {p.Name.Replace("Api", string.Empty)} API"); + + docs.AppendLine(); + } + } + + private static void addMethods(StringBuilder docs, string table, IEnumerable methods) { + foreach (MethodInfo m in methods) { + Service.Log.Info($"[{LogTag.GenerateDocs}] Documenting method {m.DeclaringType!.Name}.{m.Name}"); + if (m.GetCustomAttribute() is LuaDocAttribute usage) { + foreach (string line in usage.Lines) + docs.AppendLine($"---{line}"); + } + + ParameterInfo[] parameters = m.GetParameters(); + ParameterInfo returns = m.ReturnParameter; + foreach (ParameterInfo p in parameters) { + docs.Append($"---@param {p.Name}"); + if (p.HasDefaultValue || p.IsOptional) + docs.Append('?'); + docs.Append(' '); + docs.Append(p.GetCustomAttribute()?.LuaName ?? getLuaType(p.ParameterType)); + if (p.GetCustomAttribute() is LuaDocAttribute detail) + docs.Append($" {detail.Description}"); + docs.AppendLine(); + } + + docs.Append("---@return "); + docs.Append(returns.GetCustomAttribute()?.LuaName ?? getLuaType(returns.ParameterType)); + if (returns.GetCustomAttribute() is LuaDocAttribute retDetail) + docs.Append($" # {retDetail.Description}"); + docs.AppendLine(); + + if (m.GetCustomAttribute() is not null) + docs.AppendLine("---@deprecated"); + + docs.AppendLine($"function {table}.{m.Name}({string.Join(", ", parameters.Select(p => p.Name))}) end"); + docs.AppendLine(); + } + } + + private static string getLuaType(Type originalType) { + Type realType = originalType; + if (originalType.IsGenericType && realType.GetGenericTypeDefinition() == typeof(Nullable<>)) + realType = originalType.GenericTypeArguments[0]; + LuaType result = LuaTypeExtensions.FromType(originalType); + string generatedName; + if (realType == typeof(DynValue)) { +#if DEBUG + Service.Log.Warning($"[{LogTag.GenerateDocs}] Automatically generating return type descriptor for DynValue! This should probably use [AsLuaType()] to override!"); +#endif + generatedName = result.LuaName(); + } + else { + generatedName = result.LuaName(realType.Name); + } + + Service.Log.Info($"[{LogTag.GenerateDocs}] Translated C# type " + (realType == originalType ? realType.Name : $"Nullable<{realType.Name}>") + $" to lua type {(ushort)result} \"{generatedName}\""); + return generatedName; + } +} diff --git a/WoLua/Lua/Docs/SkipDocAttribute.cs b/WoLua/Lua/Docs/SkipDocAttribute.cs new file mode 100644 index 0000000..d0b49f7 --- /dev/null +++ b/WoLua/Lua/Docs/SkipDocAttribute.cs @@ -0,0 +1,10 @@ +namespace PrincessRTFM.WoLua.Lua.Docs; + +using System; + +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Method)] +internal class SkipDocAttribute: Attribute { + public SkipDocAttribute(string justification) { + _ = justification; + } +} diff --git a/WoLua/Plugin.cs b/WoLua/Plugin.cs index 7ceaf30..fba1405 100644 --- a/WoLua/Plugin.cs +++ b/WoLua/Plugin.cs @@ -19,6 +19,7 @@ namespace PrincessRTFM.WoLua; using PrincessRTFM.WoLua.Constants; using PrincessRTFM.WoLua.Lua; using PrincessRTFM.WoLua.Lua.Api.Game; +using PrincessRTFM.WoLua.Lua.Docs; using PrincessRTFM.WoLua.Ui; using PrincessRTFM.WoLua.Ui.Chat; @@ -53,6 +54,7 @@ public SeString FullStatus { private readonly DebugWindow debugWindow; public SingleExecutionTask ScriptScanner { get; init; } + public SingleExecutionTask DocumentationGenerator { get; init; } static Plugin() { UserData.RegisterAssembly(typeof(Plugin).Assembly, true); @@ -64,6 +66,7 @@ public Plugin(DalamudPluginInterface i) { using MethodTimer logtimer = new(); this.ScriptScanner = new(this.scanScripts); + this.DocumentationGenerator = new(this.writeLuaDocs); this.Version = FileVersionInfo.GetVersionInfo(this.GetType().Assembly.Location).ProductVersion ?? "?.?.?"; if (i.Create(this, i.GetPluginConfig() ?? new PluginConfiguration(), new XivCommonBase(i)) is null) @@ -157,6 +160,12 @@ public void OnCommand(string command, string argline) { case "debug": this.debugWindow.IsOpen = true; break; + case "make-docs": + case "make-api-ref": + case "gen-docs": + case "api": + this.DocumentationGenerator.Run(); + break; default: this.Error($"Unknown command \"{subcmd}\""); break; @@ -252,6 +261,27 @@ public void Rescan() { this.ScriptScanner.Run(); } + private void writeLuaDocs() { + using MethodTimer timer = new(); + string contents; + try { + contents = LuadocGenerator.GenerateLuaApiDocumentation(); + } + catch (Exception e) { + this.Error("Failed to generate lua API reference", e); + return; + } + try { + string path = Path.Combine(Service.Configuration.BasePath, "api.lua"); + File.WriteAllText(path, contents); + this.Print($"Lua API reference written to {path}"); + } + catch (Exception e) { + this.Error("Failed to write lua API definition file", e); + return; + } + } + #region Chat public void Print(string message, UIForegroundPayload? msgCol = null, string? scriptOrigin = null) {