From 1689de4864d7f67e5cef86e35a50f4e241200647 Mon Sep 17 00:00:00 2001 From: Lunar Starstrum Date: Thu, 31 Oct 2024 00:40:41 -0500 Subject: [PATCH] Have the source command support partial files and files with multiple top level group commands defined --- src/Commands/Common/SourceCodeCommand.cs | 160 +++++++++++++++-------- src/Tomoe.csproj | 3 +- 2 files changed, 106 insertions(+), 57 deletions(-) diff --git a/src/Commands/Common/SourceCodeCommand.cs b/src/Commands/Common/SourceCodeCommand.cs index e80b727..b74f250 100644 --- a/src/Commands/Common/SourceCodeCommand.cs +++ b/src/Commands/Common/SourceCodeCommand.cs @@ -1,12 +1,13 @@ using System; using System.Collections.Frozen; using System.Collections.Generic; -using System.Diagnostics; using System.IO; +using System.Linq; using System.Reflection; using System.Text; using System.Threading.Tasks; using DSharpPlus.Commands; +using DSharpPlus.Commands.ArgumentModifiers; using DSharpPlus.Commands.Processors.TextCommands; using DSharpPlus.Commands.Trees; using DSharpPlus.Commands.Trees.Metadata; @@ -22,11 +23,21 @@ namespace OoLunar.Tomoe.Commands.Common /// public static class SourceCodeCommand { + private record PartialCommand + { + public required string ClassName { get; init; } + public AttributeSyntax? CommandAttribute { get; set; } + + // We use a list of class declarations because a single file can have multiple group commands defined in it. + public Dictionary> Members { get; init; } = []; + } + private static readonly FrozenDictionary _commandLinks; static SourceCodeCommand() { Dictionary commandLinks = []; + Dictionary partialClasses = []; // Setup the assembly logic for reuse Assembly assembly = typeof(SourceCodeCommand).Assembly; @@ -35,24 +46,30 @@ static SourceCodeCommand() string baseUrl = $"{ThisAssembly.Git.Url}/blob/{ThisAssembly.Git.Commit}/src/"; // foreach *.cs file found within the embedded sources + // This only works because of the following csproj property: + // + // Include is the wildcard path to all the C# files in the src directory + // LogicalName is the name of the file, but with the directory structure. The item metadata can be found here: https://learn.microsoft.com/en-us/visualstudio/msbuild/msbuild-well-known-item-metadata?view=vs-2022 + // FileExtension is the file extension, which is always .cs + // Condition is the configuration, which we only want to include Release + // The Condition is important because when I'm developing, including the resources in Debug mode will break my intellisense. + // Like it literally just won't load and even debugging breaks due to an OOM exception. + // ALSO it wouldn't work in concept because the links would point to a commit that doesn't exist. foreach (string resourceFile in assembly.GetManifestResourceNames()) { - // if the file is a C# file if (!resourceFile.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) { continue; } - // grab the file's contents - string fileName = resourceFile.Remove(0, $"{assembly.GetName().Name}.".Length).Replace('.', '/').Replace("/cs", ".cs"); using Stream manifestStream = assembly.GetManifestResourceStream(resourceFile) ?? throw new InvalidOperationException($"Failed to get the embedded resource {resourceFile}."); SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(SourceText.From(manifestStream), CSharpParseOptions.Default); // Get all the classes in the file - foreach (ClassDeclarationSyntax classDeclaration in GetClasses(syntaxTree.GetRoot().DescendantNodes())) + foreach (ClassDeclarationSyntax classDeclaration in GetClasses(syntaxTree.GetRoot().DescendantNodes()).Distinct()) { - // Get the class name - string className = classDeclaration.Identifier.Text; + // Get the FQN of the class + string className = $"{GetNamespace(classDeclaration)}.{classDeclaration.Identifier}"; // Test to see if it has the Command attribute AttributeSyntax? commandAttribute = null; @@ -68,62 +85,92 @@ static SourceCodeCommand() } } - // Iterate through all methods in the class - foreach (MemberDeclarationSyntax member in classDeclaration.Members) + // If the class is a partial class, store it for later + if (!partialClasses.TryGetValue(className, out PartialCommand? partialCommand)) { - if (member is not MethodDeclarationSyntax methodDeclaration) + partialClasses[className] = partialCommand = new PartialCommand() { - continue; - } + ClassName = className + }; + } + + // We can load other partial files that don't have the Command attribute, + // so when we do find the command attribute, we'll store it. + partialCommand.CommandAttribute ??= commandAttribute; + if (!partialCommand.Members.TryGetValue(resourceFile, out List? classDeclarations)) + { + partialCommand.Members[resourceFile] = classDeclarations = []; + } - // Get the method name - string methodName = methodDeclaration.Identifier.Text; + classDeclarations.Add(classDeclaration); + } + } - // Test to see if it has the Command attribute - AttributeSyntax? subCommandAttribute = null; - AttributeSyntax? groupCommandAttribute = null; - foreach (AttributeListSyntax attributeList in methodDeclaration.AttributeLists) + // Iterate through all methods in the class + foreach (PartialCommand partialCommand in partialClasses.Values) + { + foreach ((string fileName, List classDeclarations) in partialCommand.Members) + { + foreach (ClassDeclarationSyntax classDeclaration in classDeclarations) + { + foreach (MemberDeclarationSyntax member in classDeclaration.Members) { - foreach (AttributeSyntax attribute in attributeList.Attributes) + if (member is not MethodDeclarationSyntax methodDeclaration) { - // Thankfully we won't need to worry about aliases since the text command processor's TryGetCommand will handle those for us. - if (attribute.Name.ToString() == "Command") - { - subCommandAttribute = attribute; - break; - } - else if (attribute.Name.ToString() == "DefaultGroupCommand") + continue; + } + + // Get the method name + string methodName = methodDeclaration.Identifier.Text; + + // Test to see if it has the Command attribute + AttributeSyntax? subCommandAttribute = null; + AttributeSyntax? groupCommandAttribute = null; + foreach (AttributeListSyntax attributeList in methodDeclaration.AttributeLists) + { + foreach (AttributeSyntax attribute in attributeList.Attributes) { - groupCommandAttribute = attribute; - break; + // Thankfully we won't need to worry about aliases since the text command processor's TryGetCommand will handle those for us. + if (attribute.Name.ToString() == "Command") + { + subCommandAttribute = attribute; + break; + } + else if (attribute.Name.ToString() == "DefaultGroupCommand") + { + groupCommandAttribute = attribute; + break; + } } } - } - // Find the beginning and ending lines of the method, starting as early as the XML docs and ending at the closing brace - int start = methodDeclaration.GetLocation().GetLineSpan().StartLinePosition.Line + 1; - int end = methodDeclaration.GetLocation().GetLineSpan().EndLinePosition.Line + 1; + // Find the beginning and ending lines of the method, starting as early as the XML docs and ending at the closing brace + int start = methodDeclaration.GetLocation().GetLineSpan().StartLinePosition.Line + 1; + int end = methodDeclaration.GetLocation().GetLineSpan().EndLinePosition.Line + 1; - StringBuilder commandNameBuilder = new(); - if (commandAttribute is not null && commandAttribute.ArgumentList is not null && commandAttribute.ArgumentList.Arguments.Count > 0) - { - commandNameBuilder.Append(commandAttribute.ArgumentList.Arguments[0].ToString().Trim('"')); + StringBuilder commandNameBuilder = new(); + if (partialCommand.CommandAttribute is not null + && partialCommand.CommandAttribute.ArgumentList is not null + && partialCommand.CommandAttribute.ArgumentList.Arguments.Count > 0) + { + commandNameBuilder.Append(partialCommand.CommandAttribute.ArgumentList.Arguments[0].ToString().Trim('"')); + + // If the method is a group command, simply add it by the command name + if (groupCommandAttribute is not null) + { + commandLinks[commandNameBuilder.ToString()] = $"{baseUrl}{fileName}#L{start}-L{end}"; + } + + // Append a space for the subcommands + commandNameBuilder.Append(' '); + } - // If the method is a group command, simply add it by the command name - if (groupCommandAttribute is not null) + if (subCommandAttribute is not null && subCommandAttribute.ArgumentList is not null && subCommandAttribute.ArgumentList.Arguments.Count > 0) { + // Append the command name, which may or may not have a group command prepended already. + commandNameBuilder.Append(subCommandAttribute.ArgumentList.Arguments[0].ToString().Trim('"')); commandLinks[commandNameBuilder.ToString()] = $"{baseUrl}{fileName}#L{start}-L{end}"; } - - // Append a space for the subcommands - commandNameBuilder.Append(' '); - } - - if (subCommandAttribute is not null && subCommandAttribute.ArgumentList is not null && subCommandAttribute.ArgumentList.Arguments.Count > 0) - { - // Append the command name, which may or may not have a group command prepended already. - commandNameBuilder.Append(subCommandAttribute.ArgumentList.Arguments[0].ToString().Trim('"')); - commandLinks[commandNameBuilder.ToString()] = $"{baseUrl}{fileName}#L{start}-L{end}"; } } } @@ -148,29 +195,32 @@ private static IEnumerable GetClasses(IEnumerable syntaxNode.Parent switch + { + NamespaceDeclarationSyntax namespaceDeclarationSyntax => namespaceDeclarationSyntax.Name.ToString(), + null => string.Empty, + _ => GetNamespace(syntaxNode.Parent) + }; + /// /// Sends a link to the repository which contains the code for the bot. /// [Command("source_code"), TextAlias("repository", "source", "code", "repo")] - public static async ValueTask ExecuteAsync(CommandContext context, string? commandName) + public static async ValueTask ExecuteAsync(CommandContext context, [RemainingText] string? commandName = null) { if (string.IsNullOrWhiteSpace(commandName)) { await context.RespondAsync($"You can find my source code here: <{ThisAssembly.Project.RepositoryUrl}>"); return; } - else if (!context.Extension.TryGetProcessor(out TextCommandProcessor? processor)) - { - throw new UnreachableException("The text command processor was not found."); - } - else if (!processor.TryGetCommand(commandName, context.Guild?.Id ?? 0, out _, out Command? command)) + else if (!context.Extension.GetProcessor().TryGetCommand(commandName, context.Guild?.Id ?? 0, out _, out Command? command)) { await context.RespondAsync($"I couldn't find a command named `{commandName}`."); return; } else if (!_commandLinks.TryGetValue(command.FullName, out string? link)) { - await context.RespondAsync($"I couldn't find the source code for the command `{commandName}`."); + await context.RespondAsync($"I couldn't find the source code for the command `{command.FullName}`/`{commandName}`."); return; } else diff --git a/src/Tomoe.csproj b/src/Tomoe.csproj index f7447b6..cf39ad4 100644 --- a/src/Tomoe.csproj +++ b/src/Tomoe.csproj @@ -11,8 +11,7 @@ - - +