diff --git a/.gitignore b/.gitignore index c1f4f5edfb..a48e09794c 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ packages/ .idea/ build_output.txt + +/TombIDE/TombIDE.Shared/TIDE/LuaLS/ diff --git a/AGENTS.md b/AGENTS.md index 8284c7fe6a..cbf364b206 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ ## General Project Information -- Language: **C# targeting .NET 8** (desktop application). +- Language: **C# targeting .NET 8** (desktop application). - This is a level editor suite for a family of 3D game engines used in the classic Tomb Raider series. - Level formats are grid-based, room-based, and portal-based. - A room is a spatial container for level geometry and game entities. @@ -10,21 +10,43 @@ ## General Guidelines +### Files and Namespaces + - Files must use Windows line endings. Only standard ASCII symbols are allowed; do not use Unicode symbols. -- `using` directives are grouped and sorted as follows: `DarkUI` namespaces first, then `System` namespaces, followed by third-party and local namespaces. -- Namespace declarations and type definitions should place the opening brace on a new line. -- Prefer grouping all feature-related functionality within a self-contained module or modules. Avoid creating large code blocks over 10–15 lines in existing modules; instead, offload code to helper functions. -- Avoid duplicating and copypasting code. Implement helper methods instead, whenever similar code is used within a given module, class or feature scope. +- Every document should end with a trailing newline. +- `using` directives and namespace declarations should always be sorted alphabetically. +- Remove unused `using` statements. +- Prefer importing namespaces over fully qualifying framework types when there is no ambiguity. Remove redundant qualifiers such as `System.StringComparison` and use `StringComparison` directly when no namespace conflict exists. +- Prefer file-scoped namespaces where a file contains a single namespace and no language constraint prevents it. If a block-scoped namespace is still required, place the opening brace on a new line and sort multiple namespace declarations alphabetically. + +### Nullability + +- Each refactor should enable nullable reference types for the touched code. Add `#nullable enabled` at the top of the file only when the project does not already enable nullables, and update the touched code to use nullable annotations and checks correctly. +- Always use `is null` / `is not null` rather than `== null` / `!= null`. +- Prefer nullability attributes and helpers such as `[NotNullWhen]`, `[MemberNotNull]`, `[MaybeNullWhen]` and related annotations when they improve flow analysis and keep the API clear. +- Avoid the null-forgiving operator (`!`) where possible. Prefer flow analysis, null checks, annotations and helper methods instead, and use `!` only when it is truly necessary to express a proven invariant the compiler cannot infer. + +### Architecture and Composition + +- Keep feature-related functionality within self-contained modules. Avoid large code blocks over 10-15 lines in existing modules; move that logic into helpers or dedicated types. +- Always look for opportunities to de-duplicate code and fix duplication where suitable. Prefer shared helpers or extracted modules when similar code appears within a module, class or feature scope. +- Prefer modern .NET and C# conventions when project-specific guidance does not require something else. +- Design new code with service-based composition in mind. Favor dependency injection seams, and use the temporary `TombLib.WPF` service locator only in code paths that already depend on it. +- Keep vertical slice architecture in mind when choosing where new features, helpers and dependencies should live. ## Formatting -- **Indentation** is four spaces; tabs are not used. +### Indentation + +- Indentation uses four spaces; tabs are not used. -- **Braces**: - - Always use braces for multi-statement blocks. - - Do not use braces for single-statement blocks, unless they are within multiple `else if` conditions where surrounding statements are multi-line. - - - Opening curly brace `{` for structures, classes and methods should be on the next line, not on the same line: +### Braces + +- Always use braces for multi-statement blocks. +- Single-line conditions should not use braces when the entire `if` / `else if` / `else` chain stays single-line. +- Multi-line conditions or multi-line bodies must always use braces. +- If any branch in an `if` / `else if` / `else` chain uses braces, all sibling branches should use braces as well. +- Opening curly brace `{` for structures, classes and methods should be on the next line, not on the same line: ```csharp public class Foo @@ -39,27 +61,29 @@ } ``` - - Anonymous delegates and lambdas should keep the brace on the same line: - `delegate () { ... }` or `() => { ... }`. - -- **Line breaks and spacing**: - - A blank line separates logically distinct groups of members (fields, constructors, public methods, private helpers, etc.). - - Spaces around binary operators (`=`, `+`, `==`, etc.) and after commas. - - A single space follows keyword `if`/`for`/`while` before the opening parenthesis. - - Expressions may be broken into multiple lines and aligned with the previous line's indentation level to improve readability. - - However, chained LINQ method calls, lambdas or function/method arguments should not be broken into multiple lines, unless they reach more than 150 symbols in length. - - - Do not collapse early exits or single-statement conditions into a single line: - - Bad example: - ```csharp - if (condition) return; - ``` - Do this instead: - ```csharp - if (condition) - return; - ``` +- Anonymous delegates and lambdas should keep the brace on the same line: `delegate () { ... }` or `() => { ... }`. + +### Line Breaks and Spacing + +- A blank line separates logically distinct groups of members (fields, constructors, public methods, private helpers, etc.). +- Within method bodies, use a blank line between logically distinct statements and before a control-flow block that starts a new step. +- Avoid whitespace-only lines or dead indentation; blank lines should be truly blank. +- Spaces around binary operators (`=`, `+`, `==`, etc.) and after commas. +- A single space follows keyword `if` / `for` / `while` before the opening parenthesis. +- Expressions may be broken into multiple lines and aligned with the previous line's indentation level to improve readability. +- Chained LINQ method calls, lambdas or function arguments should stay on one line unless they exceed roughly 150 characters. +- Do not collapse early exits or single-statement conditions into one line. + + Bad example: + ```csharp + if (condition) return; + ``` + + Do this instead: + ```csharp + if (condition) + return; + ``` ## Naming @@ -75,40 +99,54 @@ - Fields are generally declared as `public` or `private readonly` depending on usage; expose state via properties where appropriate. - `var` type should be preferred where possible, when the right-hand type is evident from the initializer. -- Explicit typing should be only used when it is required by logic or compiler, or when type name is shorter than 6 symbols (e.g. `int`, `bool`, `float`). +- Explicit typing should only be used when it is required by logic or compiler, or when the type name is shorter than 6 symbols (e.g. `int`, `bool`, `float`). - For floating-point numbers, always use `f` postfix and decimal, even if value is not fractional (e.g. `2.0f`). +- Consider `record` or `record struct` when value semantics, immutability, or concise data-carrier behavior make them a better fit than a class or struct. +- Prefer expression-bodied members for methods or properties whose implementation is a single readable line. +- Prefer collection expressions (`[]`, `[item]`, `[..items]`) over `Array.Empty()`, explicit array or list construction, or simple `.ToArray()` / `.ToList()` materialization when the target type supports them and the result stays clear. ## Control Flow and Syntax - Avoid excessive condition nesting and use early exits / breaks where possible. - LINQ and lambda expressions are used for collections (`FirstOrDefault`, `Where`, `Any`, etc.). +- Use pattern matching where it keeps the code clearer or removes redundant casts, temporary variables or branching. +- Under nullable-aware code, avoid throwing `ArgumentNullException` for non-nullable parameters when the guard adds no meaningful value. +- When an exception type exposes helper APIs such as `ArgumentNullException.ThrowIfNull`, prefer those helpers over manual `if` blocks when the behavior stays clear. - Exception and error handling is done with `try`/`catch`, and caught exceptions are logged with [NLog](https://nlog-project.org/) where appropriate. -- Warnings must also be logged by NLog, if cause for the incorrect behaviour is user action. +- Warnings caused by user action should also be logged through NLog. ## Comments - When comments appear they are single-line `//`. Block comments (`/* ... */`) are rare. - Comments are sparse. Code relies on meaningful names rather than inline documentation. -- Do not use `` if surrounding code and/or module isn't already using it. Only add `` for non-private methods with high complexity. -- If module or function implements complex functionality, a brief description (2-3 lines) may be added in front of it, separated by a blank line from the function body. +- Add XML documentation to classes where it clarifies intent, and to public methods and public properties by default. Use XML documentation for private members only when the behavior is complex enough that names alone are not sufficient. +- If a module or function implements complex functionality, use brief section comments to split long methods into smaller, digestible steps. - All descriptive comments should end with a full stop (`.`). ## Code Grouping -- Large methods should group related actions together, separated by blank lines. +- Large methods should group related actions together, separated by blank lines and short section comments when they cannot be broken apart further. - Constants and static helpers that are used several times should appear at the top of a class. - Constants that are used only within a scope of a method, should be declared within this method. - One-liner lambdas may be grouped together, if they share similar meaning or functionality. +- Prefer one top-level type per file when practical. Keep multiple classes, enums, records or interfaces in the same file only when they are strictly coupled. +- When a class grows too large in size or scope, split it into smaller partial classes organized by responsibility. Use partial classes only when the responsibilities still belong to the same type; otherwise extract a dedicated helper, service or type instead. +- Avoid one-line wrapper methods unless they remove duplication, enforce a policy, or provide meaning beyond a direct redirect. +- Do not keep generic helper methods inside the same feature class. First check whether a suitable shared helper already exists elsewhere in the codebase; otherwise extract the helper into the most suitable shared library project or dedicated module. +- If a helper method is broad in scope, such as a general WPF helper like `FindAncestor()`, first verify whether an equivalent already exists. If not, place it in the narrowest suitable shared library among `TombLib`, `TombLib.Scripting`, `TombLib.WPF` and `DarkUI.WPF` rather than adding it to a feature-local helper class. ## User Interface Implementation - For WinForms-based workflows, maintain the existing Visual Studio module pair for each control or unit: `.cs` and `.Designer.cs`. - For existing WinForms-based `DarkUI` controls and containers, prefer to use existing WinForms-based `DarkUI` controls. -- For new controls and containers with complex logic, or where WinForms may not perform fast enough, prefer `DarkUI.WPF` framework. Use `GeometryIOSettingsWindow` as a reference. +- For new WPF views and view models, use `GeometryIOSettingsWindow` as the reference for structure, localization and service usage patterns. +- When writing WPF UI, prioritize localization and the existing localization infrastructure from `TombLib.WPF`. +- Creating new generic WPF controls should be delegated to `DarkUI.WPF`. +- For new controls and containers with complex logic, or where WinForms may not perform fast enough, prefer `DarkUI.WPF`. - Use `CommunityToolkit` functionality where possible. ## Performance - For 3D rendering controls, prefer more performant approaches and locally cache frequently used data within the function scope whenever possible. - Avoid scenarios where bulk data updates may cause event floods, as the project relies heavily on event subscriptions across multiple controls and sub-controls. -- Use `Parallel` for bulk operations to maximize performance. Avoid using it in thread-unsafe contexts or when operating on serial data sets. \ No newline at end of file +- Use `Parallel` for bulk operations to maximize performance. Avoid using it in thread-unsafe contexts or when operating on serial data sets. diff --git a/DarkUI/DarkUI.WPF.Demo/DarkUI.WPF.Demo.csproj b/DarkUI/DarkUI.WPF.Demo/DarkUI.WPF.Demo.csproj index 573cdd5283..cef484f9f4 100644 --- a/DarkUI/DarkUI.WPF.Demo/DarkUI.WPF.Demo.csproj +++ b/DarkUI/DarkUI.WPF.Demo/DarkUI.WPF.Demo.csproj @@ -2,23 +2,9 @@ WinExe - net6.0-windows enable true True - Debug;Release - x64;x86 - - - - none - true - - - x64 - - - x86 diff --git a/DarkUI/DarkUI.WPF/DarkUI.WPF.csproj b/DarkUI/DarkUI.WPF/DarkUI.WPF.csproj index 054b6f2730..86b2e21b3b 100644 --- a/DarkUI/DarkUI.WPF/DarkUI.WPF.csproj +++ b/DarkUI/DarkUI.WPF/DarkUI.WPF.csproj @@ -1,26 +1,8 @@  - net6.0-windows enable true - Debug;Release - x64;x86 - - - - ..\..\Build ($(Platform))\ - - - ..\..\BuildRelease ($(Platform))\ - none - true - - - x64 - - - x86 diff --git a/DarkUI/DarkUI/DarkUI.csproj b/DarkUI/DarkUI/DarkUI.csproj index 114d677da1..9d4cc7468a 100644 --- a/DarkUI/DarkUI/DarkUI.csproj +++ b/DarkUI/DarkUI/DarkUI.csproj @@ -1,26 +1,7 @@  - net6.0-windows - Library false true - true - Debug;Release - x64;x86 - - - ..\..\Build ($(Platform))\ - - - ..\..\BuildRelease ($(Platform))\ - none - true - - - x64 - - - x86 diff --git a/DarkUI/DarkUI/Properties/AssemblyInfo.cs b/DarkUI/DarkUI/Properties/AssemblyInfo.cs index 7c7a1b5e65..30e9112df7 100644 --- a/DarkUI/DarkUI/Properties/AssemblyInfo.cs +++ b/DarkUI/DarkUI/Properties/AssemblyInfo.cs @@ -1,5 +1,6 @@ using System.Reflection; using System.Runtime.InteropServices; +using System.Runtime.Versioning; // General Information about an assembly is controlled through the following // set of attributes. Change these attribute values to modify the information @@ -12,6 +13,7 @@ [assembly: AssemblyCopyright("Copyright © Robin Perris")] [assembly: AssemblyTrademark("")] [assembly: AssemblyCulture("")] +[assembly: SupportedOSPlatform("windows")] // Setting ComVisible to false makes the types in this assembly not visible // to COM components. If you need to access a type in this assembly from diff --git a/DarkUI/Example/Example.csproj b/DarkUI/Example/Example.csproj index 6896affaba..d3231afa10 100644 --- a/DarkUI/Example/Example.csproj +++ b/DarkUI/Example/Example.csproj @@ -1,22 +1,8 @@  - net6.0-windows WinExe false true - true - Debug;Release - x64;x86 - - - none - true - - - x64 - - - x86 diff --git a/DarkUI/Example/Properties/AssemblyInfo.cs b/DarkUI/Example/Properties/AssemblyInfo.cs index e7033330fb..21c2a82454 100644 --- a/DarkUI/Example/Properties/AssemblyInfo.cs +++ b/DarkUI/Example/Properties/AssemblyInfo.cs @@ -1,5 +1,6 @@ using System.Reflection; using System.Runtime.InteropServices; +using System.Runtime.Versioning; // General Information about an assembly is controlled through the following // set of attributes. Change these attribute values to modify the information @@ -12,6 +13,7 @@ [assembly: AssemblyCopyright("Copyright © Robin Perris")] [assembly: AssemblyTrademark("")] [assembly: AssemblyCulture("")] +[assembly: SupportedOSPlatform("windows")] // Setting ComVisible to false makes the types in this assembly not visible // to COM components. If you need to access a type in this assembly from diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000000..d09e563946 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,38 @@ + + + net8.0-windows + 12 + Debug;Release + x64;x86 + x64 + $(Platform.Replace(' ', '')) + x64 + true + Build + BuildRelease + BuildNgXmlBuilder + BuildNgXmlBuilderRelease + + + + $(MSBuildThisFileDirectory)$(SharedDebugOutputRoot) ($(NormalizedPlatform))\ + + + $(MSBuildThisFileDirectory)$(SharedReleaseOutputRoot) ($(NormalizedPlatform))\ + + + + none + true + + + x64 + + + x86 + + diff --git a/ExternalResources.md b/ExternalResources.md index c3625bdab6..00646af570 100644 --- a/ExternalResources.md +++ b/ExternalResources.md @@ -17,6 +17,7 @@ A big thank you to all the authors for making their work publicly available and | CH.SipHash | NuGet | 1.0.2 | Public Domain | https://github.com/tanglebones/ch-siphash | | FastColoredTextBox | NuGet | 2.16.21 | LGPLv3 | https://www.codeproject.com/Articles/161871/Fast-Colored-TextBox-for-syntax-highlighting | | System.Drawing.PSD | NuGet | 1.1 | BSD 3-clause | https://github.com/bizzehdee/System.Drawing.PSD | +| Lua Language Server | Bundled zip (`TIDE/LuaLS`) | 3.18.1 | MIT | https://github.com/LuaLS/lua-language-server | ### Main Software Documentation @@ -25,3 +26,4 @@ A big thank you to all the authors for making their work publicly available and ### Icons Icons and graphics used under CC-BY ND 3.0 license from http://icons8.com + A subset of Codicons icon geometry used for Lua completion symbols is vendored from https://github.com/microsoft/vscode-codicons under the MIT license. diff --git a/FileAssociation/FileAssociation.csproj b/FileAssociation/FileAssociation.csproj index d11c102854..efd217a151 100644 --- a/FileAssociation/FileAssociation.csproj +++ b/FileAssociation/FileAssociation.csproj @@ -1,27 +1,9 @@  - net6.0-windows WinExe File Association false true - true - Debug;Release - x64;x86 - - - ..\Build ($(Platform))\ - - - ..\BuildRelease ($(Platform))\ - none - true - - - x64 - - - x86 app.manifest diff --git a/GlobalPaths.cs b/GlobalPaths.cs index bd1ed59fd6..d6dd83a5bf 100644 --- a/GlobalPaths.cs +++ b/GlobalPaths.cs @@ -7,7 +7,7 @@ internal static class DefaultPaths { - public static string ProgramDirectory => Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + public static string ProgramDirectory => Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? AppContext.BaseDirectory; #region Configs @@ -16,10 +16,11 @@ internal static class DefaultPaths public static string ConfigsDirectory => Path.Combine(ProgramDirectory, "Configs"); public static string GeometryIOConfigsDirectory => Path.Combine(ConfigsDirectory, "GeometryIO"); public static string TextEditorConfigsDirectory => Path.Combine(ConfigsDirectory, "TextEditors"); + public static string TextEditorThemesDirectory => Path.Combine(TextEditorConfigsDirectory, "Themes"); public static string ColorSchemesDirectory => Path.Combine(TextEditorConfigsDirectory, "ColorSchemes"); public static string ClassicScriptColorConfigsDirectory => Path.Combine(ColorSchemesDirectory, "ClassicScript"); - public static string LuaColorConfigsDirectory => Path.Combine(ColorSchemesDirectory, "Lua"); + public static string LuaThemeConfigsDirectory => Path.Combine(TextEditorThemesDirectory, "Lua"); public static string GameFlowColorConfigsDirectory => Path.Combine(ColorSchemesDirectory, "GameFlowScript"); public static string T1MColorConfigsDirectory => Path.Combine(ColorSchemesDirectory, "Tomb1Main"); diff --git a/Installer/Installer_compile_instructions.txt b/Installer/Installer_compile_instructions.txt index fe3f8bd3fa..99aa16e4b5 100644 --- a/Installer/Installer_compile_instructions.txt +++ b/Installer/Installer_compile_instructions.txt @@ -2,14 +2,14 @@ HOW TO MAKE INSTALLER: 1. Compile Release build into empty BuildRelease folder (with no stray logs, autosave prj2 etc.) 2. Download NSIS from here: https://sourceforge.net/projects/nsis/ -3. Run NSIS and execute install_script.nsi in-place from Installer folder. It will generate TombEditorInstall.exe installer inside BuildRelease folder. +3. Run NSIS and execute install_script_x64.nsi in-place from Installer folder. It will generate TombEditorInstall.exe installer inside BuildRelease folder. 4. You are ready to deploy your installer! IN CASE NEW COMPONENTS ARE ADDED AND FILE LIST IN BuildRelease FOLDER IS CHANGED: 1. Download uninstalled files list generator here: https://nsis.sourceforge.io/mediawiki/images/9/9f/Unlist.zip 2. Run it onto clean BuildRelease folder, it will generate new file list block ready to be placed into NSIS script -3. Overwrite autogenerated file list block in install_script.nsi "Uninstall" section (there's comments for that) with new one +3. Overwrite autogenerated file list block in install_script_x64.nsi "Uninstall" section (there's comments for that) with new one 4. You are ready to go again! HOW TO PUBLISH RELEASE USING RELEASES REPO: diff --git a/Installer/install_script_NET6_x64.nsi b/Installer/install_script_x64.nsi similarity index 99% rename from Installer/install_script_NET6_x64.nsi rename to Installer/install_script_x64.nsi index 9081fa5b9f..641616744f 100644 --- a/Installer/install_script_NET6_x64.nsi +++ b/Installer/install_script_x64.nsi @@ -3,7 +3,7 @@ !include WinVer.nsh !include x64.nsh -!cd "..\BuildRelease (x64)\net6.0-windows" +!cd "..\BuildRelease (x64)\net8.0-windows" !define MUI_COMPONENTSPAGE_SMALLDESC !define MUI_ABORTWARNING @@ -13,16 +13,13 @@ !define MUI_ICON "..\..\Icons\ICO\TE.ico" !define MUI_FINISHPAGE_SHOWREADME "Changes.txt" -!define DOT_MAJOR "6" -!define DOT_MINOR "0" - !define MUI_WELCOMEPAGE_TEXT \ "You are ready to install Tomb Editor ${Version_1}.${Version_2}.${Version_3}. $\r$\n\ $\r$\n\ Please make sure your system complies with following system requirements: $\r$\n\ $\r$\n\ - ${U+2022} Windows 7 or later (64-bit) $\r$\n\ - ${U+2022} Installed .NET 6 or later (64-bit)$\r$\n\ + ${U+2022} Windows 10 or later (64-bit) $\r$\n\ + ${U+2022} Installed .NET 8 Desktop Runtime or later (64-bit)$\r$\n\ ${U+2022} Videocard with DirectX 10 support $\r$\n\ ${U+2022} At least 2 gigabytes of RAM $\r$\n\ $\r$\n\ @@ -75,7 +72,7 @@ Section "Tomb Editor" Section1 /x "*.pdb" \ /x "*.so" \ /x "*.vshost.*" \ - /x "install_script.nsi" \ + /x "install_script_x64.nsi" \ /x "TombEditorInstall.exe" \ /x "TombEditorConfiguration.xml" \ /x "SoundToolConfiguration.xml" \ diff --git a/LuaApiBuilder/LuaApiBuilder.csproj b/LuaApiBuilder/LuaApiBuilder.csproj index e74a4a1f77..8d526a4720 100644 --- a/LuaApiBuilder/LuaApiBuilder.csproj +++ b/LuaApiBuilder/LuaApiBuilder.csproj @@ -1,30 +1,8 @@  - Library - net6.0 enable enable - Debug;Release - x64;x86 - - - - ..\..\Build ($(Platform))\ - - - - ..\..\BuildRelease ($(Platform))\ - none - true - - - - x64 - - - - x86 diff --git a/NgXmlBuilder/NgXmlBuilder.csproj b/NgXmlBuilder/NgXmlBuilder.csproj index 4483ea8969..3d2e677899 100644 --- a/NgXmlBuilder/NgXmlBuilder.csproj +++ b/NgXmlBuilder/NgXmlBuilder.csproj @@ -1,24 +1,7 @@  - net6.0-windows Exe false - Debug;Release - x64;x86 - - - ..\BuildNgXmlBuilder ($(Platform))\ - - - ..\BuildNgXmlBuilderRelease ($(Platform))\ - none - true - - - x64 - - - x86 xml.ico diff --git a/SoundTool/SoundTool.csproj b/SoundTool/SoundTool.csproj index 844d37c439..b4fa0df6c5 100644 --- a/SoundTool/SoundTool.csproj +++ b/SoundTool/SoundTool.csproj @@ -1,26 +1,8 @@  - net6.0-windows WinExe false true - true - Debug;Release - x64;x86 - - - ..\Build ($(Platform))\ - - - ..\BuildRelease ($(Platform))\ - none - true - - - x64 - - - x86 ST.ico diff --git a/Tomb Editor.sln b/Tomb Editor.sln index a10bb5cede..025f30f07b 100644 --- a/Tomb Editor.sln +++ b/Tomb Editor.sln @@ -52,6 +52,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TombLib.Scripting.ClassicSc EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TombLib.Scripting.Lua", "TombLib\TombLib.Scripting.Lua\TombLib.Scripting.Lua.csproj", "{87D4149C-E716-49F6-848F-ACA345D11B30}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TombLib.Scripting.Lua.LanguageServer", "TombLib\TombLib.Scripting.Lua.LanguageServer\TombLib.Scripting.Lua.LanguageServer.csproj", "{BAC24926-D29E-4E42-905B-CDBAB29C0B53}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileAssociation", "FileAssociation\FileAssociation.csproj", "{934E79A8-2B20-4E0E-A145-08404493A7F6}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TombLib.Scripting.GameFlowScript", "TombLib\TombLib.Scripting.GameFlowScript\TombLib.Scripting.GameFlowScript.csproj", "{98E13BC4-6196-4346-AB4F-92DE33D84589}" @@ -216,6 +218,14 @@ Global {87D4149C-E716-49F6-848F-ACA345D11B30}.Release|x64.Build.0 = Release|x64 {87D4149C-E716-49F6-848F-ACA345D11B30}.Release|x86.ActiveCfg = Release|x86 {87D4149C-E716-49F6-848F-ACA345D11B30}.Release|x86.Build.0 = Release|x86 + {BAC24926-D29E-4E42-905B-CDBAB29C0B53}.Debug|x64.ActiveCfg = Debug|x64 + {BAC24926-D29E-4E42-905B-CDBAB29C0B53}.Debug|x64.Build.0 = Debug|x64 + {BAC24926-D29E-4E42-905B-CDBAB29C0B53}.Debug|x86.ActiveCfg = Debug|x86 + {BAC24926-D29E-4E42-905B-CDBAB29C0B53}.Debug|x86.Build.0 = Debug|x86 + {BAC24926-D29E-4E42-905B-CDBAB29C0B53}.Release|x64.ActiveCfg = Release|x64 + {BAC24926-D29E-4E42-905B-CDBAB29C0B53}.Release|x64.Build.0 = Release|x64 + {BAC24926-D29E-4E42-905B-CDBAB29C0B53}.Release|x86.ActiveCfg = Release|x86 + {BAC24926-D29E-4E42-905B-CDBAB29C0B53}.Release|x86.Build.0 = Release|x86 {934E79A8-2B20-4E0E-A145-08404493A7F6}.Debug|x64.ActiveCfg = Debug|x64 {934E79A8-2B20-4E0E-A145-08404493A7F6}.Debug|x64.Build.0 = Debug|x64 {934E79A8-2B20-4E0E-A145-08404493A7F6}.Debug|x86.ActiveCfg = Debug|x86 @@ -306,6 +316,7 @@ Global {3EAAFD71-DD96-427D-8793-643DADD2F3A3} = {642147AB-23FD-4715-AE26-90BE6C755FD0} {5D9A72E7-4B33-4177-AD7C-1B46591005FD} = {642147AB-23FD-4715-AE26-90BE6C755FD0} {87D4149C-E716-49F6-848F-ACA345D11B30} = {642147AB-23FD-4715-AE26-90BE6C755FD0} + {BAC24926-D29E-4E42-905B-CDBAB29C0B53} = {642147AB-23FD-4715-AE26-90BE6C755FD0} {98E13BC4-6196-4346-AB4F-92DE33D84589} = {642147AB-23FD-4715-AE26-90BE6C755FD0} {7987FD7B-AAC9-4C7F-8188-C54FDE35C3D6} = {642147AB-23FD-4715-AE26-90BE6C755FD0} {61E45B12-B972-136D-6066-1CD28A5429E1} = {6F5F6BB9-64D2-4F82-B6AE-162361DF4965} diff --git a/TombEditor.Tests/TombEditor.Tests.csproj b/TombEditor.Tests/TombEditor.Tests.csproj index f0ad47e018..92b3dd0927 100644 --- a/TombEditor.Tests/TombEditor.Tests.csproj +++ b/TombEditor.Tests/TombEditor.Tests.csproj @@ -1,8 +1,6 @@ - net6.0-windows - 12 enable enable true @@ -10,18 +8,6 @@ false true - x64;x86 - - - - none - true - - - x64 - - - x86 diff --git a/TombEditor/TombEditor.csproj b/TombEditor/TombEditor.csproj index 6948354235..368533537a 100644 --- a/TombEditor/TombEditor.csproj +++ b/TombEditor/TombEditor.csproj @@ -1,28 +1,9 @@  - net6.0-windows - 12 WinExe false true true - true - Debug;Release - x64;x86 - - - ..\Build ($(Platform))\ - - - ..\BuildRelease ($(Platform))\ - none - true - - - x64 - - - x86 TE.ico diff --git a/TombIDE/TombIDE.ProjectMaster/TombIDE.ProjectMaster.csproj b/TombIDE/TombIDE.ProjectMaster/TombIDE.ProjectMaster.csproj index 6470e3e658..d8f5d96ffe 100644 --- a/TombIDE/TombIDE.ProjectMaster/TombIDE.ProjectMaster.csproj +++ b/TombIDE/TombIDE.ProjectMaster/TombIDE.ProjectMaster.csproj @@ -1,29 +1,9 @@  - net6.0-windows - 12 - Library false true - true - Debug;Release - x64;x86 enable - - ..\..\Build ($(Platform))\ - - - ..\..\BuildRelease ($(Platform))\ - none - true - - - x64 - - - x86 - ..\..\Libs\CustomTabControl.dll diff --git a/TombIDE/TombIDE.REGSVR/TombIDE.REGSVR.csproj b/TombIDE/TombIDE.REGSVR/TombIDE.REGSVR.csproj index f04c35af9b..3582bbe966 100644 --- a/TombIDE/TombIDE.REGSVR/TombIDE.REGSVR.csproj +++ b/TombIDE/TombIDE.REGSVR/TombIDE.REGSVR.csproj @@ -1,25 +1,8 @@  - net6.0-windows Exe TombIDE Library Registration false - Debug;Release - x64;x86 - - - ..\..\Build ($(Platform))\ - - - ..\..\BuildRelease ($(Platform))\ - none - true - - - x64 - - - x86 app.manifest diff --git a/TombIDE/TombIDE.ScriptingStudio/Bases/StudioBase.cs b/TombIDE/TombIDE.ScriptingStudio/Bases/StudioBase.cs index c4bf0fe0a2..05b3ddc76f 100644 --- a/TombIDE/TombIDE.ScriptingStudio/Bases/StudioBase.cs +++ b/TombIDE/TombIDE.ScriptingStudio/Bases/StudioBase.cs @@ -108,6 +108,8 @@ public string ScriptRootDirectoryPath protected ToolStripMenuItem ReferenceBrowserViewItem; protected ToolStripMenuItem CompilerLogsViewItem; protected ToolStripMenuItem SearchResultsViewItem; + protected ToolStripMenuItem LuaDiagnosticsViewItem; + protected ToolStripMenuItem LuaReferencesResultsViewItem; protected ToolStripMenuItem StatusStripViewItem; #endregion Fields @@ -125,7 +127,7 @@ public StudioBase(string scriptRootDirectoryPath, string engineDirectoryPath) InitializeFindReplaceForm(); CompilerLogs = new CompilerLogs(); - SearchResults = new SearchResults(EditorTabControl); + SearchResults = new SearchResults(NavigateToSearchResult); IDE.Instance.IDEEventRaised += OnIDEEventRaised; @@ -218,6 +220,7 @@ private void InitializeDockPanel() // Apply the current layout DockPanel.RestoreDockPanelState(DockPanelState, FindDockContentByKey); + OnDockPanelLayoutRestored(); ApplyMessageFilters(); } @@ -236,6 +239,8 @@ private void InitializeFrequentlyAccessedMenuStripItems() ReferenceBrowserViewItem = MenuStrip.FindItem(UICommand.ReferenceBrowser) as ToolStripMenuItem; CompilerLogsViewItem = MenuStrip.FindItem(UICommand.CompilerLogs) as ToolStripMenuItem; SearchResultsViewItem = MenuStrip.FindItem(UICommand.SearchResults) as ToolStripMenuItem; + LuaDiagnosticsViewItem = MenuStrip.FindItem(UICommand.LuaDiagnostics) as ToolStripMenuItem; + LuaReferencesResultsViewItem = MenuStrip.FindItem(UICommand.LuaReferencesResults) as ToolStripMenuItem; StatusStripViewItem = MenuStrip.FindItem(UICommand.StatusStrip) as ToolStripMenuItem; } @@ -295,6 +300,21 @@ protected virtual void OnToolStripItemClicked(UICommand e) HandleDocumentCommands(e); } + protected virtual void OnDockPanelLayoutRestored() + { } + + protected virtual bool CanExecuteUndo() + => CurrentEditor is not null && CurrentEditor.CanUndo; + + protected virtual bool CanExecuteRedo() + => CurrentEditor is not null && CurrentEditor.CanRedo; + + protected virtual void ExecuteUndo() + => CurrentEditor?.Undo(); + + protected virtual void ExecuteRedo() + => CurrentEditor?.Redo(); + #endregion Virtual region #region Abstract region @@ -354,6 +374,23 @@ private void Editor_ContentChangedWorkerRunCompleted(object sender, EventArgs e) private void TextEditor_KeyDown(object sender, System.Windows.Input.KeyEventArgs e) { + if (System.Windows.Input.Keyboard.Modifiers == System.Windows.Input.ModifierKeys.Alt) + { + if (e.Key == System.Windows.Input.Key.Left) + { + OnToolStripItemClicked(UICommand.NavigateBack); + e.Handled = true; + return; + } + + if (e.Key == System.Windows.Input.Key.Right) + { + OnToolStripItemClicked(UICommand.NavigateForward); + e.Handled = true; + return; + } + } + if ((System.Windows.Input.Keyboard.Modifiers == System.Windows.Input.ModifierKeys.Control) && (e.Key == System.Windows.Input.Key.F || e.Key == System.Windows.Input.Key.H)) FindReplaceForm.Show(this, (CurrentEditor as TextEditorBase).SelectedText); @@ -424,14 +461,14 @@ protected void UpdateUI() protected void UpdateUndoRedoSaveStates() { // Undo buttons - UndoMenuItem.Enabled = CurrentEditor != null && CurrentEditor.CanUndo; + UndoMenuItem.Enabled = CanExecuteUndo(); UndoMenuItem.Text = UndoMenuItem.Enabled ? Strings.Default.Undo : Strings.Default.CantUndo; UndoToolStripButton.Enabled = UndoMenuItem.Enabled; UndoToolStripButton.ToolTipText = UndoMenuItem.Text; // Redo buttons - RedoMenuItem.Enabled = CurrentEditor != null && CurrentEditor.CanRedo; + RedoMenuItem.Enabled = CanExecuteRedo(); RedoMenuItem.Text = RedoMenuItem.Enabled ? Strings.Default.Redo : Strings.Default.CantRedo; RedoToolStripButton.Enabled = RedoMenuItem.Enabled; @@ -467,8 +504,8 @@ private void HandleGlobalCommands(UICommand command) case UICommand.Exit: IDE.Instance.RequestProgramClose(); break; // Edit - case UICommand.Undo: CurrentEditor?.Undo(); break; - case UICommand.Redo: CurrentEditor?.Redo(); break; + case UICommand.Undo: ExecuteUndo(); break; + case UICommand.Redo: ExecuteRedo(); break; case UICommand.Cut: CurrentEditor?.Cut(); break; case UICommand.Copy: CurrentEditor?.Copy(); break; case UICommand.Paste: CurrentEditor?.Paste(); break; @@ -506,6 +543,7 @@ protected virtual void HandleDocumentCommands(UICommand command) case UICommand.SpacesToTabs: textEditor.ConvertSpacesToTabs(); break; case UICommand.Reindent: textEditor.TidyCode(); break; case UICommand.TrimWhiteSpace: textEditor.TidyCode(true); break; + case UICommand.ToggleComment: textEditor.ToggleCommentLines(); break; case UICommand.CommentOut: textEditor.CommentOutLines(); break; case UICommand.Uncomment: textEditor.UncommentLines(); break; case UICommand.ToggleBookmark: textEditor.ToggleBookmark(); break; @@ -532,6 +570,23 @@ protected virtual void HandleDocumentCommands(UICommand command) } } + protected virtual void NavigateToSearchResult(string filePath, FindReplaceItem item) + { + if (string.IsNullOrWhiteSpace(filePath)) + return; + + EditorTabControl.OpenFile(filePath); + + if (CurrentEditor is not TextEditorBase textEditor) + return; + + if (!EditorNavigationHelper.TryCreateSearchResultLocation(textEditor, filePath, item, out EditorNavigationLocation? location) + || location is null) + return; + + EditorNavigationHelper.ApplyLocation(textEditor, location.Value); + } + protected void ToggleItemVisibility(UICommand command) { Control control = GetControlByKey(command.ToString()); diff --git a/TombIDE/TombIDE.ScriptingStudio/Controls/EditorTabControl.cs b/TombIDE/TombIDE.ScriptingStudio/Controls/EditorTabControl.cs index 4a0b82ad9e..2dcc4fd0e1 100644 --- a/TombIDE/TombIDE.ScriptingStudio/Controls/EditorTabControl.cs +++ b/TombIDE/TombIDE.ScriptingStudio/Controls/EditorTabControl.cs @@ -9,6 +9,7 @@ using System.Windows.Forms.Integration; using TombIDE.ScriptingStudio.Forms; using TombIDE.ScriptingStudio.Helpers; +using TombIDE.ScriptingStudio.Objects; using TombIDE.ScriptingStudio.Properties; using TombIDE.Shared; using TombIDE.Shared.SharedClasses; @@ -363,6 +364,7 @@ public FileSavingResult SaveFileAs() public FileSavingResult SaveFileAs(TabPage tab) { IEditorControl editor = GetEditorOfTab(tab); + string oldFilePath = editor.FilePath; string[] ignoredPaths = Array.Empty(); @@ -372,10 +374,37 @@ public FileSavingResult SaveFileAs(TabPage tab) using (var form = new FormFileCreation(ScriptRootDirectoryPath, FileCreationMode.SavingAs, editor.DefaultFileExtension, null, null, ignoredPaths)) if (form.ShowDialog(this) == DialogResult.OK) { + if (string.IsNullOrWhiteSpace(oldFilePath) + || oldFilePath.Equals(form.NewFilePath, StringComparison.OrdinalIgnoreCase)) + { + editor.FilePath = form.NewFilePath; + UpdateTabPageName(tab); + + return SaveFile(tab); + } + editor.FilePath = form.NewFilePath; UpdateTabPageName(tab); - return SaveFile(tab); + FileSavingResult result = SaveFile(tab); + + if (result == FileSavingResult.Success) + { + if (FindTabPagesOfFile(oldFilePath).Any()) + { + RenameDocumentTabPage(oldFilePath, form.NewFilePath); + SaveOtherTabPagesOfFile(editor); + } + else + OnDocumentRenamed(new DocumentRenamedEventArgs(oldFilePath, form.NewFilePath)); + } + else + { + editor.FilePath = oldFilePath; + UpdateTabPageName(tab); + } + + return result; } else return FileSavingResult.Canceled; @@ -520,6 +549,10 @@ public IEditorControl GetEditorOfTab(TabPage tab) protected virtual void OnFileOpened(EventArgs e) => FileOpened?.Invoke(CurrentEditor, e); + public event EventHandler DocumentRenamed; + protected virtual void OnDocumentRenamed(DocumentRenamedEventArgs e) + => DocumentRenamed?.Invoke(this, e); + protected override void OnTabClosing(TabControlCancelEventArgs e) { IEditorControl editorOfTab = GetEditorOfTab(e.TabPage); @@ -690,7 +723,17 @@ public void UpdateTabPageName(IEditorControl tabPageEditor) public void RenameDocumentTabPage(string oldFilePath, string newFilePath) { - IEnumerable tabPages = FindTabPagesOfFile(oldFilePath); + if (string.IsNullOrWhiteSpace(oldFilePath) + || string.IsNullOrWhiteSpace(newFilePath) + || oldFilePath.Equals(newFilePath, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + List tabPages = FindTabPagesOfFile(oldFilePath).ToList(); + + if (tabPages.Count == 0) + return; foreach (TabPage tab in tabPages) { @@ -699,6 +742,8 @@ public void RenameDocumentTabPage(string oldFilePath, string newFilePath) UpdateTabPageName(editor); } + + OnDocumentRenamed(new DocumentRenamedEventArgs(oldFilePath, newFilePath)); } private string BuildTabPageTitleText(string filePath, EditorType editorType) diff --git a/TombIDE/TombIDE.ScriptingStudio/Helpers/EditorNavigationHelper.cs b/TombIDE/TombIDE.ScriptingStudio/Helpers/EditorNavigationHelper.cs new file mode 100644 index 0000000000..fd18a9b735 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/Helpers/EditorNavigationHelper.cs @@ -0,0 +1,110 @@ +#nullable enable + +using System; +using System.Text.RegularExpressions; +using ICSharpCode.AvalonEdit.Document; +using TombIDE.ScriptingStudio.Objects; +using TombLib.Scripting.Bases; +using TombLib.Scripting.Lua.Objects; +using TombLib.Scripting.Objects; + +namespace TombIDE.ScriptingStudio.Helpers; + +internal static class EditorNavigationHelper +{ + public static EditorNavigationLocation CreateLocation(TextEditorBase textEditor) + => new( + textEditor.FilePath, + textEditor.CaretOffset, + textEditor.SelectionStart, + textEditor.SelectionLength, + textEditor.CurrentRow); + + public static EditorNavigationLocation CreateDefinitionLocation( + TextEditorBase textEditor, + string filePath, + int lineNumber, + int columnNumber) + { + int offset = GetOffset(textEditor, lineNumber, columnNumber); + + return new EditorNavigationLocation(filePath, offset, offset, 0, lineNumber); + } + + public static EditorNavigationLocation CreateRangeLocation( + TextEditorBase textEditor, + string filePath, + LuaDocumentRange range) + { + int startOffset = GetOffset(textEditor, range.StartLineNumber, range.StartColumnNumber); + int endOffset = GetOffset(textEditor, range.EndLineNumber, range.EndColumnNumber); + int selectionLength = Math.Max(0, endOffset - startOffset); + + return new EditorNavigationLocation(filePath, startOffset, startOffset, selectionLength, range.StartLineNumber); + } + + public static void ApplyLocation(TextEditorBase textEditor, EditorNavigationLocation location) + { + int documentLength = textEditor.Document.TextLength; + int selectionStart = Math.Max(0, Math.Min(location.SelectionStart, documentLength)); + int selectionLength = Math.Max(0, Math.Min(location.SelectionLength, documentLength - selectionStart)); + int caretOffset = Math.Max(0, Math.Min(location.CaretOffset, documentLength)); + + textEditor.Focus(); + textEditor.CaretOffset = caretOffset; + textEditor.Select(selectionStart, selectionLength); + textEditor.ScrollToLine(GetPreferredLine(textEditor, location, selectionStart, caretOffset)); + } + + public static bool TryCreateSearchResultLocation( + TextEditorBase textEditor, + string filePath, + FindReplaceItem item, + out EditorNavigationLocation? location) + { + location = null; + + if (item.LineNumber < 1 || item.LineNumber > textEditor.Document.LineCount) + return false; + + DocumentLine line = textEditor.Document.GetLineByNumber(item.LineNumber); + string lineText = textEditor.Document.GetText(line.Offset, line.Length); + MatchCollection matches = Regex.Matches(lineText, item.MatchSegmentText); + + if (item.MatchSegmentIndex < 0 || item.MatchSegmentIndex >= matches.Count) + { + location = new EditorNavigationLocation(filePath, line.Offset, line.Offset, 0, line.LineNumber); + return true; + } + + Match match = matches[item.MatchSegmentIndex]; + int selectionStart = line.Offset + match.Index; + + location = new EditorNavigationLocation(filePath, selectionStart, selectionStart, match.Length, line.LineNumber); + return true; + } + + private static int GetPreferredLine( + TextEditorBase textEditor, + EditorNavigationLocation location, + int selectionStart, + int caretOffset) + { + if (location.PreferredLine is int preferredLine) + return Math.Max(1, Math.Min(preferredLine, textEditor.Document.LineCount)); + + int offset = selectionStart > 0 || location.SelectionLength > 0 + ? selectionStart + : caretOffset; + + return textEditor.Document.GetLineByOffset(offset).LineNumber; + } + + private static int GetOffset(TextEditorBase textEditor, int lineNumber, int columnNumber) + { + int safeLineNumber = Math.Max(1, Math.Min(lineNumber, textEditor.Document.LineCount)); + DocumentLine documentLine = textEditor.Document.GetLineByNumber(safeLineNumber); + int safeColumnNumber = Math.Max(1, Math.Min(columnNumber, documentLine.Length + 1)); + return documentLine.Offset + safeColumnNumber - 1; + } +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/LuaStudio.Diagnostics.cs b/TombIDE/TombIDE.ScriptingStudio/LuaStudio.Diagnostics.cs new file mode 100644 index 0000000000..d0e4400f55 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/LuaStudio.Diagnostics.cs @@ -0,0 +1,67 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using TombIDE.ScriptingStudio.Objects; +using TombIDE.ScriptingStudio.ToolWindows; +using TombLib.Scripting.Lua; +using TombLib.Scripting.Objects; + +namespace TombIDE.ScriptingStudio; + +public sealed partial class LuaStudio +{ + public LuaDiagnostics LuaDiagnostics = null!; + + private void InitializeLuaDiagnostics() + { + LuaDiagnostics = new LuaDiagnostics(NavigateToDiagnostic); + } + + private void EditorTabControl_LuaSelectedIndexChanged(object? sender, EventArgs e) + { + RefreshLuaDiagnosticsView(); + UpdateLuaFeatureCommandAvailability(); + } + + private void LuaEditor_TextChanged(object? sender, EventArgs e) + { + InvalidateWorkspaceEditHistory(); + + if (!ReferenceEquals(sender, CurrentEditor)) + return; + + RefreshLuaDiagnosticsView(isPending: true); + } + + private void RefreshLuaDiagnosticsView(bool isPending = false, IReadOnlyList? diagnostics = null) + { + if (CurrentEditor is not LuaEditor editor) + { + LuaDiagnostics.ShowNoActiveDocument(); + return; + } + + if (isPending) + { + LuaDiagnostics.ShowPending(); + return; + } + + LuaDiagnostics.ShowDiagnostics( + editor.FilePath, + editor.Document, + diagnostics ?? _intellisenseProvider.GetDiagnostics(editor.FilePath)); + } + + private void NavigateToDiagnostic(LuaDiagnosticListItem diagnostic) + => NavigateToLocation( + diagnostic.FilePath, + NavigationOrigin.Diagnostics, + _ => new EditorNavigationLocation( + diagnostic.FilePath, + diagnostic.StartOffset, + diagnostic.StartOffset, + Math.Max(0, diagnostic.EndOffset - diagnostic.StartOffset), + diagnostic.LineNumber)); +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/LuaStudio.Formatting.cs b/TombIDE/TombIDE.ScriptingStudio/LuaStudio.Formatting.cs new file mode 100644 index 0000000000..50eee0d4a3 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/LuaStudio.Formatting.cs @@ -0,0 +1,74 @@ +#nullable enable + +using DarkUI.Forms; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Forms; +using TombIDE.ScriptingStudio.Services; +using TombIDE.Shared; +using TombLib.Scripting.Lua; +using TombLib.Scripting.Lua.Objects; + +namespace TombIDE.ScriptingStudio; + +public sealed partial class LuaStudio +{ + private async Task ReformatDocumentAsync() + { + if (CurrentEditor is not LuaEditor editor) + return; + + if (!_intellisenseProvider.SupportsFormatting) + { + DarkMessageBox.Show(this, + Strings.Default.LuaReformatUnsupported, + Strings.Default.Reindent, + MessageBoxButtons.OK, + MessageBoxIcon.Information); + return; + } + + try + { + LuaFormattingOptions formattingOptions = CreateFormattingOptions(editor); + IReadOnlyList textEdits = await _intellisenseProvider + .FormatDocumentAsync(editor.FilePath, editor.Text, formattingOptions) + .ConfigureAwait(true); + + if (textEdits.Count == 0) + return; + + LuaWorkspaceEdit workspaceEdit = new([ + new LuaDocumentEdit(editor.FilePath, textEdits) + ]); + + LuaWorkspaceEditSelectionState selectionState = LuaWorkspaceEditSelectionState.Capture(editor); + LuaWorkspaceEditTransaction transaction = _workspaceEditApplier.Apply(workspaceEdit, selectionState); + + if (!transaction.HasChanges) + return; + + PushWorkspaceEditTransaction(transaction); + HandleWorkspaceDocumentsChanged(transaction.DocumentChanges.Select(documentChange => documentChange.FilePath)); + } + catch (OperationCanceledException) + { + // Ignore canceled formatting requests. + } + catch (Exception ex) + { + DarkMessageBox.Show(this, ex.Message, Strings.Default.Reindent, MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + private static LuaFormattingOptions CreateFormattingOptions(LuaEditor editor) + { + int tabSize = editor.Options.IndentationSize > 0 + ? editor.Options.IndentationSize + : 4; + + return new LuaFormattingOptions(tabSize, editor.Options.ConvertTabsToSpaces); + } +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/LuaStudio.Intellisense.cs b/TombIDE/TombIDE.ScriptingStudio/LuaStudio.Intellisense.cs new file mode 100644 index 0000000000..b47475c943 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/LuaStudio.Intellisense.cs @@ -0,0 +1,255 @@ +#nullable enable + +using ICSharpCode.AvalonEdit.Document; +using NLog; +using System; +using System.Collections.Generic; +using System.IO; +using System.Windows.Forms; +using LuaLanguageServerLocator = TombIDE.ScriptingStudio.Services.LuaIntellisense.LuaLanguageServerLocator; +using TombIDE.ScriptingStudio.Helpers; +using TombIDE.ScriptingStudio.Objects; +using TombLib.Scripting.Bases; +using TombLib.Scripting.Lua.LanguageServer; +using TombLib.Scripting.Lua; +using TombLib.Scripting.Lua.Objects; +using TombLib.Scripting.Lua.Services; +using TombLib.Scripting.Objects; + +namespace TombIDE.ScriptingStudio; + +public sealed partial class LuaStudio +{ + private static readonly Logger Log = LogManager.GetCurrentClassLogger(); + + private readonly ILuaIntellisenseProvider _intellisenseProvider; + + private void HookLuaIntellisense() + { + EditorTabControl.FileOpened -= EditorTabControl_LuaFileOpened; + EditorTabControl.FileOpened += EditorTabControl_LuaFileOpened; + EditorTabControl.SelectedIndexChanged -= EditorTabControl_LuaSelectedIndexChanged; + EditorTabControl.SelectedIndexChanged += EditorTabControl_LuaSelectedIndexChanged; + EditorTabControl.DocumentRenamed -= EditorTabControl_DocumentRenamed; + EditorTabControl.DocumentRenamed += EditorTabControl_DocumentRenamed; + + _intellisenseProvider.DiagnosticsUpdated -= IntellisenseProvider_DiagnosticsUpdated; + _intellisenseProvider.DiagnosticsUpdated += IntellisenseProvider_DiagnosticsUpdated; + _intellisenseProvider.SemanticTokensUpdated -= IntellisenseProvider_SemanticTokensUpdated; + _intellisenseProvider.SemanticTokensUpdated += IntellisenseProvider_SemanticTokensUpdated; + + if (_intellisenseProvider is LuaLanguageServerIntellisenseProvider languageServerProvider) + { + languageServerProvider.StartupFailed -= IntellisenseProvider_StartupFailed; + languageServerProvider.StartupFailed += IntellisenseProvider_StartupFailed; + languageServerProvider.WorkspaceWatcherFailed -= IntellisenseProvider_WorkspaceWatcherFailed; + languageServerProvider.WorkspaceWatcherFailed += IntellisenseProvider_WorkspaceWatcherFailed; + } + + UpdateLuaFeatureCommandAvailability(); + } + + private void DisposeLuaIntellisense() + { + CancelPendingReferenceRequest(); + + EditorTabControl.FileOpened -= EditorTabControl_LuaFileOpened; + EditorTabControl.SelectedIndexChanged -= EditorTabControl_LuaSelectedIndexChanged; + EditorTabControl.DocumentRenamed -= EditorTabControl_DocumentRenamed; + _intellisenseProvider.DiagnosticsUpdated -= IntellisenseProvider_DiagnosticsUpdated; + _intellisenseProvider.SemanticTokensUpdated -= IntellisenseProvider_SemanticTokensUpdated; + + if (_intellisenseProvider is LuaLanguageServerIntellisenseProvider languageServerProvider) + { + languageServerProvider.StartupFailed -= IntellisenseProvider_StartupFailed; + languageServerProvider.WorkspaceWatcherFailed -= IntellisenseProvider_WorkspaceWatcherFailed; + } + + _intellisenseProvider.Dispose(); + } + + private ILuaIntellisenseProvider CreateLuaIntellisenseProvider() + { + string? executablePath = LuaLanguageServerLocator.ResolveExecutablePath(); + + if (string.IsNullOrWhiteSpace(executablePath)) + { + // LuaLS is shipped with TombIDE; if the bundled binary is missing the user has no way + // of knowing why diagnostics, completion, definition and references silently stop working. + // Log a warning and surface a single non-blocking notification so the failure is visible. + Log.Warn("Bundled Lua language server was not found; Lua IntelliSense (diagnostics, completion, hover, go-to-definition and find references) will be unavailable for this session."); + + MessageBox.Show(this, + "The bundled Lua language server (LuaLS) could not be located.\n\n" + + "Lua IntelliSense - including diagnostics, completion, hover, go-to-definition and find references - will be unavailable for this session.\n\n" + + "Reinstall TombIDE to restore the bundled language server.", + "Lua IntelliSense unavailable", + MessageBoxButtons.OK, + MessageBoxIcon.Warning); + } + + return new LuaLanguageServerIntellisenseProvider(ScriptRootDirectoryPath, executablePath); + } + + private void EditorTabControl_LuaFileOpened(object? sender, EventArgs e) + { + if (sender is not LuaEditor editor) + return; + + editor.IntellisenseProvider = _intellisenseProvider; + editor.DefinitionNavigationRequested -= NavigateToDefinition; + editor.DefinitionNavigationRequested += NavigateToDefinition; + editor.StatusChanged -= LuaEditor_StatusChanged; + editor.StatusChanged += LuaEditor_StatusChanged; + editor.TextChanged -= LuaEditor_TextChanged; + editor.TextChanged += LuaEditor_TextChanged; + editor.TextChangedDelayed -= LuaEditor_TextChangedDelayed; + editor.TextChangedDelayed += LuaEditor_TextChangedDelayed; + + _intellisenseProvider.OpenDocument(editor.FilePath, editor.Text); + ApplyDiagnosticsToEditor(editor, _intellisenseProvider.GetDiagnostics(editor.FilePath)); + ApplySemanticTokensToEditor(editor, _intellisenseProvider.GetSemanticTokens(editor.FilePath)); + RefreshLuaDiagnosticsView(); + UpdateLuaFeatureCommandAvailability(); + } + + private void LuaEditor_TextChangedDelayed(object? sender, EventArgs e) + { + if (sender is LuaEditor editor) + _intellisenseProvider.UpdateDocument(editor.FilePath, editor.Text); + } + + private void EditorTabControl_DocumentRenamed(object? sender, DocumentRenamedEventArgs e) + { + LuaEditor? editor = null; + + foreach (TabPage tabPage in EditorTabControl.FindTabPagesOfFile(e.NewFilePath)) + { + if (EditorTabControl.GetEditorOfTab(tabPage) is LuaEditor luaEditor) + { + editor = luaEditor; + break; + } + } + + if (editor is null) + return; + + _intellisenseProvider.RenameDocument(e.OldFilePath, e.NewFilePath, editor.Text); + + if (ReferenceEquals(CurrentEditor, editor)) + RefreshLuaDiagnosticsView(); + } + + private void IntellisenseProvider_DiagnosticsUpdated(string filePath, IReadOnlyList diagnostics) + { + if (InvokeRequired) + { + BeginInvoke(new Action>(IntellisenseProvider_DiagnosticsUpdated), filePath, diagnostics); + return; + } + + foreach (TabPage tabPage in EditorTabControl.FindTabPagesOfFile(filePath)) + { + if (EditorTabControl.GetEditorOfTab(tabPage) is LuaEditor editor) + ApplyDiagnosticsToEditor(editor, diagnostics); + } + + if (CurrentEditor is LuaEditor currentEditor + && string.Equals(currentEditor.FilePath, filePath, StringComparison.OrdinalIgnoreCase)) + { + RefreshLuaDiagnosticsView(diagnostics: diagnostics); + } + + UpdateLuaFeatureCommandAvailability(); + } + + private void IntellisenseProvider_SemanticTokensUpdated(string filePath, IReadOnlyList semanticTokens) + { + if (InvokeRequired) + { + BeginInvoke(new Action>(IntellisenseProvider_SemanticTokensUpdated), filePath, semanticTokens); + return; + } + + foreach (TabPage tabPage in EditorTabControl.FindTabPagesOfFile(filePath)) + { + if (EditorTabControl.GetEditorOfTab(tabPage) is LuaEditor editor) + ApplySemanticTokensToEditor(editor, semanticTokens); + } + + UpdateLuaFeatureCommandAvailability(); + } + + private void IntellisenseProvider_StartupFailed(LuaLanguageServerStartupFailure failure) + { + if (InvokeRequired) + { + BeginInvoke(new Action(IntellisenseProvider_StartupFailed), failure); + return; + } + + if (IsDisposed) + return; + + MessageBox.Show(this, + failure.Message, + failure.IsPersistent ? "Lua IntelliSense disabled" : "Lua IntelliSense unavailable", + MessageBoxButtons.OK, + MessageBoxIcon.Warning); + } + + private void IntellisenseProvider_WorkspaceWatcherFailed(LuaWorkspaceWatcherFailure failure) + { + if (InvokeRequired) + { + BeginInvoke(new Action(IntellisenseProvider_WorkspaceWatcherFailed), failure); + return; + } + + if (IsDisposed) + return; + + MessageBox.Show(this, + failure.Message, + "Lua workspace watching disabled", + MessageBoxButtons.OK, + MessageBoxIcon.Warning); + } + + private void NavigateToDefinition(LuaDefinitionLocation definitionLocation) + { + if (definitionLocation is null || string.IsNullOrWhiteSpace(definitionLocation.FilePath) || !File.Exists(definitionLocation.FilePath)) + return; + + NavigateToLocation( + definitionLocation.FilePath, + NavigationOrigin.Definition, + editor => EditorNavigationHelper.CreateDefinitionLocation( + editor, + definitionLocation.FilePath, + definitionLocation.LineNumber, + definitionLocation.ColumnNumber)); + } + + private static void ApplyDiagnosticsToEditor(LuaEditor editor, IReadOnlyList diagnostics) + => editor.SetDiagnostics(editor.LiveErrorUnderlining ? diagnostics : []); + + private static void ApplySemanticTokensToEditor(LuaEditor editor, IReadOnlyList semanticTokens) + => editor.SetSemanticTokens(semanticTokens ?? []); + + private void ApplyTrackedDocumentStateToEditors(string filePath) + { + IReadOnlyList diagnostics = _intellisenseProvider.GetDiagnostics(filePath); + IReadOnlyList semanticTokens = _intellisenseProvider.GetSemanticTokens(filePath); + + foreach (TabPage tabPage in EditorTabControl.FindTabPagesOfFile(filePath)) + { + if (EditorTabControl.GetEditorOfTab(tabPage) is LuaEditor editor) + { + ApplyDiagnosticsToEditor(editor, diagnostics); + ApplySemanticTokensToEditor(editor, semanticTokens); + } + } + } +} diff --git a/TombIDE/TombIDE.ScriptingStudio/LuaStudio.Navigation.cs b/TombIDE/TombIDE.ScriptingStudio/LuaStudio.Navigation.cs new file mode 100644 index 0000000000..c2950235f7 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/LuaStudio.Navigation.cs @@ -0,0 +1,114 @@ +#nullable enable + +using System; +using System.IO; +using TombIDE.ScriptingStudio.Helpers; +using TombIDE.ScriptingStudio.Objects; +using TombIDE.ScriptingStudio.Services; +using TombLib.Scripting.Bases; +using TombLib.Scripting.Lua; +using TombLib.Scripting.Objects; + +namespace TombIDE.ScriptingStudio; + +public sealed partial class LuaStudio +{ + private readonly EditorNavigationHistoryService _navigationHistory = new(); + + private enum NavigationOrigin + { + Definition, + Diagnostics, + References, + SearchResults, + HistoryBack, + HistoryForward + } + + private void LuaEditor_StatusChanged(object? sender, EventArgs e) + { + if (sender is not LuaEditor editor) + return; + + _navigationHistory.Observe(EditorNavigationHelper.CreateLocation(editor)); + } + + private void NavigateBack() + { + if (TryGetCurrentNavigationLocation() is not EditorNavigationLocation currentLocation) + return; + + if (!_navigationHistory.TryNavigateBack(currentLocation, out EditorNavigationLocation? targetLocation) + || targetLocation is null) + return; + + NavigateToLocation( + targetLocation.Value.FilePath, + NavigationOrigin.HistoryBack, + _ => targetLocation.Value); + } + + private void NavigateForward() + { + if (TryGetCurrentNavigationLocation() is not EditorNavigationLocation currentLocation) + return; + + if (!_navigationHistory.TryNavigateForward(currentLocation, out EditorNavigationLocation? targetLocation) + || targetLocation is null) + return; + + NavigateToLocation( + targetLocation.Value.FilePath, + NavigationOrigin.HistoryForward, + _ => targetLocation.Value); + } + + private EditorNavigationLocation? TryGetCurrentNavigationLocation() + { + if (CurrentEditor is not TextEditorBase textEditor || string.IsNullOrWhiteSpace(textEditor.FilePath)) + return null; + + return EditorNavigationHelper.CreateLocation(textEditor); + } + + private void NavigateToLocation( + string filePath, + NavigationOrigin origin, + Func locationFactory, + EditorNavigationLocation? sourceLocation = null) + { + if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath)) + return; + + EditorNavigationLocation? currentLocation = sourceLocation ?? TryGetCurrentNavigationLocation(); + + using IDisposable suppression = _navigationHistory.SuppressRecording(); + + EditorTabControl.OpenFile(filePath); + + if (CurrentEditor is not TextEditorBase textEditor) + return; + + EditorNavigationLocation? targetLocation = locationFactory(textEditor); + if (targetLocation is null) + return; + + if (origin is NavigationOrigin.Definition or NavigationOrigin.Diagnostics or NavigationOrigin.References or NavigationOrigin.SearchResults + && currentLocation is EditorNavigationLocation source + && !source.IsEquivalentTo(targetLocation.Value)) + { + _navigationHistory.RecordProgrammaticJump(source, targetLocation.Value); + } + + EditorNavigationHelper.ApplyLocation(textEditor, targetLocation.Value); + _navigationHistory.SetCurrentLocation(EditorNavigationHelper.CreateLocation(textEditor)); + } + + protected override void NavigateToSearchResult(string filePath, FindReplaceItem item) + => NavigateToLocation( + filePath, + NavigationOrigin.SearchResults, + textEditor => EditorNavigationHelper.TryCreateSearchResultLocation(textEditor, filePath, item, out EditorNavigationLocation? location) + ? location + : null); +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/LuaStudio.References.cs b/TombIDE/TombIDE.ScriptingStudio/LuaStudio.References.cs new file mode 100644 index 0000000000..1b414205b2 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/LuaStudio.References.cs @@ -0,0 +1,216 @@ +#nullable enable + +using DarkUI.Docking; +using ICSharpCode.AvalonEdit.Document; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Forms; +using TombIDE.ScriptingStudio.Helpers; +using TombIDE.ScriptingStudio.Objects; +using TombIDE.ScriptingStudio.ToolStrips; +using TombIDE.ScriptingStudio.ToolWindows; +using TombIDE.ScriptingStudio.UI; +using TombLib.Scripting.Bases; +using TombLib.Scripting.Enums; +using TombLib.Scripting.Lua; +using TombLib.Scripting.Lua.Objects; + +namespace TombIDE.ScriptingStudio; + +public sealed partial class LuaStudio +{ + public LuaReferencesResults LuaReferencesResults = null!; + + private CancellationTokenSource? _referencesCancellationTokenSource; + private int _referencesRequestToken; + + private void InitializeLuaReferencesResults() + { + LuaReferencesResults = new LuaReferencesResults(NavigateToReference); + } + + private async Task FindReferencesAsync() + { + ShowLuaReferencesResults(); + + if (CurrentEditor is not LuaEditor editor) + { + LuaReferencesResults.ShowNoActiveDocument(); + return; + } + + if (!_intellisenseProvider.SupportsReferences) + { + LuaReferencesResults.ShowUnsupported(); + return; + } + + CancellationToken cancellationToken = ResetReferenceRequestCancellation(); + int requestToken = ++_referencesRequestToken; + LuaReferencesResults.ShowLoading(); + + try + { + IReadOnlyList references = await _intellisenseProvider + .GetReferencesAsync( + editor.FilePath, + editor.Text, + Math.Max(0, editor.CurrentRow - 1), + Math.Max(0, editor.CurrentColumn - 1), + cancellationToken) + .ConfigureAwait(true); + + if (cancellationToken.IsCancellationRequested || requestToken != _referencesRequestToken) + return; + + if (references.Count == 0 && !_intellisenseProvider.SupportsReferences) + { + LuaReferencesResults.ShowUnsupported(); + UpdateLuaFeatureCommandAvailability(); + return; + } + + LuaReferencesResults.ShowReferences(BuildReferenceGroups(references)); + } + catch (OperationCanceledException) + { + // Ignore stale reference requests. + } + } + + private void CancelPendingReferenceRequest() + { + _referencesCancellationTokenSource?.Cancel(); + _referencesCancellationTokenSource?.Dispose(); + _referencesCancellationTokenSource = null; + } + + private void UpdateLuaFeatureCommandAvailability() + { + bool hasActiveTextEditor = CurrentEditor is TextEditorBase; + bool hasActiveLuaEditor = CurrentEditor is LuaEditor; + + SetCommandEnabled(UICommand.FindReferences, hasActiveLuaEditor && _intellisenseProvider.SupportsReferences); + SetCommandEnabled(UICommand.RenameSymbol, hasActiveLuaEditor && _intellisenseProvider.SupportsRename); + SetCommandEnabled(UICommand.Reindent, hasActiveLuaEditor ? _intellisenseProvider.SupportsFormatting : hasActiveTextEditor); + } + + private void SetCommandEnabled(UICommand command, bool isEnabled) + { + if (MenuStrip.FindItem(command) is ToolStripItem menuItem) + menuItem.Enabled = isEnabled; + + if (EditorContextMenu.FindItem(command) is ToolStripItem contextMenuItem) + contextMenuItem.Enabled = isEnabled; + } + + private void ShowLuaReferencesResults() + { + if (!DockPanel.ContainsContent(LuaReferencesResults)) + { + LuaReferencesResults.DockArea = DarkDockArea.Bottom; + DockPanel.AddContent(LuaReferencesResults); + } + + LuaReferencesResults.DockGroup.SetVisibleContent(LuaReferencesResults); + } + + private void NavigateToReference(LuaReferenceListItem reference) + => NavigateToLocation( + reference.FilePath, + NavigationOrigin.References, + textEditor => EditorNavigationHelper.CreateRangeLocation(textEditor, reference.FilePath, reference.Range)); + + private IReadOnlyList BuildReferenceGroups(IReadOnlyList references) + { + if (references.Count == 0) + return []; + + var lineCache = new Dictionary(StringComparer.OrdinalIgnoreCase); + var groups = new List(); + + foreach (IGrouping fileGroup in references + .Where(reference => !string.IsNullOrWhiteSpace(reference.FilePath)) + .GroupBy(reference => reference.FilePath, StringComparer.OrdinalIgnoreCase) + .OrderBy(group => GetDisplayPath(group.Key), StringComparer.OrdinalIgnoreCase)) + { + var items = fileGroup + .OrderBy(reference => reference.Range.StartLineNumber) + .ThenBy(reference => reference.Range.StartColumnNumber) + .Select(reference => new LuaReferenceListItem( + reference.FilePath, + reference.Range, + reference.Range.StartLineNumber, + reference.Range.StartColumnNumber, + GetPreviewText(reference.FilePath, reference.Range.StartLineNumber, lineCache))) + .ToArray(); + + groups.Add(new LuaReferenceGroup(fileGroup.Key, GetDisplayPath(fileGroup.Key), items)); + } + + return groups; + } + + private string GetDisplayPath(string filePath) + { + string fullFilePath = Path.GetFullPath(filePath); + string fullScriptRootPath = Path.GetFullPath(ScriptRootDirectoryPath); + + if (!fullScriptRootPath.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)) + fullScriptRootPath += Path.DirectorySeparatorChar; + + if (fullFilePath.StartsWith(fullScriptRootPath, StringComparison.OrdinalIgnoreCase)) + return Path.GetRelativePath(fullScriptRootPath, fullFilePath); + + return fullFilePath; + } + + private string GetPreviewText(string filePath, int lineNumber, Dictionary lineCache) + { + string? previewText = TryGetOpenEditorLineText(filePath, lineNumber); + + if (previewText is null) + { + if (!lineCache.TryGetValue(filePath, out string[]? lines)) + { + lines = File.Exists(filePath) ? File.ReadAllLines(filePath) : null; + lineCache[filePath] = lines; + } + + if (lines is not null && lineNumber >= 1 && lineNumber <= lines.Length) + previewText = lines[lineNumber - 1]; + } + + return previewText?.Trim() ?? string.Empty; + } + + private string? TryGetOpenEditorLineText(string filePath, int lineNumber) + { + TabPage? tabPage = EditorTabControl.FindTabPage(filePath, EditorType.Text); + + if (tabPage is null || EditorTabControl.GetEditorOfTab(tabPage) is not TextEditorBase textEditor) + return null; + + return TryGetDocumentLineText(textEditor.Document, lineNumber); + } + + private static string? TryGetDocumentLineText(TextDocument document, int lineNumber) + { + if (lineNumber < 1 || lineNumber > document.LineCount) + return null; + + DocumentLine line = document.GetLineByNumber(lineNumber); + return document.GetText(line.Offset, line.Length); + } + + private CancellationToken ResetReferenceRequestCancellation() + { + CancelPendingReferenceRequest(); + _referencesCancellationTokenSource = new CancellationTokenSource(); + return _referencesCancellationTokenSource.Token; + } +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/LuaStudio.Rename.cs b/TombIDE/TombIDE.ScriptingStudio/LuaStudio.Rename.cs new file mode 100644 index 0000000000..cde3c53dba --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/LuaStudio.Rename.cs @@ -0,0 +1,190 @@ +#nullable enable + +using DarkUI.Forms; +using ICSharpCode.AvalonEdit.Document; +using System; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Forms; +using System.Windows.Interop; +using TombIDE.ScriptingStudio.Services; +using TombIDE.Shared; +using TombLib.Forms.ViewModels; +using TombLib.Forms.Views; +using TombLib.Scripting.Lua; +using TombLib.Scripting.Lua.Objects; + +namespace TombIDE.ScriptingStudio; + +public sealed partial class LuaStudio +{ + private readonly Services.LuaWorkspaceEditApplier _workspaceEditApplier; + + private async Task RenameSymbolAsync() + { + if (CurrentEditor is not LuaEditor editor) + { + ShowRenameInfo(Strings.Default.LuaRenameNoDocument, MessageBoxIcon.Information); + return; + } + + if (!_intellisenseProvider.SupportsRename) + { + ShowRenameInfo(Strings.Default.LuaRenameUnsupported, MessageBoxIcon.Information); + return; + } + + if (!TryGetRenameTarget(editor, out int renameOffset, out string currentName)) + { + ShowRenameInfo(Strings.Default.LuaRenameNoSymbol, MessageBoxIcon.Information); + return; + } + + if (!TryPromptRenameSymbol(currentName, out string? newName) + || string.IsNullOrWhiteSpace(newName) + || string.Equals(currentName, newName, StringComparison.Ordinal)) + { + return; + } + + TextLocation location = editor.Document.GetLocation(renameOffset); + + try + { + LuaWorkspaceEdit? workspaceEdit = await _intellisenseProvider + .RenameSymbolAsync( + editor.FilePath, + editor.Text, + Math.Max(0, location.Line - 1), + Math.Max(0, location.Column - 1), + newName) + .ConfigureAwait(true); + + if (workspaceEdit is null || !workspaceEdit.HasEdits) + { + ShowRenameInfo(Strings.Default.LuaRenameNoChanges, MessageBoxIcon.Information); + return; + } + + LuaWorkspaceEditTransaction transaction = _workspaceEditApplier.Apply(workspaceEdit); + + if (!transaction.HasChanges) + { + ShowRenameInfo(Strings.Default.LuaRenameNoChanges, MessageBoxIcon.Information); + return; + } + + PushWorkspaceEditTransaction(transaction); + HandleWorkspaceDocumentsChanged(transaction.DocumentChanges.Select(documentChange => documentChange.FilePath)); + } + catch (OperationCanceledException) + { + // Ignore canceled rename requests. + } + catch (Exception ex) + { + DarkMessageBox.Show(this, ex.Message, Strings.Default.RenameSymbol, MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + private void ShowRenameInfo(string message, MessageBoxIcon icon) + => DarkMessageBox.Show(this, message, Strings.Default.RenameSymbol, MessageBoxButtons.OK, icon); + + private static bool TryGetRenameTarget(LuaEditor editor, out int renameOffset, out string currentName) + { + renameOffset = 0; + currentName = string.Empty; + + if (editor.SelectionLength > 0) + { + renameOffset = editor.SelectionStart; + currentName = editor.SelectedText?.Trim() ?? string.Empty; + return !string.IsNullOrWhiteSpace(currentName); + } + + if (!TryGetIdentifierStartOffset(editor.Document, editor.CaretOffset, out renameOffset)) + return false; + + currentName = editor.GetWordFromOffset(renameOffset)?.Trim() ?? string.Empty; + return !string.IsNullOrWhiteSpace(currentName); + } + + private bool TryPromptRenameSymbol(string currentName, out string? newName) + { + var viewModel = new InputBoxWindowViewModel(Strings.Default.RenameSymbol, Strings.Default.LuaRenamePromptLabel, currentName); + var window = new InputBoxWindow { DataContext = viewModel }; + PropertyChangedEventHandler? propertyChangedHandler = null; + + propertyChangedHandler = (_, e) => + { + if (e.PropertyName == nameof(InputBoxWindowViewModel.DialogResult) && viewModel.DialogResult.HasValue) + window.DialogResult = viewModel.DialogResult; + }; + + viewModel.PropertyChanged += propertyChangedHandler; + + try + { + if (FindForm() is Form ownerForm) + new WindowInteropHelper(window).Owner = ownerForm.Handle; + + bool? dialogResult = window.ShowDialog(); + newName = dialogResult == true ? viewModel.Value.Trim() : null; + return dialogResult == true; + } + finally + { + viewModel.PropertyChanged -= propertyChangedHandler; + } + } + + private bool TryGetOpenLuaEditor(string filePath, [NotNullWhen(true)] out LuaEditor? editor) + { + foreach (TabPage tabPage in EditorTabControl.FindTabPagesOfFile(filePath)) + { + if (EditorTabControl.GetEditorOfTab(tabPage) is LuaEditor luaEditor) + { + editor = luaEditor; + return true; + } + } + + editor = null; + return false; + } + + private static bool TryGetIdentifierStartOffset(TextDocument document, int offset, out int identifierStartOffset) + { + identifierStartOffset = 0; + + if (document.TextLength == 0) + return false; + + int probeOffset = Math.Clamp(offset, 0, document.TextLength); + + if (probeOffset >= document.TextLength) + probeOffset = document.TextLength - 1; + + if (probeOffset > 0 + && !IsLuaIdentifierCharacter(document.GetCharAt(probeOffset)) + && IsLuaIdentifierCharacter(document.GetCharAt(probeOffset - 1))) + { + probeOffset--; + } + + if (!IsLuaIdentifierCharacter(document.GetCharAt(probeOffset))) + return false; + + identifierStartOffset = probeOffset; + + while (identifierStartOffset > 0 && IsLuaIdentifierCharacter(document.GetCharAt(identifierStartOffset - 1))) + identifierStartOffset--; + + return true; + } + + private static bool IsLuaIdentifierCharacter(char character) + => char.IsLetterOrDigit(character) || character == '_'; +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/LuaStudio.WorkspaceEditHistory.cs b/TombIDE/TombIDE.ScriptingStudio/LuaStudio.WorkspaceEditHistory.cs new file mode 100644 index 0000000000..f864de1382 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/LuaStudio.WorkspaceEditHistory.cs @@ -0,0 +1,115 @@ +#nullable enable + +using DarkUI.Forms; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows.Forms; +using TombIDE.ScriptingStudio.Services; +using TombIDE.Shared; +using TombLib.Scripting.Lua; + +namespace TombIDE.ScriptingStudio; + +public sealed partial class LuaStudio +{ + private readonly LuaWorkspaceEditHistoryService _workspaceEditHistory; + private int _workspaceEditHistoryApplyDepth; + + protected override bool CanExecuteUndo() + => _workspaceEditHistory.CanUndo || base.CanExecuteUndo(); + + protected override bool CanExecuteRedo() + => _workspaceEditHistory.CanRedo || base.CanExecuteRedo(); + + protected override void ExecuteUndo() + { + if (TryExecuteWorkspaceUndo()) + return; + + base.ExecuteUndo(); + } + + protected override void ExecuteRedo() + { + if (TryExecuteWorkspaceRedo()) + return; + + base.ExecuteRedo(); + } + + private bool IsApplyingWorkspaceEditHistory => _workspaceEditHistoryApplyDepth > 0; + + private void PushWorkspaceEditTransaction(LuaWorkspaceEditTransaction transaction) + { + if (!transaction.HasChanges) + return; + + _workspaceEditHistory.Push(transaction); + UpdateUndoRedoSaveStates(); + } + + private void InvalidateWorkspaceEditHistory() + { + if (IsApplyingWorkspaceEditHistory || !_workspaceEditHistory.HasEntries) + return; + + _workspaceEditHistory.Clear(); + UpdateUndoRedoSaveStates(); + } + + private bool TryExecuteWorkspaceUndo() + { + if (!_workspaceEditHistory.CanUndo) + return false; + + ApplyWorkspaceEditHistoryOperation(_workspaceEditHistory.Undo, Strings.Default.Undo); + return true; + } + + private bool TryExecuteWorkspaceRedo() + { + if (!_workspaceEditHistory.CanRedo) + return false; + + ApplyWorkspaceEditHistoryOperation(_workspaceEditHistory.Redo, Strings.Default.Redo); + return true; + } + + private void ApplyWorkspaceEditHistoryOperation(Func> operation, string caption) + { + try + { + _workspaceEditHistoryApplyDepth++; + HandleWorkspaceDocumentsChanged(operation()); + } + catch (Exception ex) + { + DarkMessageBox.Show(this, ex.Message, caption, MessageBoxButtons.OK, MessageBoxIcon.Error); + } + finally + { + _workspaceEditHistoryApplyDepth--; + UpdateUndoRedoSaveStates(); + } + } + + private void HandleWorkspaceDocumentsChanged(IEnumerable filePaths) + { + string[] changedFiles = [.. filePaths + .Where(filePath => !string.IsNullOrWhiteSpace(filePath)) + .Distinct(StringComparer.OrdinalIgnoreCase)]; + + foreach (string filePath in changedFiles) + { + if (TryGetOpenLuaEditor(filePath, out LuaEditor? updatedEditor) && updatedEditor is not null) + _intellisenseProvider.UpdateDocument(filePath, updatedEditor.Text); + } + + if (CurrentEditor is LuaEditor currentEditor + && changedFiles.Any(filePath => string.Equals(filePath, currentEditor.FilePath, StringComparison.OrdinalIgnoreCase))) + { + RefreshLuaDiagnosticsView(isPending: true); + } + } +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/LuaStudio.cs b/TombIDE/TombIDE.ScriptingStudio/LuaStudio.cs index 9ebfae2417..5d713e442e 100644 --- a/TombIDE/TombIDE.ScriptingStudio/LuaStudio.cs +++ b/TombIDE/TombIDE.ScriptingStudio/LuaStudio.cs @@ -1,12 +1,15 @@ -using System; +using DarkUI.Docking; +using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Text.RegularExpressions; using System.Windows.Forms; +using ICSharpCode.AvalonEdit.Document; using TombIDE.ScriptingStudio.Bases; using TombIDE.ScriptingStudio.Controls; +using TombIDE.ScriptingStudio.Services; using TombIDE.ScriptingStudio.ToolWindows; using TombIDE.ScriptingStudio.UI; using TombIDE.Shared; @@ -14,11 +17,14 @@ using TombLib.Scripting.Bases; using TombLib.Scripting.Enums; using TombLib.Scripting.Interfaces; -using TombLib.Scripting.Lua.Services; +using TombLib.Scripting.Lua; +using TombLib.Scripting.Lua.Objects; +using TombLib.Scripting.Lua.Utils; +using TombLib.Scripting.Objects; namespace TombIDE.ScriptingStudio { - public sealed class LuaStudio : StudioBase + public sealed partial class LuaStudio : StudioBase { public override StudioMode StudioMode => StudioMode.Lua; @@ -37,6 +43,13 @@ public LuaStudio() : base(IDE.Instance.Project.GetScriptRootDirectory(), IDE.Ins FileExplorer.ExcludedDirectoryFilter = "Scripts\\Engine"; FileExplorer.Filter = "*.lua"; FileExplorer.CommentPrefix = "--"; + InitializeLuaDiagnostics(); + InitializeLuaReferencesResults(); + + _intellisenseProvider = CreateLuaIntellisenseProvider(); + _workspaceEditApplier = new LuaWorkspaceEditApplier(EditorTabControl); + _workspaceEditHistory = new LuaWorkspaceEditHistoryService(_workspaceEditApplier); + HookLuaIntellisense(); EditorTabControl.CheckPreviousSession(); @@ -58,12 +71,14 @@ protected override void OnIDEEventRaised(IIDEEvent obj) if (obj is IDE.ProgramClosingEvent) { + DisposeLuaIntellisense(); + IDE.Instance.IDEConfiguration.Lua_DockPanelState = DockPanel.GetDockPanelState(); IDE.Instance.IDEConfiguration.Save(); } } - private bool IsSilentAction(IIDEEvent obj) + private static bool IsSilentAction(IIDEEvent obj) => obj is IDE.ScriptEditor_AppendScriptEvent || obj is IDE.ScriptEditor_ScriptPresenceCheckEvent || obj is IDE.ScriptEditor_StringPresenceCheckEvent @@ -91,7 +106,7 @@ private void IDEEvent_HandleSilentActions(IIDEEvent obj) EndSilentScriptAction(cachedTab, true, false, false); } - else if (obj is IDE.ScriptEditor_ScriptPresenceCheckEvent scrpce) + else if (obj is IDE.ScriptEditor_ScriptPresenceCheckEvent) { IDE.Instance.ScriptDefined = true; // TEMP !!! } @@ -126,9 +141,9 @@ private void AppendScript(ScriptGenerationResult result, CreateGeneratedFiles(result.FilesToCreate); } - catch + catch (Exception exception) { - // Oh well... + Debug.WriteLine($"[LuaStudio] Failed to append generated Lua script output: {exception}"); } } @@ -152,7 +167,7 @@ private void AppendGameFlowScript(string scriptText, bool wasScriptFileAlreadyOp private void AppendLanguageScript(string languageScript, bool wasLanguageFileAlreadyOpened, bool wasLanguageFileFileChanged) { - EditorTabControl.OpenFile(PathHelper.GetLanguageFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TombEngine), EditorType.Text); + EditorTabControl.OpenFile(PathHelper.GetLanguageFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TombEngine)); TabPage affectedTab = EditorTabControl.SelectedTab; if (CurrentEditor is TextEditorBase stringsEditor) @@ -198,7 +213,7 @@ private void CreateGeneratedFiles(IReadOnlyList files) private bool IsLevelLanguageStringDefined(string levelName) { - EditorTabControl.OpenFile(PathHelper.GetLanguageFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TombEngine), EditorType.Text); + EditorTabControl.OpenFile(PathHelper.GetLanguageFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TombEngine)); if (CurrentEditor is TextEditorBase editor) { @@ -213,7 +228,7 @@ private bool IsLevelLanguageStringDefined(string levelName) private void RenameRequestedLanguageString(string oldName, string newName) { - EditorTabControl.OpenFile(PathHelper.GetLanguageFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TombEngine), EditorType.Text); + EditorTabControl.OpenFile(PathHelper.GetLanguageFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TombEngine)); if (CurrentEditor is TextEditorBase editor) { @@ -235,8 +250,12 @@ protected override void RestoreDefaultLayout() DockPanel.RemoveContent(); DockPanel.RestoreDockPanelState(DockPanelState, FindDockContentByKey); + EnsureLuaToolWindowsInDockPanel(); } + protected override void OnDockPanelLayoutRestored() + => EnsureLuaToolWindowsInDockPanel(); + private void EndSilentScriptAction(TabPage previousTab, bool indicateChange, bool saveAffectedFile, bool closeAffectedTab) { if (indicateChange) @@ -261,13 +280,48 @@ private void EndSilentScriptAction(TabPage previousTab, bool indicateChange, boo #region Other methods + private void EnsureLuaToolWindowsInDockPanel() + { + if (DockPanel is null) + return; + + DarkDockGroup bottomGroup = SearchResults?.DockGroup ?? CompilerLogs?.DockGroup; + + bottomGroup = EnsureLuaToolWindowInDockPanel(LuaDiagnostics, bottomGroup); + EnsureLuaToolWindowInDockPanel(LuaReferencesResults, bottomGroup); + } + + private DarkDockGroup EnsureLuaToolWindowInDockPanel(DarkToolWindow toolWindow, DarkDockGroup bottomGroup) + { + if (DockPanel.ContainsContent(toolWindow)) + return bottomGroup ?? toolWindow.DockGroup; + + toolWindow.DockArea = DarkDockArea.Bottom; + + if (bottomGroup is not null) + DockPanel.AddContent(toolWindow, bottomGroup); + else + DockPanel.AddContent(toolWindow); + + return bottomGroup ?? toolWindow.DockGroup; + } + protected override void ApplyUserSettings(IEditorControl editor) => editor.UpdateSettings(Configs.Lua); protected override void ApplyUserSettings() { foreach (TabPage tab in EditorTabControl.TabPages) - ApplyUserSettings(EditorTabControl.GetEditorOfTab(tab)); + { + IEditorControl editor = EditorTabControl.GetEditorOfTab(tab); + ApplyUserSettings(editor); + + if (editor is LuaEditor luaEditor) + { + ApplyDiagnosticsToEditor(luaEditor, _intellisenseProvider.GetDiagnostics(luaEditor.FilePath)); + ApplySemanticTokensToEditor(luaEditor, _intellisenseProvider.GetSemanticTokens(luaEditor.FilePath)); + } + } UpdateSettings(); } @@ -279,10 +333,37 @@ protected override void Build() protected override void HandleDocumentCommands(UICommand command) { + if (command == UICommand.Reindent && CurrentEditor is LuaEditor) + { + _ = ReformatDocumentAsync(); + return; + } + switch (command) { + case UICommand.NavigateBack: + NavigateBack(); + break; + + case UICommand.NavigateForward: + NavigateForward(); + break; + + case UICommand.GoToDefinition: + if (CurrentEditor is LuaEditor luaEditor) + _ = luaEditor.NavigateToDefinitionAtCaretAsync(); + break; + + case UICommand.FindReferences: + _ = FindReferencesAsync(); + break; + + case UICommand.RenameSymbol: + _ = RenameSymbolAsync(); + break; + case UICommand.LuaBasics: - string url = "https://github.com/MontyTRC89/TombEngine/wiki/Basics-of-Lua-Programming"; + const string url = "https://github.com/MontyTRC89/TombEngine/wiki/Basics-of-Lua-Programming"; var process = new ProcessStartInfo { diff --git a/TombIDE/TombIDE.ScriptingStudio/Objects/DocumentRenamedEventArgs.cs b/TombIDE/TombIDE.ScriptingStudio/Objects/DocumentRenamedEventArgs.cs new file mode 100644 index 0000000000..c637c69921 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/Objects/DocumentRenamedEventArgs.cs @@ -0,0 +1,16 @@ +using System; + +namespace TombIDE.ScriptingStudio.Objects +{ + public class DocumentRenamedEventArgs : EventArgs + { + public string OldFilePath { get; } + public string NewFilePath { get; } + + public DocumentRenamedEventArgs(string oldFilePath, string newFilePath) + { + OldFilePath = oldFilePath; + NewFilePath = newFilePath; + } + } +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/Objects/EditorNavigationLocation.cs b/TombIDE/TombIDE.ScriptingStudio/Objects/EditorNavigationLocation.cs new file mode 100644 index 0000000000..baa30976ff --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/Objects/EditorNavigationLocation.cs @@ -0,0 +1,19 @@ +#nullable enable + +using System; + +namespace TombIDE.ScriptingStudio.Objects; + +internal readonly record struct EditorNavigationLocation( + string FilePath, + int CaretOffset, + int SelectionStart, + int SelectionLength, + int? PreferredLine) +{ + public bool IsEquivalentTo(EditorNavigationLocation other) + => string.Equals(FilePath, other.FilePath, StringComparison.OrdinalIgnoreCase) + && CaretOffset == other.CaretOffset + && SelectionStart == other.SelectionStart + && SelectionLength == other.SelectionLength; +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/Objects/LuaDiagnosticListItem.cs b/TombIDE/TombIDE.ScriptingStudio/Objects/LuaDiagnosticListItem.cs new file mode 100644 index 0000000000..abe620047b --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/Objects/LuaDiagnosticListItem.cs @@ -0,0 +1,15 @@ +#nullable enable + +using TombLib.Scripting.Objects; + +namespace TombIDE.ScriptingStudio.Objects; + +internal sealed record class LuaDiagnosticListItem( + string FilePath, + TextEditorDiagnosticSeverity Severity, + string SeverityLabel, + int LineNumber, + int ColumnNumber, + string Message, + int StartOffset, + int EndOffset); \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/Objects/LuaReferenceGroup.cs b/TombIDE/TombIDE.ScriptingStudio/Objects/LuaReferenceGroup.cs new file mode 100644 index 0000000000..7b3c38afb4 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/Objects/LuaReferenceGroup.cs @@ -0,0 +1,25 @@ +#nullable enable + +using System.Collections.Generic; + +namespace TombIDE.ScriptingStudio.Objects; + +internal sealed class LuaReferenceGroup +{ + public LuaReferenceGroup(string filePath, string displayPath, IReadOnlyList items) + { + FilePath = filePath; + DisplayPath = displayPath; + Items = items; + } + + public string FilePath { get; } + + public string DisplayPath { get; } + + public IReadOnlyList Items { get; } + + public int Count => Items.Count; + + public string Header => $"{DisplayPath} ({Count})"; +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/Objects/LuaReferenceListItem.cs b/TombIDE/TombIDE.ScriptingStudio/Objects/LuaReferenceListItem.cs new file mode 100644 index 0000000000..313c05256d --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/Objects/LuaReferenceListItem.cs @@ -0,0 +1,15 @@ +#nullable enable + +using TombLib.Scripting.Lua.Objects; + +namespace TombIDE.ScriptingStudio.Objects; + +internal sealed record class LuaReferenceListItem( + string FilePath, + LuaDocumentRange Range, + int LineNumber, + int ColumnNumber, + string PreviewText) +{ + public string DisplayText => $"{LineNumber}:{ColumnNumber} {PreviewText}"; +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/Properties/InternalsVisibleTo.cs b/TombIDE/TombIDE.ScriptingStudio/Properties/InternalsVisibleTo.cs new file mode 100644 index 0000000000..d34753098b --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/Properties/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("TombLib.Test")] diff --git a/TombIDE/TombIDE.ScriptingStudio/Services/EditorNavigationHistoryService.cs b/TombIDE/TombIDE.ScriptingStudio/Services/EditorNavigationHistoryService.cs new file mode 100644 index 0000000000..6ca3d0a9f3 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/Services/EditorNavigationHistoryService.cs @@ -0,0 +1,131 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using TombIDE.ScriptingStudio.Objects; + +namespace TombIDE.ScriptingStudio.Services; + +internal sealed class EditorNavigationHistoryService +{ + private const int MinimumCaretMoveDistance = 32; + private const int MinimumSelectionMoveDistance = 8; + + private readonly Stack _backStack = new(); + private readonly Stack _forwardStack = new(); + + private EditorNavigationLocation? _currentLocation; + private int _suppressionDepth; + + public bool CanNavigateBack => _backStack.Count > 0; + + public bool CanNavigateForward => _forwardStack.Count > 0; + + public void Observe(EditorNavigationLocation location) + { + if (_suppressionDepth > 0) + { + _currentLocation = location; + return; + } + + if (_currentLocation is not EditorNavigationLocation currentLocation) + { + _currentLocation = location; + return; + } + + if (!IsMeaningfulChange(currentLocation, location)) + { + _currentLocation = location; + return; + } + + PushDistinct(_backStack, currentLocation); + _forwardStack.Clear(); + _currentLocation = location; + } + + public void RecordProgrammaticJump(EditorNavigationLocation currentLocation, EditorNavigationLocation targetLocation) + { + _currentLocation = currentLocation; + + if (currentLocation.IsEquivalentTo(targetLocation)) + return; + + PushDistinct(_backStack, currentLocation); + _forwardStack.Clear(); + } + + public void SetCurrentLocation(EditorNavigationLocation location) + => _currentLocation = location; + + public bool TryNavigateBack(EditorNavigationLocation currentLocation, out EditorNavigationLocation? targetLocation) + { + targetLocation = null; + + if (_backStack.Count == 0) + return false; + + PushDistinct(_forwardStack, currentLocation); + targetLocation = _backStack.Pop(); + _currentLocation = targetLocation; + return true; + } + + public bool TryNavigateForward(EditorNavigationLocation currentLocation, out EditorNavigationLocation? targetLocation) + { + targetLocation = null; + + if (_forwardStack.Count == 0) + return false; + + PushDistinct(_backStack, currentLocation); + targetLocation = _forwardStack.Pop(); + _currentLocation = targetLocation; + return true; + } + + public IDisposable SuppressRecording() + { + _suppressionDepth++; + return new RecordingScope(this); + } + + private static bool IsMeaningfulChange(EditorNavigationLocation previous, EditorNavigationLocation current) + { + if (!string.Equals(previous.FilePath, current.FilePath, StringComparison.OrdinalIgnoreCase)) + return true; + + if (previous.SelectionLength != current.SelectionLength) + return true; + + if (previous.SelectionLength > 0 || current.SelectionLength > 0) + return Math.Abs(previous.SelectionStart - current.SelectionStart) >= MinimumSelectionMoveDistance; + + return Math.Abs(previous.CaretOffset - current.CaretOffset) >= MinimumCaretMoveDistance; + } + + private static void PushDistinct(Stack stack, EditorNavigationLocation location) + { + if (stack.Count == 0 || !stack.Peek().IsEquivalentTo(location)) + stack.Push(location); + } + + private sealed class RecordingScope : IDisposable + { + private EditorNavigationHistoryService? _owner; + + public RecordingScope(EditorNavigationHistoryService owner) + => _owner = owner; + + public void Dispose() + { + if (_owner is null) + return; + + _owner._suppressionDepth = Math.Max(0, _owner._suppressionDepth - 1); + _owner = null; + } + } +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/Services/LuaIntellisense/LuaLanguageServerLocator.cs b/TombIDE/TombIDE.ScriptingStudio/Services/LuaIntellisense/LuaLanguageServerLocator.cs new file mode 100644 index 0000000000..3f4abcade1 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/Services/LuaIntellisense/LuaLanguageServerLocator.cs @@ -0,0 +1,20 @@ +#nullable enable + +using System.IO; + +namespace TombIDE.ScriptingStudio.Services.LuaIntellisense; + +internal static class LuaLanguageServerLocator +{ + private const string ExecutableFileName = "lua-language-server.exe"; + + /// + /// Resolves the bundled Lua language server executable when it is installed with TombIDE. + /// + /// The bundled executable path, or when it is unavailable. + public static string? ResolveExecutablePath() + { + string bundledExecutablePath = Path.Combine(DefaultPaths.TIDEDirectory, "LuaLS", "bin", ExecutableFileName); + return File.Exists(bundledExecutablePath) ? bundledExecutablePath : null; + } +} diff --git a/TombIDE/TombIDE.ScriptingStudio/Services/LuaWorkspaceEditApplier.cs b/TombIDE/TombIDE.ScriptingStudio/Services/LuaWorkspaceEditApplier.cs new file mode 100644 index 0000000000..224c6af958 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/Services/LuaWorkspaceEditApplier.cs @@ -0,0 +1,268 @@ +#nullable enable + +using ICSharpCode.AvalonEdit.Document; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Windows.Forms; +using TombIDE.ScriptingStudio.Controls; +using TombLib.Scripting.Bases; +using TombLib.Scripting.Interfaces; +using TombLib.Scripting.Lua.Objects; + +namespace TombIDE.ScriptingStudio.Services; + +internal sealed class LuaWorkspaceEditApplier(EditorTabControl editorTabControl) +{ + private readonly EditorTabControl _editorTabControl = editorTabControl ?? throw new ArgumentNullException(nameof(editorTabControl)); + + public LuaWorkspaceEditTransaction Apply(LuaWorkspaceEdit workspaceEdit, LuaWorkspaceEditSelectionState? selectionState = null) + { + ArgumentNullException.ThrowIfNull(workspaceEdit); + + if (!workspaceEdit.HasEdits) + return new LuaWorkspaceEditTransaction([]); + + TabPage? previouslySelectedTab = _editorTabControl.SelectedTab; + var documentChanges = new List(); + + try + { + foreach (IGrouping fileGroup in workspaceEdit.DocumentEdits + .Where(documentEdit => !string.IsNullOrWhiteSpace(documentEdit.FilePath)) + .GroupBy(documentEdit => documentEdit.FilePath, StringComparer.OrdinalIgnoreCase)) + { + string filePath = fileGroup.Key; + TextEditorBase textEditor = GetOrOpenTextEditor(filePath); + LuaWorkspaceEditSelectionState? selectionStateForFile = selectionState is not null + && string.Equals(selectionState.FilePath, filePath, StringComparison.OrdinalIgnoreCase) + ? selectionState + : null; + string beforeContent = textEditor.Text; + List preparedTextEdits = PrepareTextEdits(textEditor.Document, + fileGroup.SelectMany(documentEdit => documentEdit.TextEdits)); + RestoredSelectionState? restoredSelectionState = selectionStateForFile is null + ? null + : MapSelectionState(selectionStateForFile, preparedTextEdits); + + ApplyPreparedTextEdits(textEditor, preparedTextEdits); + SynchronizeOpenTabs(filePath, textEditor); + + if (selectionStateForFile is not null && restoredSelectionState is not null) + RestoreSelectionState(selectionStateForFile.Editor, restoredSelectionState.Value); + + if (!string.Equals(beforeContent, textEditor.Text, StringComparison.Ordinal)) + documentChanges.Add(new LuaWorkspaceDocumentChange(filePath, beforeContent, textEditor.Text)); + } + } + finally + { + if (previouslySelectedTab is not null && _editorTabControl.TabPages.Contains(previouslySelectedTab)) + _editorTabControl.SelectTab(previouslySelectedTab); + } + + return new LuaWorkspaceEditTransaction(documentChanges); + } + + public IReadOnlyList ApplyBeforeSnapshot(LuaWorkspaceEditTransaction transaction) + => ApplyContentSnapshots(transaction, static documentChange => documentChange.BeforeContent); + + public IReadOnlyList ApplyAfterSnapshot(LuaWorkspaceEditTransaction transaction) + => ApplyContentSnapshots(transaction, static documentChange => documentChange.AfterContent); + + private TextEditorBase GetOrOpenTextEditor(string filePath) + { + if (!_editorTabControl.FindTabPagesOfFile(filePath).Any() && !File.Exists(filePath)) + throw new FileNotFoundException("Unable to apply a Lua workspace edit because the target file could not be found.", filePath); + + _editorTabControl.OpenFile(filePath); + + if (_editorTabControl.CurrentEditor is not TextEditorBase textEditor) + throw new InvalidOperationException($"Unable to apply Lua workspace edits to '{filePath}'."); + + return textEditor; + } + + private static void ApplyPreparedTextEdits(TextEditorBase textEditor, IReadOnlyList preparedTextEdits) + { + if (preparedTextEdits.Count == 0) + return; + + textEditor.Document.UndoStack.StartUndoGroup(); + textEditor.Document.BeginUpdate(); + + try + { + foreach (PreparedTextEdit preparedTextEdit in preparedTextEdits) + textEditor.Document.Replace(preparedTextEdit.StartOffset, preparedTextEdit.Length, preparedTextEdit.NewText); + } + finally + { + textEditor.Document.EndUpdate(); + textEditor.Document.UndoStack.EndUndoGroup(); + } + + textEditor.TryRunContentChangedWorker(); + } + + private IReadOnlyList ApplyContentSnapshots(LuaWorkspaceEditTransaction transaction, Func selectContent) + { + ArgumentNullException.ThrowIfNull(transaction); + ArgumentNullException.ThrowIfNull(selectContent); + + if (!transaction.HasChanges) + return []; + + TabPage? previouslySelectedTab = _editorTabControl.SelectedTab; + var updatedFiles = new List(transaction.DocumentChanges.Count); + + try + { + foreach (LuaWorkspaceDocumentChange documentChange in transaction.DocumentChanges) + { + TextEditorBase textEditor = GetOrOpenTextEditor(documentChange.FilePath); + ApplyDocumentContent(textEditor, selectContent(documentChange)); + SynchronizeOpenTabs(documentChange.FilePath, textEditor); + updatedFiles.Add(documentChange.FilePath); + } + } + finally + { + if (previouslySelectedTab is not null && _editorTabControl.TabPages.Contains(previouslySelectedTab)) + _editorTabControl.SelectTab(previouslySelectedTab); + } + + return updatedFiles; + } + + private static RestoredSelectionState MapSelectionState(LuaWorkspaceEditSelectionState selectionState, IReadOnlyList preparedTextEdits) + { + PreparedTextEdit[] editsAscending = [.. preparedTextEdits]; + + Array.Sort(editsAscending, static (left, right) => + { + int startComparison = left.StartOffset.CompareTo(right.StartOffset); + return startComparison != 0 + ? startComparison + : left.Length.CompareTo(right.Length); + }); + + int selectionStart = MapOffset(selectionState.SelectionStart, editsAscending); + int selectionEnd = MapOffset(selectionState.SelectionEnd, editsAscending); + int caretOffset = MapOffset(selectionState.CaretOffset, editsAscending); + + if (selectionEnd < selectionStart) + (selectionStart, selectionEnd) = (selectionEnd, selectionStart); + + return new RestoredSelectionState(selectionStart, selectionEnd, caretOffset); + } + + private static int MapOffset(int offset, IReadOnlyList preparedTextEdits) + { + int cumulativeDelta = 0; + + foreach (PreparedTextEdit preparedTextEdit in preparedTextEdits) + { + if (offset < preparedTextEdit.StartOffset) + break; + + if (offset <= preparedTextEdit.EndOffset) + { + int relativeOffset = offset - preparedTextEdit.StartOffset; + int normalizedRelativeOffset = Math.Min(relativeOffset, preparedTextEdit.NewText.Length); + return preparedTextEdit.StartOffset + cumulativeDelta + normalizedRelativeOffset; + } + + cumulativeDelta += preparedTextEdit.NewText.Length - preparedTextEdit.Length; + } + + return offset + cumulativeDelta; + } + + private static void RestoreSelectionState(TextEditorBase textEditor, RestoredSelectionState restoredSelectionState) + { + int documentLength = textEditor.Document.TextLength; + int selectionStart = Math.Clamp(restoredSelectionState.SelectionStart, 0, documentLength); + int selectionEnd = Math.Clamp(restoredSelectionState.SelectionEnd, 0, documentLength); + int caretOffset = Math.Clamp(restoredSelectionState.CaretOffset, 0, documentLength); + + if (selectionEnd < selectionStart) + (selectionStart, selectionEnd) = (selectionEnd, selectionStart); + + textEditor.Select(selectionStart, selectionEnd - selectionStart); + textEditor.CaretOffset = caretOffset; + } + + private static void ApplyDocumentContent(TextEditorBase textEditor, string content) + { + if (string.Equals(textEditor.Text, content, StringComparison.Ordinal)) + return; + + textEditor.Content = content; + } + + private static List PrepareTextEdits(TextDocument document, IEnumerable textEdits) + { + var preparedTextEdits = new List(); + + foreach (LuaTextEdit textEdit in textEdits) + { + if (!TryGetOffset(document, textEdit.Range.StartLineNumber, textEdit.Range.StartColumnNumber, out int startOffset) + || !TryGetOffset(document, textEdit.Range.EndLineNumber, textEdit.Range.EndColumnNumber, out int endOffset) + || endOffset < startOffset) + { + throw new InvalidOperationException("LuaLS returned an invalid workspace-edit range."); + } + + preparedTextEdits.Add(new PreparedTextEdit(startOffset, endOffset - startOffset, textEdit.NewText)); + } + + preparedTextEdits.Sort(static (left, right) => + { + int startComparison = right.StartOffset.CompareTo(left.StartOffset); + + return startComparison != 0 + ? startComparison + : right.Length.CompareTo(left.Length); + }); + + return preparedTextEdits; + } + + private void SynchronizeOpenTabs(string filePath, TextEditorBase sourceEditor) + { + foreach (TabPage tabPage in _editorTabControl.FindTabPagesOfFile(filePath)) + { + IEditorControl? editor = _editorTabControl.GetEditorOfTab(tabPage); + + if (ReferenceEquals(editor, sourceEditor) || editor is null) + continue; + + if (!string.Equals(editor.Content, sourceEditor.Content, StringComparison.Ordinal)) + editor.Content = sourceEditor.Content; + + editor.TryRunContentChangedWorker(); + } + } + + private static bool TryGetOffset(TextDocument document, int lineNumber, int columnNumber, out int offset) + { + offset = 0; + + if (lineNumber < 1 || lineNumber > document.LineCount) + return false; + + DocumentLine line = document.GetLineByNumber(lineNumber); + int characterOffset = Math.Clamp(columnNumber - 1, 0, line.Length); + offset = line.Offset + characterOffset; + return true; + } + + private readonly record struct PreparedTextEdit(int StartOffset, int Length, string NewText) + { + public int EndOffset => StartOffset + Length; + } + + private readonly record struct RestoredSelectionState(int SelectionStart, int SelectionEnd, int CaretOffset); +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/Services/LuaWorkspaceEditHistoryService.cs b/TombIDE/TombIDE.ScriptingStudio/Services/LuaWorkspaceEditHistoryService.cs new file mode 100644 index 0000000000..e057ee49c8 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/Services/LuaWorkspaceEditHistoryService.cs @@ -0,0 +1,88 @@ +#nullable enable + +using System; +using System.Collections.Generic; + +namespace TombIDE.ScriptingStudio.Services; + +internal sealed class LuaWorkspaceEditHistoryService(LuaWorkspaceEditApplier workspaceEditApplier) +{ + private readonly LuaWorkspaceEditApplier _workspaceEditApplier = workspaceEditApplier ?? throw new ArgumentNullException(nameof(workspaceEditApplier)); + private readonly Stack _undoStack = []; + private readonly Stack _redoStack = []; + + public bool CanUndo => _undoStack.Count > 0; + public bool CanRedo => _redoStack.Count > 0; + public bool HasEntries => CanUndo || CanRedo; + + public void Push(LuaWorkspaceEditTransaction transaction) + { + ArgumentNullException.ThrowIfNull(transaction); + + if (!transaction.HasChanges) + return; + + _undoStack.Push(transaction); + _redoStack.Clear(); + } + + public void Clear() + { + _undoStack.Clear(); + _redoStack.Clear(); + } + + public IReadOnlyList Undo() + { + if (_undoStack.Count == 0) + return []; + + LuaWorkspaceEditTransaction transaction = _undoStack.Pop(); + + try + { + IReadOnlyList changedFiles = _workspaceEditApplier.ApplyBeforeSnapshot(transaction); + _redoStack.Push(transaction); + return changedFiles; + } + catch + { + _undoStack.Push(transaction); + throw; + } + } + + public IReadOnlyList Redo() + { + if (_redoStack.Count == 0) + return []; + + LuaWorkspaceEditTransaction transaction = _redoStack.Pop(); + + try + { + IReadOnlyList changedFiles = _workspaceEditApplier.ApplyAfterSnapshot(transaction); + _undoStack.Push(transaction); + return changedFiles; + } + catch + { + _redoStack.Push(transaction); + throw; + } + } +} + +internal sealed class LuaWorkspaceEditTransaction(IReadOnlyList documentChanges) +{ + public IReadOnlyList DocumentChanges { get; } = documentChanges ?? []; + + public bool HasChanges => DocumentChanges.Count > 0; +} + +internal sealed class LuaWorkspaceDocumentChange(string filePath, string beforeContent, string afterContent) +{ + public string FilePath { get; } = filePath ?? string.Empty; + public string BeforeContent { get; } = beforeContent ?? string.Empty; + public string AfterContent { get; } = afterContent ?? string.Empty; +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/Services/LuaWorkspaceEditSelectionState.cs b/TombIDE/TombIDE.ScriptingStudio/Services/LuaWorkspaceEditSelectionState.cs new file mode 100644 index 0000000000..460d4d3853 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/Services/LuaWorkspaceEditSelectionState.cs @@ -0,0 +1,38 @@ +#nullable enable + +using System; +using TombLib.Scripting.Bases; + +namespace TombIDE.ScriptingStudio.Services; + +internal sealed class LuaWorkspaceEditSelectionState +{ + private LuaWorkspaceEditSelectionState(string filePath, TextEditorBase editor, int selectionStart, int selectionEnd, int caretOffset) + { + FilePath = filePath; + Editor = editor; + SelectionStart = selectionStart; + SelectionEnd = selectionEnd; + CaretOffset = caretOffset; + } + + public string FilePath { get; } + + public TextEditorBase Editor { get; } + + public int SelectionStart { get; } + + public int SelectionEnd { get; } + + public int CaretOffset { get; } + + public static LuaWorkspaceEditSelectionState Capture(TextEditorBase editor) + { + ArgumentNullException.ThrowIfNull(editor); + + int selectionStart = editor.SelectionStart; + int selectionEnd = selectionStart + editor.SelectionLength; + + return new LuaWorkspaceEditSelectionState(editor.FilePath, editor, selectionStart, selectionEnd, editor.CaretOffset); + } +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/Settings/ClassicScriptSettingsControl.cs b/TombIDE/TombIDE.ScriptingStudio/Settings/ClassicScriptSettingsControl.cs index cd90cb033a..1ead99be19 100644 --- a/TombIDE/TombIDE.ScriptingStudio/Settings/ClassicScriptSettingsControl.cs +++ b/TombIDE/TombIDE.ScriptingStudio/Settings/ClassicScriptSettingsControl.cs @@ -547,7 +547,7 @@ private void UpdatePreviewTemp(bool forceUpdate = true) if (editorPreview.LiveErrorUnderlining) editorPreview.CheckForErrors(); else - editorPreview.ResetAllErrors(); + editorPreview.ClearDiagnostics(); editorPreview.WordWrap = checkBox_WordWrapping.Checked; diff --git a/TombIDE/TombIDE.ScriptingStudio/Settings/FormSaveSchemeAs.cs b/TombIDE/TombIDE.ScriptingStudio/Settings/FormSaveSchemeAs.cs index 5df6860f2a..90379cead1 100644 --- a/TombIDE/TombIDE.ScriptingStudio/Settings/FormSaveSchemeAs.cs +++ b/TombIDE/TombIDE.ScriptingStudio/Settings/FormSaveSchemeAs.cs @@ -9,8 +9,7 @@ namespace TombIDE.ScriptingStudio.Settings internal enum ColorSchemeType { ClassicScript, - GameFlowScript, - Lua + GameFlowScript } internal partial class FormSaveSchemeAs : DarkForm @@ -77,17 +76,6 @@ private void button_Save_Click(object sender, EventArgs e) schemeFilePath = Path.Combine(schemeFolderPath, newName + ".gflsch"); break; } - case ColorSchemeType.Lua: - { - string schemeFolderPath = DefaultPaths.LuaColorConfigsDirectory; - - foreach (string file in Directory.GetFiles(schemeFolderPath, "*.luasch", SearchOption.TopDirectoryOnly)) - if (Path.GetFileNameWithoutExtension(file).Equals(newName, StringComparison.OrdinalIgnoreCase)) - throw new ArgumentException("A scheme with the same name already exists."); - - schemeFilePath = Path.Combine(schemeFolderPath, newName + ".luasch"); - break; - } } // // // // diff --git a/TombIDE/TombIDE.ScriptingStudio/Settings/LuaSettingsControl.Designer.cs b/TombIDE/TombIDE.ScriptingStudio/Settings/LuaSettingsControl.Designer.cs index 17450950d1..c97b4fcf41 100644 --- a/TombIDE/TombIDE.ScriptingStudio/Settings/LuaSettingsControl.Designer.cs +++ b/TombIDE/TombIDE.ScriptingStudio/Settings/LuaSettingsControl.Designer.cs @@ -113,13 +113,12 @@ private void InitializeComponent() // checkBox_Autocomplete // checkBox_Autocomplete.AutoSize = true; - checkBox_Autocomplete.Enabled = false; checkBox_Autocomplete.Location = new System.Drawing.Point(6, 166); checkBox_Autocomplete.Margin = new System.Windows.Forms.Padding(6, 6, 3, 0); checkBox_Autocomplete.Name = "checkBox_Autocomplete"; - checkBox_Autocomplete.Size = new System.Drawing.Size(30, 17); + checkBox_Autocomplete.Size = new System.Drawing.Size(135, 17); checkBox_Autocomplete.TabIndex = 6; - checkBox_Autocomplete.Text = "-"; + checkBox_Autocomplete.Text = "Enable autocomplete"; // // checkBox_HighlightCurrentLine // diff --git a/TombIDE/TombIDE.ScriptingStudio/Settings/LuaSettingsControl.cs b/TombIDE/TombIDE.ScriptingStudio/Settings/LuaSettingsControl.cs index 290814c554..c4f01d32de 100644 --- a/TombIDE/TombIDE.ScriptingStudio/Settings/LuaSettingsControl.cs +++ b/TombIDE/TombIDE.ScriptingStudio/Settings/LuaSettingsControl.cs @@ -1,25 +1,33 @@ using DarkUI.Controls; -using DarkUI.Forms; using System; using System.Collections.Generic; using System.ComponentModel; -using System.Diagnostics; using System.Drawing; using System.Drawing.Text; -using System.IO; using System.Windows; using System.Windows.Forms; using TombLib.Scripting.Lua; using TombLib.Scripting.Lua.Objects; +using TombLib.Scripting.Lua.Resources; using TombLib.Scripting.Objects; using TombLib.Scripting.Resources; -using TombLib.Utils; namespace TombIDE.ScriptingStudio.Settings { internal partial class LuaSettingsControl : UserControl { - // TODO: Refactor !!! + private const string PreviewText = + "---@class Weapon\n" + + "local Weapon = {}\n" + + "global levelName = \"Lara\"\n\n" + + "function Weapon:new(name)\n" + + " local damage = math.max(levelName and 1 or 0, 1)\n" + + " self.name = name\n" + + " return damage, \"mods\\\\ten\\tpreview\", true, TEN\n" + + "end"; + + private static readonly string[] PreviewLines = PreviewText.Replace("\r", string.Empty).Split('\n'); + private static readonly IReadOnlyList PreviewTokens = CreatePreviewTokens(); private LuaEditor editorPreview; @@ -33,37 +41,69 @@ public LuaSettingsControl() public void Initialize(LuaEditorConfiguration config) { InitializePreview(); - FillFontList(); - UpdateSchemeList(); + ConfigureThemePresetUi(); + UpdateThemeList(); UpdateControlsWithSettings(config); + UpdatePreview(); } private void InitializePreview() { editorPreview = new LuaEditor(new Version(0, 0)) { - Text = - "if _G[k] then\n" + - " print(\"WARNING! Key \"..k..\" already exists in global environment!\")\n" + - "else\n" + - " _G[k] = v\n" + - " if \"table\" == type(v) then\n" + - " if nil == v.__type then\n" + - " ShortenInner(v)\n" + - " end\n" + - " end\n" + - "end", + Text = PreviewText, IsReadOnly = true, HorizontalScrollBarVisibility = System.Windows.Controls.ScrollBarVisibility.Hidden, VerticalScrollBarVisibility = System.Windows.Controls.ScrollBarVisibility.Hidden }; editorPreview.TextArea.Margin = new Thickness(3); - elementHost.Child = editorPreview; } + private void ConfigureThemePresetUi() + { + int previewBottom = groupBox_Preview.Bottom; + + groupBox_Colors.Enabled = true; + groupBox_Colors.Text = "Theme"; + darkLabel4.Text = "Preset:"; + darkLabel4.Location = new System.Drawing.Point(12, 23); + comboBox_ColorSchemes.DropDownStyle = ComboBoxStyle.DropDownList; + comboBox_ColorSchemes.Location = new System.Drawing.Point(12, 42); + comboBox_ColorSchemes.Width = groupBox_Colors.ClientSize.Width - 24; + groupBox_Colors.Height = 77; + + Control[] hiddenControls = + { + button_ImportScheme, + button_SaveScheme, + button_DeleteScheme, + button_OpenSchemesFolder, + colorButton_Background, + colorButton_Foreground, + colorButton_Comments, + colorButton_SpecialOperators, + colorButton_Values, + colorButton_Statements, + colorButton_Operators, + darkLabel5, + darkLabel6, + darkLabel7, + darkLabel8, + darkLabel10, + darkLabel11, + darkLabel12 + }; + + for (int i = 0; i < hiddenControls.Length; i++) + hiddenControls[i].Visible = false; + + groupBox_Preview.Top = groupBox_Colors.Bottom + 9; + groupBox_Preview.Height = previewBottom - groupBox_Preview.Top; + } + private void FillFontList() { var fontList = new List(); @@ -74,133 +114,62 @@ private void FillFontList() comboBox_FontFamily.Items.AddRange(fontList.ToArray()); } - private void UpdateSchemeList() + private void UpdateThemeList() { - string cachedSelectedItem = null; - - if (comboBox_ColorSchemes.SelectedItem != null) - cachedSelectedItem = comboBox_ColorSchemes.SelectedItem.ToString(); + string cachedSelectedItem = comboBox_ColorSchemes.SelectedItem?.ToString(); comboBox_ColorSchemes.Items.Clear(); - foreach (string file in Directory.GetFiles(DefaultPaths.LuaColorConfigsDirectory, "*.luasch", SearchOption.TopDirectoryOnly)) - comboBox_ColorSchemes.Items.Add(Path.GetFileNameWithoutExtension(file)); + foreach (LuaTheme theme in LuaThemeRepository.GetAvailableThemes()) + comboBox_ColorSchemes.Items.Add(theme.Name); - if (cachedSelectedItem != null) + if (!string.IsNullOrWhiteSpace(cachedSelectedItem) && comboBox_ColorSchemes.Items.Contains(cachedSelectedItem)) comboBox_ColorSchemes.SelectedItem = cachedSelectedItem; + else if (comboBox_ColorSchemes.Items.Contains(ConfigurationDefaults.SelectedThemeName)) + comboBox_ColorSchemes.SelectedItem = ConfigurationDefaults.SelectedThemeName; + else if (comboBox_ColorSchemes.Items.Count > 0) + comboBox_ColorSchemes.SelectedIndex = 0; } #endregion Construction #region Events - private void VisiblePreviewSetting_Changed(object sender, EventArgs e) => - UpdatePreviewTemp(); + private void VisiblePreviewSetting_Changed(object sender, EventArgs e) + => UpdatePreview(); - private void comboBox_FontFamily_SelectedIndexChanged(object sender, EventArgs e) => - UpdatePreviewTemp(false); + private void comboBox_FontFamily_SelectedIndexChanged(object sender, EventArgs e) + => UpdatePreview(); private void comboBox_ColorSchemes_SelectedIndexChanged(object sender, EventArgs e) - { - if (comboBox_ColorSchemes.Items.Count == 1) - button_DeleteScheme.Enabled = false; // Disallow deleting the last available scheme - - ToggleSaveSchemeButton(); + => UpdatePreview(); - string fullSchemePath = Path.Combine(DefaultPaths.LuaColorConfigsDirectory, comboBox_ColorSchemes.SelectedItem.ToString() + ".luasch"); - ColorScheme selectedScheme = XmlUtils.ReadXmlFile(fullSchemePath); - - UpdateColorButtons(selectedScheme); - UpdatePreviewColors(selectedScheme); + private void button_Color_Click(object sender, EventArgs e) + { } - private void button_Color_Click(object sender, EventArgs e) => - ChangeColor((DarkButton)sender); - private void menuItem_Bold_Click(object sender, EventArgs e) { - menuItem_Bold.Checked = !menuItem_Bold.Checked; - UpdateButton(sender); } private void menuItem_Italic_Click(object sender, EventArgs e) { - menuItem_Italic.Checked = !menuItem_Italic.Checked; - UpdateButton(sender); } private void button_SaveScheme_Click(object sender, EventArgs e) { - using (var form = new FormSaveSchemeAs(ColorSchemeType.GameFlowScript)) - if (form.ShowDialog(this) == DialogResult.OK) - { - var currentScheme = new ColorScheme - { - Values = (HighlightingObject)colorButton_Values.Tag, - Operators = (HighlightingObject)colorButton_Operators.Tag, - SpecialOperators = (HighlightingObject)colorButton_SpecialOperators.Tag, - Statements = (HighlightingObject)colorButton_Statements.Tag, - Comments = (HighlightingObject)colorButton_Comments.Tag, - Background = ColorTranslator.ToHtml(colorButton_Background.BackColor), - Foreground = ColorTranslator.ToHtml(colorButton_Foreground.BackColor) - }; - - XmlUtils.WriteXmlFile(form.SchemeFilePath, currentScheme); - - comboBox_ColorSchemes.Items.Add(Path.GetFileNameWithoutExtension(form.SchemeFilePath)); - comboBox_ColorSchemes.SelectedItem = Path.GetFileNameWithoutExtension(form.SchemeFilePath); - - comboBox_ColorSchemes.Items.Remove("~UNTITLED"); - } } private void button_DeleteScheme_Click(object sender, EventArgs e) { - DialogResult result = DarkMessageBox.Show(this, - "Are you sure you want to delete the \"" + comboBox_ColorSchemes.SelectedItem + "\" color scheme?", "Are you sure?", - MessageBoxButtons.YesNo, MessageBoxIcon.Question); - - if (result == DialogResult.Yes) - { - string selectedSchemeFilePath = Path.Combine(DefaultPaths.LuaColorConfigsDirectory, comboBox_ColorSchemes.SelectedItem + ".luasch"); - - if (File.Exists(selectedSchemeFilePath)) - { - Microsoft.VisualBasic.FileIO.FileSystem.DeleteFile(selectedSchemeFilePath, - Microsoft.VisualBasic.FileIO.UIOption.AllDialogs, Microsoft.VisualBasic.FileIO.RecycleOption.SendToRecycleBin); - - comboBox_ColorSchemes.Items.Remove(comboBox_ColorSchemes.SelectedItem); - comboBox_ColorSchemes.SelectedIndex = 0; - } - } } private void button_ImportScheme_Click(object sender, EventArgs e) { - using (var dialog = new OpenFileDialog()) - { - dialog.Filter = "Lua Scheme|*.luasch"; - - if (dialog.ShowDialog(this) == DialogResult.OK) - { - File.Copy(dialog.FileName, Path.Combine(DefaultPaths.LuaColorConfigsDirectory, Path.GetFileName(dialog.FileName)), true); - UpdateSchemeList(); - - comboBox_ColorSchemes.SelectedItem = Path.GetFileNameWithoutExtension(dialog.FileName); - } - } } private void button_OpenSchemesFolder_Click(object sender, EventArgs e) { - var startInfo = new ProcessStartInfo - { - FileName = "explorer.exe", - Arguments = DefaultPaths.LuaColorConfigsDirectory, - UseShellExecute = true - }; - - Process.Start(startInfo); } #endregion Events @@ -209,13 +178,16 @@ private void button_OpenSchemesFolder_Click(object sender, EventArgs e) private void UpdateControlsWithSettings(LuaEditorConfiguration config) { - numeric_FontSize.Value = (decimal)config.FontSize - 4; // -4 because AvalonEdit has a different font size scale + numeric_FontSize.Value = (decimal)config.FontSize - 4; comboBox_FontFamily.SelectedItem = config.FontFamily; numeric_UndoStackSize.Value = config.UndoStackSize; LoadSettingsForCheckBoxes(config); - comboBox_ColorSchemes.SelectedItem = config.SelectedColorSchemeName; + if (comboBox_ColorSchemes.Items.Contains(config.SelectedThemeName)) + comboBox_ColorSchemes.SelectedItem = config.SelectedThemeName; + else if (comboBox_ColorSchemes.Items.Contains(ConfigurationDefaults.SelectedThemeName)) + comboBox_ColorSchemes.SelectedItem = ConfigurationDefaults.SelectedThemeName; } private void LoadSettingsForCheckBoxes(LuaEditorConfiguration config) @@ -241,13 +213,14 @@ private void LoadSettingsForCheckBoxes(LuaEditorConfiguration config) public void ApplySettings(LuaEditorConfiguration config) { - config.FontSize = (double)(numeric_FontSize.Value + 4); // +4 because AvalonEdit has a different font size scale - config.FontFamily = comboBox_FontFamily.SelectedItem.ToString(); + config.FontSize = (double)(numeric_FontSize.Value + 4); + config.FontFamily = comboBox_FontFamily.SelectedItem?.ToString() ?? TextEditorBaseDefaults.FontFamily; config.UndoStackSize = (int)numeric_UndoStackSize.Value; ApplySettingsFromCheckBoxes(config); - //config.SelectedColorSchemeName = comboBox_ColorSchemes.SelectedItem.ToString(); + if (comboBox_ColorSchemes.SelectedItem is not null) + config.SelectedThemeName = comboBox_ColorSchemes.SelectedItem.ToString(); config.Save(); } @@ -264,7 +237,6 @@ private void ApplySettingsFromCheckBoxes(LuaEditorConfiguration config) config.HighlightCurrentLine = checkBox_HighlightCurrentLine.Checked; config.ShowLineNumbers = checkBox_LineNumbers.Checked; - config.ShowVisualSpaces = checkBox_VisibleSpaces.Checked; config.ShowVisualTabs = checkBox_VisibleTabs.Checked; } @@ -275,11 +247,15 @@ private void ApplySettingsFromCheckBoxes(LuaEditorConfiguration config) public void ResetToDefault() { - numeric_FontSize.Value = (decimal)(TextEditorBaseDefaults.FontSize - 4); // -4 because AvalonEdit has a different font size scale + numeric_FontSize.Value = (decimal)(TextEditorBaseDefaults.FontSize - 4); comboBox_FontFamily.SelectedItem = TextEditorBaseDefaults.FontFamily; numeric_UndoStackSize.Value = TextEditorBaseDefaults.UndoStackSize; - ResetCheckBoxSettings(); + + if (comboBox_ColorSchemes.Items.Contains(ConfigurationDefaults.SelectedThemeName)) + comboBox_ColorSchemes.SelectedItem = ConfigurationDefaults.SelectedThemeName; + + UpdatePreview(); } private void ResetCheckBoxSettings() @@ -294,195 +270,96 @@ private void ResetCheckBoxSettings() checkBox_HighlightCurrentLine.Checked = TextEditorBaseDefaults.HighlightCurrentLine; checkBox_LineNumbers.Checked = TextEditorBaseDefaults.ShowLineNumbers; - checkBox_VisibleSpaces.Checked = TextEditorBaseDefaults.ShowVisualSpaces; checkBox_VisibleTabs.Checked = TextEditorBaseDefaults.ShowVisualTabs; } #endregion Resetting - public void ForcePreviewUpdate() => - editorPreview.Focus(); + public void ForcePreviewUpdate() + => editorPreview.Focus(); - private void ChangeColor(DarkButton targetButton) + private void UpdatePreview() { - colorDialog.Color = targetButton.BackColor; - - if (colorDialog.ShowDialog(this) == DialogResult.OK) - { - targetButton.BackColor = colorDialog.Color; - - if (targetButton.Tag != null) - ((HighlightingObject)targetButton.Tag).HtmlColor = ColorTranslator.ToHtml(colorDialog.Color); - - UpdatePreview(); + if (editorPreview is null) + return; - UpdateColorButtonStyleText(targetButton); - } + LuaEditorConfiguration previewConfig = CreatePreviewConfiguration(); + editorPreview.UpdateSettings(previewConfig); + editorPreview.SetSemanticTokens(PreviewTokens); + ForcePreviewUpdate(); } - private void UpdatePreview() + private LuaEditorConfiguration CreatePreviewConfiguration() { - var currentScheme = new ColorScheme + var config = new LuaEditorConfiguration { - Values = (HighlightingObject)colorButton_Values.Tag, - Operators = (HighlightingObject)colorButton_Operators.Tag, - SpecialOperators = (HighlightingObject)colorButton_SpecialOperators.Tag, - Statements = (HighlightingObject)colorButton_Statements.Tag, - Comments = (HighlightingObject)colorButton_Comments.Tag, - Background = ColorTranslator.ToHtml(colorButton_Background.BackColor), - Foreground = ColorTranslator.ToHtml(colorButton_Foreground.BackColor) + FontSize = (double)(numeric_FontSize.Value + 4), + FontFamily = comboBox_FontFamily.SelectedItem?.ToString() ?? TextEditorBaseDefaults.FontFamily, + UndoStackSize = (int)numeric_UndoStackSize.Value, + AutocompleteEnabled = checkBox_Autocomplete.Checked, + WordWrapping = checkBox_WordWrapping.Checked, + AutoCloseParentheses = checkBox_CloseParentheses.Checked, + AutoCloseBrackets = checkBox_CloseBrackets.Checked, + AutoCloseQuotes = checkBox_CloseQuotes.Checked, + AutoCloseBraces = checkBox_CloseBraces.Checked, + HighlightCurrentLine = checkBox_HighlightCurrentLine.Checked, + ShowLineNumbers = checkBox_LineNumbers.Checked, + ShowVisualSpaces = checkBox_VisibleSpaces.Checked, + ShowVisualTabs = checkBox_VisibleTabs.Checked }; - bool itemFound = false; - - foreach (string item in comboBox_ColorSchemes.Items) - { - if (item == "~UNTITLED") - continue; - - ColorScheme itemScheme = XmlUtils.ReadXmlFile(Path.Combine(DefaultPaths.LuaColorConfigsDirectory, item + ".luasch")); - - if (currentScheme == itemScheme) - { - comboBox_ColorSchemes.SelectedItem = item; - itemFound = true; - break; - } - } - - if (!itemFound) - { - if (!comboBox_ColorSchemes.Items.Contains("~UNTITLED")) - comboBox_ColorSchemes.Items.Add("~UNTITLED"); - - XmlUtils.WriteXmlFile(Path.Combine(DefaultPaths.LuaColorConfigsDirectory, "~UNTITLED.luasch"), currentScheme); - - comboBox_ColorSchemes.SelectedItem = "~UNTITLED"; - } - - UpdatePreviewColors(currentScheme); - } - - private void UpdateColorButtons(ColorScheme scheme) - { - colorButton_Values.BackColor = ColorTranslator.FromHtml(scheme.Values.HtmlColor); - colorButton_Values.Tag = scheme.Values; - - colorButton_Operators.BackColor = ColorTranslator.FromHtml(scheme.Operators.HtmlColor); - colorButton_Operators.Tag = scheme.Operators; - - colorButton_SpecialOperators.BackColor = ColorTranslator.FromHtml(scheme.SpecialOperators.HtmlColor); - colorButton_SpecialOperators.Tag = scheme.SpecialOperators; - - colorButton_Statements.BackColor = ColorTranslator.FromHtml(scheme.Statements.HtmlColor); - colorButton_Statements.Tag = scheme.Statements; - - colorButton_Comments.BackColor = ColorTranslator.FromHtml(scheme.Comments.HtmlColor); - colorButton_Comments.Tag = scheme.Comments; - - UpdateColorButtonStyleText(colorButton_Values); - UpdateColorButtonStyleText(colorButton_Operators); - UpdateColorButtonStyleText(colorButton_SpecialOperators); - UpdateColorButtonStyleText(colorButton_Statements); - UpdateColorButtonStyleText(colorButton_Comments); - UpdateColorButtonStyleText(colorButton_Comments); - - colorButton_Background.BackColor = ColorTranslator.FromHtml(scheme.Background); - colorButton_Foreground.BackColor = ColorTranslator.FromHtml(scheme.Foreground); + config.SelectedThemeName = comboBox_ColorSchemes.SelectedItem?.ToString() ?? ConfigurationDefaults.SelectedThemeName; + return config; } private void buttonContextMenu_Opening(object sender, CancelEventArgs e) { - var sourceButton = (DarkButton)((DarkContextMenu)sender).SourceControl; - var highlighting = (HighlightingObject)sourceButton.Tag; - - menuItem_Bold.Checked = highlighting.IsBold; - menuItem_Italic.Checked = highlighting.IsItalic; - } - - private void UpdateButton(object sender) - { - var sourceButton = (DarkButton)((DarkContextMenu)((ToolStripMenuItem)sender).GetCurrentParent()).SourceControl; - var highlighting = (HighlightingObject)sourceButton.Tag; - - highlighting.IsBold = menuItem_Bold.Checked; - highlighting.IsItalic = menuItem_Italic.Checked; - - UpdateColorButtonStyleText(sourceButton); - - UpdatePreview(); + e.Cancel = true; } - private void UpdateColorButtonStyleText(DarkButton colorButton) + private static IReadOnlyList CreatePreviewTokens() { - if (colorButton.Tag == null) - return; - - var highlighting = (HighlightingObject)colorButton.Tag; - - if (highlighting.IsBold && highlighting.IsItalic) - colorButton.Text = "Style: Bold & Italic"; - else if (highlighting.IsBold) - colorButton.Text = "Style: Bold"; - else if (highlighting.IsItalic) - colorButton.Text = "Style: Italic"; - else - colorButton.Text = "Style: Normal"; - - if (colorButton.BackColor.R + (colorButton.BackColor.G * 1.25) + colorButton.BackColor.B > 384) // Green is a much lighter color - colorButton.ForeColor = Color.Black; - else - colorButton.ForeColor = Color.White; + return new[] + { + CreatePreviewToken(0, "Weapon", "class"), + CreatePreviewToken(1, "Weapon", "class"), + CreatePreviewToken(2, "levelName", "variable", 1, "global"), + CreatePreviewToken(4, "Weapon", "class"), + CreatePreviewToken(4, "new", "method", 1, "declaration"), + CreatePreviewToken(4, "name", "parameter"), + CreatePreviewToken(5, "math", "namespace", 1, "defaultLibrary"), + CreatePreviewToken(5, "max", "function", 1, "defaultLibrary"), + CreatePreviewToken(5, "levelName", "variable", 1, "global"), + CreatePreviewToken(6, "name", "property", 1), + CreatePreviewToken(6, "name", "parameter", 2) + }; } - private void UpdatePreviewColors(ColorScheme scheme) + private static LuaSemanticToken CreatePreviewToken(int lineIndex, string tokenText, string tokenType, params string[] modifiers) { - editorPreview.Background = new System.Windows.Media.SolidColorBrush - ( - (System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString - ( - scheme.Background - ) - ); - - editorPreview.Foreground = new System.Windows.Media.SolidColorBrush - ( - (System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString - ( - scheme.Foreground - ) - ); - - editorPreview.SyntaxHighlighting = new SyntaxHighlighting(scheme); + return CreatePreviewToken(lineIndex, tokenText, tokenType, 1, modifiers); } - private void ToggleSaveSchemeButton() + private static LuaSemanticToken CreatePreviewToken(int lineIndex, string tokenText, string tokenType, int occurrence, params string[] modifiers) { - bool isUntitled = comboBox_ColorSchemes.SelectedItem.ToString().Equals("~UNTITLED", StringComparison.OrdinalIgnoreCase); - - button_SaveScheme.Enabled = isUntitled; - button_SaveScheme.Visible = isUntitled; - - comboBox_ColorSchemes.Width = isUntitled ? 395 : 426; + int characterIndex = GetOccurrenceIndex(PreviewLines[lineIndex], tokenText, occurrence); + return new LuaSemanticToken(lineIndex, characterIndex, tokenText.Length, tokenType, modifiers); } - private void UpdatePreviewTemp(bool forceUpdate = true) + private static int GetOccurrenceIndex(string line, string tokenText, int occurrence) { - editorPreview.FontSize = (double)(numeric_FontSize.Value + 4); // +4 because AvalonEdit has a different font size scale - - if (comboBox_FontFamily.SelectedItem != null) - editorPreview.FontFamily = new System.Windows.Media.FontFamily(comboBox_FontFamily.SelectedItem.ToString()); + int startIndex = -1; - editorPreview.WordWrap = checkBox_WordWrapping.Checked; - editorPreview.Options.HighlightCurrentLine = checkBox_HighlightCurrentLine.Checked; - editorPreview.ShowLineNumbers = checkBox_LineNumbers.Checked; + for (int currentOccurrence = 0; currentOccurrence < occurrence; currentOccurrence++) + { + startIndex = line.IndexOf(tokenText, startIndex + 1, StringComparison.Ordinal); - editorPreview.Options.ShowSpaces = checkBox_VisibleSpaces.Checked; - editorPreview.Options.ShowTabs = checkBox_VisibleTabs.Checked; + if (startIndex < 0) + throw new InvalidOperationException("Failed to locate preview token '" + tokenText + "'."); + } - if (forceUpdate) - ForcePreviewUpdate(); + return startIndex; } } } diff --git a/TombIDE/TombIDE.ScriptingStudio/TombIDE.ScriptingStudio.csproj b/TombIDE/TombIDE.ScriptingStudio/TombIDE.ScriptingStudio.csproj index a9a2c4c87b..3bc30e3c68 100644 --- a/TombIDE/TombIDE.ScriptingStudio/TombIDE.ScriptingStudio.csproj +++ b/TombIDE/TombIDE.ScriptingStudio/TombIDE.ScriptingStudio.csproj @@ -1,36 +1,16 @@  - net6.0-windows - Library false true true - true - Debug;Release - x64;x86 - - - ..\..\Build ($(Platform))\ - - - ..\..\BuildRelease ($(Platform))\ - none - true - - - x64 - - - x86 ..\..\Libs\CustomTabControl.dll - - False - ..\..\Libs\ICSharpCode.AvalonEdit.dll - + + + @@ -87,6 +67,7 @@ + diff --git a/TombIDE/TombIDE.ScriptingStudio/ToolWindows/LuaDiagnostics.cs b/TombIDE/TombIDE.ScriptingStudio/ToolWindows/LuaDiagnostics.cs new file mode 100644 index 0000000000..0126fb48fd --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/ToolWindows/LuaDiagnostics.cs @@ -0,0 +1,47 @@ +#nullable enable + +using DarkUI.Docking; +using ICSharpCode.AvalonEdit.Document; +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Windows.Forms; +using System.Windows.Forms.Integration; +using TombIDE.ScriptingStudio.Objects; +using TombIDE.ScriptingStudio.ViewModels; +using TombIDE.ScriptingStudio.Views; +using TombIDE.Shared; +using TombLib.Scripting.Objects; + +namespace TombIDE.ScriptingStudio.ToolWindows; + +public sealed class LuaDiagnostics : DarkToolWindow +{ + private readonly LuaDiagnosticsViewModel _viewModel = new(); + + internal LuaDiagnostics(Action? activateDiagnostic) + { + ElementHost elementHost = new() + { + Dock = DockStyle.Fill, + Child = new LuaDiagnosticsView(_viewModel, activateDiagnostic) + }; + + Controls.Add(elementHost); + + DefaultDockArea = DarkDockArea.Bottom; + DockText = Strings.Default.LuaDiagnostics; + Name = nameof(LuaDiagnostics); + SerializationKey = nameof(LuaDiagnostics); + Size = new Size(420, 220); + } + + public void ShowNoActiveDocument() + => _viewModel.ShowNoActiveDocument(); + + public void ShowPending() + => _viewModel.ShowPending(); + + public void ShowDiagnostics(string filePath, TextDocument document, IReadOnlyList diagnostics) + => _viewModel.ShowDiagnostics(filePath, document, diagnostics); +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/ToolWindows/LuaReferencesResults.cs b/TombIDE/TombIDE.ScriptingStudio/ToolWindows/LuaReferencesResults.cs new file mode 100644 index 0000000000..a49bbbd07d --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/ToolWindows/LuaReferencesResults.cs @@ -0,0 +1,48 @@ +#nullable enable + +using DarkUI.Docking; +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Windows.Forms; +using System.Windows.Forms.Integration; +using TombIDE.ScriptingStudio.Objects; +using TombIDE.ScriptingStudio.ViewModels; +using TombIDE.ScriptingStudio.Views; +using TombIDE.Shared; + +namespace TombIDE.ScriptingStudio.ToolWindows; + +public sealed class LuaReferencesResults : DarkToolWindow +{ + private readonly LuaReferencesResultsViewModel _viewModel = new(); + + internal LuaReferencesResults(Action? activateReference) + { + ElementHost elementHost = new() + { + Dock = DockStyle.Fill, + Child = new LuaReferencesResultsView(_viewModel, activateReference) + }; + + Controls.Add(elementHost); + + DefaultDockArea = DarkDockArea.Bottom; + DockText = Strings.Default.LuaReferencesResults; + Name = nameof(LuaReferencesResults); + SerializationKey = nameof(LuaReferencesResults); + Size = new Size(420, 220); + } + + public void ShowNoActiveDocument() + => _viewModel.ShowNoActiveDocument(); + + public void ShowUnsupported() + => _viewModel.ShowUnsupported(); + + public void ShowLoading() + => _viewModel.ShowLoading(); + + internal void ShowReferences(IReadOnlyList groups) + => _viewModel.ShowReferences(groups); +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/ToolWindows/SearchResults.cs b/TombIDE/TombIDE.ScriptingStudio/ToolWindows/SearchResults.cs index b91e6789ff..d8e9d7cca1 100644 --- a/TombIDE/TombIDE.ScriptingStudio/ToolWindows/SearchResults.cs +++ b/TombIDE/TombIDE.ScriptingStudio/ToolWindows/SearchResults.cs @@ -1,26 +1,24 @@ -using DarkUI.Controls; +#nullable enable + +using DarkUI.Controls; using DarkUI.Docking; -using ICSharpCode.AvalonEdit.Document; -using System.Text.RegularExpressions; +using System; using System.Windows.Forms; -using TombIDE.ScriptingStudio.Controls; using TombIDE.Shared; -using TombLib.Scripting.Bases; -using TombLib.Scripting.Enums; using TombLib.Scripting.Objects; namespace TombIDE.ScriptingStudio.ToolWindows { public partial class SearchResults : DarkToolWindow { - private EditorTabControl _targetTabControl; + private readonly Action? _navigateToSearchResult; - public SearchResults(EditorTabControl targetTabControl) + public SearchResults(Action? navigateToSearchResult) { InitializeComponent(); DockText = Strings.Default.SearchResults; - _targetTabControl = targetTabControl; + _navigateToSearchResult = navigateToSearchResult; } public void UpdateResults(FindReplaceEventArgs e) @@ -56,43 +54,14 @@ private void treeView_MouseDoubleClick(object sender, MouseEventArgs e) return; var item = treeView.SelectedNodes[0].Tag as FindReplaceItem; + if (item is null) + return; - if (_targetTabControl != null) - { - string sourceFilePath = treeView.SelectedNodes[0].ParentNode.Tag.ToString(); - TabPage tab = _targetTabControl.FindTabPage(sourceFilePath, EditorType.Text); - - if (tab != null) - { - _targetTabControl.SelectTab(tab); - HandleJump(_targetTabControl.CurrentEditor as TextEditorBase, item); - } - } - } - - private void HandleJump(TextEditorBase textEditor, FindReplaceItem item) - { - try - { - DocumentLine line = textEditor.Document.GetLineByNumber(item.LineNumber); - string lineText = textEditor.Document.GetText(line.Offset, line.Length); - - MatchCollection matches = Regex.Matches(lineText, item.MatchSegmentText); - - if (item.MatchSegmentIndex > matches.Count) - { - textEditor.Select(line.Offset, 0); - textEditor.ScrollToLine(line.LineNumber); - } - else - { - Match match = matches[item.MatchSegmentIndex]; + string? sourceFilePath = treeView.SelectedNodes[0].ParentNode?.Tag?.ToString(); + if (string.IsNullOrWhiteSpace(sourceFilePath)) + return; - textEditor.Select(line.Offset + match.Index, match.Length); - textEditor.ScrollToLine(line.LineNumber); - } - } - catch { } + _navigateToSearchResult?.Invoke(sourceFilePath, item); } private bool IsRootNode() diff --git a/TombIDE/TombIDE.ScriptingStudio/UI/DocumentModePresets/ContextMenus/ClassicScript.xml b/TombIDE/TombIDE.ScriptingStudio/UI/DocumentModePresets/ContextMenus/ClassicScript.xml index c9faf03b61..4901fe163e 100644 --- a/TombIDE/TombIDE.ScriptingStudio/UI/DocumentModePresets/ContextMenus/ClassicScript.xml +++ b/TombIDE/TombIDE.ScriptingStudio/UI/DocumentModePresets/ContextMenus/ClassicScript.xml @@ -4,6 +4,7 @@ + diff --git a/TombIDE/TombIDE.ScriptingStudio/UI/DocumentModePresets/ContextMenus/GameFlowScript.xml b/TombIDE/TombIDE.ScriptingStudio/UI/DocumentModePresets/ContextMenus/GameFlowScript.xml index 8d3bb645af..0c8e8a714f 100644 --- a/TombIDE/TombIDE.ScriptingStudio/UI/DocumentModePresets/ContextMenus/GameFlowScript.xml +++ b/TombIDE/TombIDE.ScriptingStudio/UI/DocumentModePresets/ContextMenus/GameFlowScript.xml @@ -4,6 +4,7 @@ + diff --git a/TombIDE/TombIDE.ScriptingStudio/UI/DocumentModePresets/ContextMenus/Lua.xml b/TombIDE/TombIDE.ScriptingStudio/UI/DocumentModePresets/ContextMenus/Lua.xml index 8d3bb645af..84089e5d6b 100644 --- a/TombIDE/TombIDE.ScriptingStudio/UI/DocumentModePresets/ContextMenus/Lua.xml +++ b/TombIDE/TombIDE.ScriptingStudio/UI/DocumentModePresets/ContextMenus/Lua.xml @@ -4,6 +4,13 @@ + + + + + + + diff --git a/TombIDE/TombIDE.ScriptingStudio/UI/DocumentModePresets/ContextMenus/Tomb1Main.xml b/TombIDE/TombIDE.ScriptingStudio/UI/DocumentModePresets/ContextMenus/Tomb1Main.xml index 8d3bb645af..0c8e8a714f 100644 --- a/TombIDE/TombIDE.ScriptingStudio/UI/DocumentModePresets/ContextMenus/Tomb1Main.xml +++ b/TombIDE/TombIDE.ScriptingStudio/UI/DocumentModePresets/ContextMenus/Tomb1Main.xml @@ -4,6 +4,7 @@ + diff --git a/TombIDE/TombIDE.ScriptingStudio/UI/DocumentModePresets/MenuStrips/ClassicScript.xml b/TombIDE/TombIDE.ScriptingStudio/UI/DocumentModePresets/MenuStrips/ClassicScript.xml index 6faacb4c90..901604d340 100644 --- a/TombIDE/TombIDE.ScriptingStudio/UI/DocumentModePresets/MenuStrips/ClassicScript.xml +++ b/TombIDE/TombIDE.ScriptingStudio/UI/DocumentModePresets/MenuStrips/ClassicScript.xml @@ -9,6 +9,7 @@ + diff --git a/TombIDE/TombIDE.ScriptingStudio/UI/DocumentModePresets/MenuStrips/GameFlowScript.xml b/TombIDE/TombIDE.ScriptingStudio/UI/DocumentModePresets/MenuStrips/GameFlowScript.xml index bcb42987ca..f5eee0bd3f 100644 --- a/TombIDE/TombIDE.ScriptingStudio/UI/DocumentModePresets/MenuStrips/GameFlowScript.xml +++ b/TombIDE/TombIDE.ScriptingStudio/UI/DocumentModePresets/MenuStrips/GameFlowScript.xml @@ -8,6 +8,7 @@ + diff --git a/TombIDE/TombIDE.ScriptingStudio/UI/DocumentModePresets/MenuStrips/Lua.xml b/TombIDE/TombIDE.ScriptingStudio/UI/DocumentModePresets/MenuStrips/Lua.xml index 6faacb4c90..eb32884e92 100644 --- a/TombIDE/TombIDE.ScriptingStudio/UI/DocumentModePresets/MenuStrips/Lua.xml +++ b/TombIDE/TombIDE.ScriptingStudio/UI/DocumentModePresets/MenuStrips/Lua.xml @@ -9,6 +9,13 @@ + + + + + + + diff --git a/TombIDE/TombIDE.ScriptingStudio/UI/DocumentModePresets/MenuStrips/Tomb1Main.xml b/TombIDE/TombIDE.ScriptingStudio/UI/DocumentModePresets/MenuStrips/Tomb1Main.xml index bcb42987ca..f5eee0bd3f 100644 --- a/TombIDE/TombIDE.ScriptingStudio/UI/DocumentModePresets/MenuStrips/Tomb1Main.xml +++ b/TombIDE/TombIDE.ScriptingStudio/UI/DocumentModePresets/MenuStrips/Tomb1Main.xml @@ -8,6 +8,7 @@ + diff --git a/TombIDE/TombIDE.ScriptingStudio/UI/StudioModePresets/MenuStrips/Lua.xml b/TombIDE/TombIDE.ScriptingStudio/UI/StudioModePresets/MenuStrips/Lua.xml index 3a488ea59d..07a333b1a7 100644 --- a/TombIDE/TombIDE.ScriptingStudio/UI/StudioModePresets/MenuStrips/Lua.xml +++ b/TombIDE/TombIDE.ScriptingStudio/UI/StudioModePresets/MenuStrips/Lua.xml @@ -20,6 +20,8 @@ + + @@ -33,6 +35,8 @@ + + diff --git a/TombIDE/TombIDE.ScriptingStudio/UI/StudioModePresets/ToolStrips/Lua.xml b/TombIDE/TombIDE.ScriptingStudio/UI/StudioModePresets/ToolStrips/Lua.xml index 2a19e5e69e..343558ce4c 100644 --- a/TombIDE/TombIDE.ScriptingStudio/UI/StudioModePresets/ToolStrips/Lua.xml +++ b/TombIDE/TombIDE.ScriptingStudio/UI/StudioModePresets/ToolStrips/Lua.xml @@ -1,5 +1,8 @@  + + + diff --git a/TombIDE/TombIDE.ScriptingStudio/UI/UICommand.cs b/TombIDE/TombIDE.ScriptingStudio/UI/UICommand.cs index fd6858bae4..84cfa64443 100644 --- a/TombIDE/TombIDE.ScriptingStudio/UI/UICommand.cs +++ b/TombIDE/TombIDE.ScriptingStudio/UI/UICommand.cs @@ -30,6 +30,7 @@ public enum UICommand Reindent, TrimWhiteSpace, + ToggleComment, CommentOut, Uncomment, ToggleBookmark, @@ -60,6 +61,8 @@ public enum UICommand ReferenceBrowser, CompilerLogs, SearchResults, + LuaDiagnostics, + LuaReferencesResults, StatusStrip, // View end @@ -72,6 +75,11 @@ public enum UICommand // Other: + NavigateBack, + NavigateForward, + GoToDefinition, + FindReferences, + RenameSymbol, TypeFirstAvailableId, NewFileAtCaret } diff --git a/TombIDE/TombIDE.ScriptingStudio/UI/UIElement.cs b/TombIDE/TombIDE.ScriptingStudio/UI/UIElement.cs index 200b0a4be3..2062415080 100644 --- a/TombIDE/TombIDE.ScriptingStudio/UI/UIElement.cs +++ b/TombIDE/TombIDE.ScriptingStudio/UI/UIElement.cs @@ -24,6 +24,7 @@ public enum UIElement Reindent, TrimWhiteSpace, + ToggleComment, CommentOut, Uncomment, ToggleBookmark, diff --git a/TombIDE/TombIDE.ScriptingStudio/UI/UIKeys.cs b/TombIDE/TombIDE.ScriptingStudio/UI/UIKeys.cs index 842f096a46..da0a2bd870 100644 --- a/TombIDE/TombIDE.ScriptingStudio/UI/UIKeys.cs +++ b/TombIDE/TombIDE.ScriptingStudio/UI/UIKeys.cs @@ -21,9 +21,15 @@ internal struct UIKeys public const Keys Reindent = Keys.Control | Keys.R; public const Keys TrimWhitespace = Keys.Control | Keys.Shift | Keys.R; + public const Keys ToggleComment = Keys.Control | Keys.OemQuestion; public const Keys CommentOut = Keys.Control | Keys.Shift | Keys.C; public const Keys Uncomment = Keys.Control | Keys.Shift | Keys.U; public const Keys ToggleBookmark = Keys.Control | Keys.B; + public const Keys NavigateBack = Keys.Alt | Keys.Left; + public const Keys NavigateForward = Keys.Alt | Keys.Right; + public const Keys GoToDefinition = Keys.F12; + public const Keys FindReferences = Keys.Shift | Keys.F12; + public const Keys RenameSymbol = Keys.F2; public const Keys PrevBookmark = Keys.Control | Keys.Oemcomma; public const Keys NextBookmark = Keys.Control | Keys.OemPeriod; public const Keys ClearBookmarks = Keys.Control | Keys.Shift | Keys.B; diff --git a/TombIDE/TombIDE.ScriptingStudio/ViewModels/LuaDiagnosticsViewModel.cs b/TombIDE/TombIDE.ScriptingStudio/ViewModels/LuaDiagnosticsViewModel.cs new file mode 100644 index 0000000000..c73be11bbf --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/ViewModels/LuaDiagnosticsViewModel.cs @@ -0,0 +1,194 @@ +#nullable enable + +using ICSharpCode.AvalonEdit.Document; +using System; +using System.Collections.ObjectModel; +using System.Collections.Generic; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Windows.Data; +using TombIDE.ScriptingStudio.Objects; +using TombIDE.Shared; +using TombLib.Scripting.Objects; + +namespace TombIDE.ScriptingStudio.ViewModels; + +internal sealed class LuaDiagnosticsViewModel : INotifyPropertyChanged +{ + private readonly ObservableCollection _diagnostics = []; + + private LuaDiagnosticListItem? _selectedItem; + private bool _showErrors = true; + private bool _showWarnings = true; + private bool _showMessages = true; + private string _statusText = string.Empty; + + public LuaDiagnosticsViewModel() + { + Diagnostics = CollectionViewSource.GetDefaultView(_diagnostics); + Diagnostics.Filter = FilterDiagnostic; + + ShowNoActiveDocument(); + } + + public event PropertyChangedEventHandler? PropertyChanged; + + public ICollectionView Diagnostics { get; } + + public LuaDiagnosticListItem? SelectedItem + { + get => _selectedItem; + set => SetField(ref _selectedItem, value); + } + + public bool ShowErrors + { + get => _showErrors; + set + { + if (!SetField(ref _showErrors, value)) + return; + + Diagnostics.Refresh(); + } + } + + public bool ShowWarnings + { + get => _showWarnings; + set + { + if (!SetField(ref _showWarnings, value)) + return; + + Diagnostics.Refresh(); + } + } + + public bool ShowMessages + { + get => _showMessages; + set + { + if (!SetField(ref _showMessages, value)) + return; + + Diagnostics.Refresh(); + } + } + + public string StatusText + { + get => _statusText; + private set + { + if (!SetField(ref _statusText, value)) + return; + + OnPropertyChanged(nameof(HasStatusText)); + } + } + + public bool HasStatusText => !string.IsNullOrWhiteSpace(StatusText); + + public string ErrorsLabel => Strings.Default.Errors; + + public string WarningsLabel => Strings.Default.Warnings; + + public string MessagesLabel => Strings.Default.Messages; + + public string SeverityHeader => Strings.Default.Severity; + + public string LineHeader => Strings.Default.LineHeader; + + public string ColumnHeader => Strings.Default.ColumnHeader; + + public string MessageHeader => Strings.Default.Message; + + public void ShowNoActiveDocument() + => ReplaceDiagnostics([], Strings.Default.LuaDiagnosticsNoDocument); + + public void ShowPending() + => ReplaceDiagnostics([], Strings.Default.LuaDiagnosticsUpdating); + + public void ShowDiagnostics(string filePath, TextDocument document, IReadOnlyList diagnostics) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(diagnostics); + + LuaDiagnosticListItem[] items = new LuaDiagnosticListItem[diagnostics.Count]; + + for (int i = 0; i < diagnostics.Count; i++) + items[i] = CreateItem(filePath, document, diagnostics[i]); + + ReplaceDiagnostics(items, items.Length == 0 ? Strings.Default.NoDiagnostics : string.Empty); + } + + private void ReplaceDiagnostics(IReadOnlyList diagnostics, string statusText) + { + _diagnostics.Clear(); + + foreach (LuaDiagnosticListItem diagnostic in diagnostics) + _diagnostics.Add(diagnostic); + + SelectedItem = _diagnostics.Count > 0 ? _diagnostics[0] : null; + StatusText = statusText; + Diagnostics.Refresh(); + } + + private bool FilterDiagnostic(object item) + { + if (item is not LuaDiagnosticListItem diagnostic) + return false; + + return diagnostic.Severity switch + { + TextEditorDiagnosticSeverity.Error => ShowErrors, + TextEditorDiagnosticSeverity.Warning => ShowWarnings, + _ => ShowMessages + }; + } + + private static LuaDiagnosticListItem CreateItem(string filePath, TextDocument document, TextEditorDiagnostic diagnostic) + { + int documentLength = document.TextLength; + int startOffset = Math.Max(0, Math.Min(diagnostic.StartOffset, documentLength)); + int endOffset = Math.Max(startOffset, Math.Min(diagnostic.EndOffset, documentLength)); + + DocumentLine line = document.GetLineByOffset(startOffset); + int columnNumber = startOffset - line.Offset + 1; + + return new LuaDiagnosticListItem( + filePath, + diagnostic.Severity, + GetSeverityLabel(diagnostic.Severity), + line.LineNumber, + columnNumber, + diagnostic.Message, + startOffset, + endOffset); + } + + private static string GetSeverityLabel(TextEditorDiagnosticSeverity severity) + => severity switch + { + TextEditorDiagnosticSeverity.Error => Strings.Default.Error, + TextEditorDiagnosticSeverity.Warning => Strings.Default.Warning, + TextEditorDiagnosticSeverity.Information => Strings.Default.Information, + TextEditorDiagnosticSeverity.Hint => Strings.Default.Hint, + _ => Strings.Default.Message + }; + + private bool SetField(ref T field, T value, [CallerMemberName] string? propertyName = null) + { + if (Equals(field, value)) + return false; + + field = value; + OnPropertyChanged(propertyName); + return true; + } + + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/ViewModels/LuaReferencesResultsViewModel.cs b/TombIDE/TombIDE.ScriptingStudio/ViewModels/LuaReferencesResultsViewModel.cs new file mode 100644 index 0000000000..5cbe148299 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/ViewModels/LuaReferencesResultsViewModel.cs @@ -0,0 +1,72 @@ +#nullable enable + +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using TombIDE.ScriptingStudio.Objects; +using TombIDE.Shared; + +namespace TombIDE.ScriptingStudio.ViewModels; + +internal sealed class LuaReferencesResultsViewModel : INotifyPropertyChanged +{ + private readonly ObservableCollection _groups = []; + private string _statusText = string.Empty; + + public event PropertyChangedEventHandler? PropertyChanged; + + public ObservableCollection Groups => _groups; + + public string StatusText + { + get => _statusText; + private set + { + if (!SetField(ref _statusText, value)) + return; + + OnPropertyChanged(nameof(HasStatusText)); + } + } + + public bool HasStatusText => !string.IsNullOrWhiteSpace(StatusText); + + public bool HasResults => _groups.Count > 0; + + public void ShowNoActiveDocument() + => ReplaceGroups([], Strings.Default.LuaReferencesNoDocument); + + public void ShowUnsupported() + => ReplaceGroups([], Strings.Default.LuaReferencesUnsupported); + + public void ShowLoading() + => ReplaceGroups([], Strings.Default.LuaReferencesLoading); + + public void ShowReferences(IReadOnlyList groups) + => ReplaceGroups(groups, groups.Count == 0 ? Strings.Default.NoReferencesFound : string.Empty); + + private void ReplaceGroups(IReadOnlyList groups, string statusText) + { + _groups.Clear(); + + foreach (LuaReferenceGroup group in groups) + _groups.Add(group); + + StatusText = statusText; + OnPropertyChanged(nameof(HasResults)); + } + + private bool SetField(ref T field, T value, [CallerMemberName] string? propertyName = null) + { + if (EqualityComparer.Default.Equals(field, value)) + return false; + + field = value; + OnPropertyChanged(propertyName); + return true; + } + + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/Views/LuaDiagnosticsView.xaml b/TombIDE/TombIDE.ScriptingStudio/Views/LuaDiagnosticsView.xaml new file mode 100644 index 0000000000..798bce96d5 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/Views/LuaDiagnosticsView.xaml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/Views/LuaDiagnosticsView.xaml.cs b/TombIDE/TombIDE.ScriptingStudio/Views/LuaDiagnosticsView.xaml.cs new file mode 100644 index 0000000000..8f8d8adaf6 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/Views/LuaDiagnosticsView.xaml.cs @@ -0,0 +1,43 @@ +#nullable enable + +using System; +using System.Windows.Controls; +using System.Windows.Input; +using TombIDE.ScriptingStudio.Objects; +using TombIDE.ScriptingStudio.ViewModels; + +namespace TombIDE.ScriptingStudio.Views; + +public partial class LuaDiagnosticsView : UserControl +{ + private readonly Action? _activateDiagnostic; + + internal LuaDiagnosticsView(LuaDiagnosticsViewModel viewModel, Action? activateDiagnostic) + { + InitializeComponent(); + DataContext = viewModel; + _activateDiagnostic = activateDiagnostic; + } + + private LuaDiagnosticsViewModel ViewModel => (LuaDiagnosticsViewModel)DataContext; + + private void DiagnosticsGrid_MouseDoubleClick(object sender, MouseButtonEventArgs e) + => ActivateSelectedDiagnostic(); + + private void DiagnosticsGrid_PreviewKeyDown(object sender, KeyEventArgs e) + { + if (e.Key != Key.Enter) + return; + + ActivateSelectedDiagnostic(); + e.Handled = true; + } + + private void ActivateSelectedDiagnostic() + { + if (ViewModel.SelectedItem is not LuaDiagnosticListItem selectedDiagnostic) + return; + + _activateDiagnostic?.Invoke(selectedDiagnostic); + } +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/Views/LuaReferencesResultsView.xaml b/TombIDE/TombIDE.ScriptingStudio/Views/LuaReferencesResultsView.xaml new file mode 100644 index 0000000000..ec668bdb59 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/Views/LuaReferencesResultsView.xaml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/Views/LuaReferencesResultsView.xaml.cs b/TombIDE/TombIDE.ScriptingStudio/Views/LuaReferencesResultsView.xaml.cs new file mode 100644 index 0000000000..e40583e784 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/Views/LuaReferencesResultsView.xaml.cs @@ -0,0 +1,41 @@ +#nullable enable + +using System; +using System.Windows.Controls; +using System.Windows.Input; +using TombIDE.ScriptingStudio.Objects; +using TombIDE.ScriptingStudio.ViewModels; + +namespace TombIDE.ScriptingStudio.Views; + +public partial class LuaReferencesResultsView : UserControl +{ + private readonly Action? _activateReference; + + internal LuaReferencesResultsView(LuaReferencesResultsViewModel viewModel, Action? activateReference) + { + InitializeComponent(); + DataContext = viewModel; + _activateReference = activateReference; + } + + private void ReferencesTree_MouseDoubleClick(object sender, MouseButtonEventArgs e) + => ActivateSelectedReference(); + + private void ReferencesTree_PreviewKeyDown(object sender, KeyEventArgs e) + { + if (e.Key != Key.Enter) + return; + + ActivateSelectedReference(); + e.Handled = true; + } + + private void ActivateSelectedReference() + { + if (ReferencesTree.SelectedItem is not LuaReferenceListItem selectedReference) + return; + + _activateReference?.Invoke(selectedReference); + } +} \ No newline at end of file diff --git a/TombIDE/TombIDE.Shared/DefaultLayouts.cs b/TombIDE/TombIDE.Shared/DefaultLayouts.cs index 02252d9196..2d939bd6f2 100644 --- a/TombIDE/TombIDE.Shared/DefaultLayouts.cs +++ b/TombIDE/TombIDE.Shared/DefaultLayouts.cs @@ -122,6 +122,25 @@ public static class DefaultLayouts VisibleContent = "ReferenceBrowser" } } + }, + new DockRegionState + { + Area = DarkDockArea.Bottom, + Size = new Size(320, 220), + Groups = new List + { + new DockGroupState + { + Contents = new List + { + "SearchResults", + "LuaDiagnostics", + "LuaReferencesResults" + }, + + VisibleContent = "LuaDiagnostics" + } + } } } }; diff --git a/TombIDE/TombIDE.Shared/Resources/Localization/EN/TombIDE.xml b/TombIDE/TombIDE.Shared/Resources/Localization/EN/TombIDE.xml index 94ac9707ca..128b699afd 100644 --- a/TombIDE/TombIDE.Shared/Resources/Localization/EN/TombIDE.xml +++ b/TombIDE/TombIDE.Shared/Resources/Localization/EN/TombIDE.xml @@ -59,11 +59,17 @@ Tabs to Spaces Spaces to Tabs - Reindent Code + Reformat Trim Ending Whitespace + Toggle Comment Comment out Selected Lines Uncomment Selected Lines Toggle Bookmark + Navigate Back + Navigate Forward + Go to Definition + Find References + Rename Symbol Go to Previous Bookmark Go to Next Bookmark Clear All Bookmarks... @@ -85,6 +91,8 @@ Reference Browser Compiler Logs Search Results + Lua Diagnostics + Lua References Tool Strip Status Strip @@ -114,10 +122,34 @@ Row: {0} Column: {0} Line: {0} + Line + Column Selected: {0} Zoom: {0}% Reset Zoom + Severity + Message + Errors + Warning + Warnings + Information + Hint + Messages + No diagnostics in the active Lua document. + Select an active Lua document to view diagnostics. + Diagnostics are updating... + No references found. + Select an active Lua document to view references. + The active Lua language server does not support Find References. + Searching for references... + Select an active Lua document to rename a symbol. + The active Lua language server does not support Rename Symbol. + Select a Lua identifier or place the caret on one first. + New name: + No rename changes were returned. + The active Lua language server does not support document formatting. + Decimal Value Hexadecimal Value Macro diff --git a/TombIDE/TombIDE.Shared/Resources/Localization/Localization.cs b/TombIDE/TombIDE.Shared/Resources/Localization/Localization.cs index e62cc48d05..ae4f6d23ac 100644 --- a/TombIDE/TombIDE.Shared/Resources/Localization/Localization.cs +++ b/TombIDE/TombIDE.Shared/Resources/Localization/Localization.cs @@ -50,9 +50,15 @@ public class Localization public string Reindent { get; set; } public string TrimWhitespace { get; set; } + public string ToggleComment { get; set; } public string CommentOut { get; set; } public string Uncomment { get; set; } public string ToggleBookmark { get; set; } + public string NavigateBack { get; set; } + public string NavigateForward { get; set; } + public string GoToDefinition { get; set; } + public string FindReferences { get; set; } + public string RenameSymbol { get; set; } public string PrevBookmark { get; set; } public string NextBookmark { get; set; } public string ClearBookmarks { get; set; } @@ -73,6 +79,8 @@ public class Localization public string ReferenceBrowser { get; set; } public string CompilerLogs { get; set; } public string SearchResults { get; set; } + public string LuaDiagnostics { get; set; } + public string LuaReferencesResults { get; set; } public string ToolStrip { get; set; } public string StatusStrip { get; set; } @@ -102,10 +110,34 @@ public class Localization public string Row { get; set; } public string Column { get; set; } public string Line { get; set; } + public string LineHeader { get; set; } + public string ColumnHeader { get; set; } public string Selected { get; set; } public string Zoom { get; set; } public string ResetZoom { get; set; } + public string Severity { get; set; } + public string Message { get; set; } + public string Errors { get; set; } + public string Warning { get; set; } + public string Warnings { get; set; } + public string Information { get; set; } + public string Hint { get; set; } + public string Messages { get; set; } + public string NoDiagnostics { get; set; } + public string LuaDiagnosticsNoDocument { get; set; } + public string LuaDiagnosticsUpdating { get; set; } + public string NoReferencesFound { get; set; } + public string LuaReferencesNoDocument { get; set; } + public string LuaReferencesUnsupported { get; set; } + public string LuaReferencesLoading { get; set; } + public string LuaRenameNoDocument { get; set; } + public string LuaRenameUnsupported { get; set; } + public string LuaRenameNoSymbol { get; set; } + public string LuaRenamePromptLabel { get; set; } + public string LuaRenameNoChanges { get; set; } + public string LuaReformatUnsupported { get; set; } + public string DecimalValue { get; set; } public string HexadecimalValue { get; set; } public string Macro { get; set; } diff --git a/TombIDE/TombIDE.Shared/Resources/Localization/PL/TombIDE.xml b/TombIDE/TombIDE.Shared/Resources/Localization/PL/TombIDE.xml index 7311a4e7ca..49f04b40a1 100644 --- a/TombIDE/TombIDE.Shared/Resources/Localization/PL/TombIDE.xml +++ b/TombIDE/TombIDE.Shared/Resources/Localization/PL/TombIDE.xml @@ -59,11 +59,17 @@ Tabulatory na spacje Spacje na tabulatory - Uporządkuj białe znaki + Przeformatuj Usuń białe znaki na końcach linii + Przelacz komentarz Wstaw zakomentowanie Usuń zakomentowanie Przełącz zakładkę + Przejdź wstecz + Przejdź dalej + Przejdź do definicji + Znajdź odwołania + Zmień nazwę symbolu Idź do poprzedniej zakładki Idź do następnej zakładki Wyczyść wszystkie zakładki... @@ -85,6 +91,8 @@ Wyszukiwarka referencji Raport kompilatora Wyniki wyszukiwania + Diagnostyka Lua + Referencje Lua Pasek narzędzi Pasek stanu @@ -114,10 +122,34 @@ Wiersz: {0} Kolumna: {0} Linia: {0} + Linia + Kolumna Zaznaczone: {0} Powiększenie: {0}% Resetuj powiększenie + Poziom + Wiadomość + Błędy + Ostrzeżenie + Ostrzeżenia + Informacja + Wskazówka + Komunikaty + Brak diagnostyki dla aktywnego dokumentu Lua. + Wybierz aktywny dokument Lua, aby wyświetlić diagnostykę. + Diagnostyka jest aktualizowana... + Nie znaleziono referencji. + Wybierz aktywny dokument Lua, aby wyswietlic referencje. + Aktywny serwer jezyka Lua nie obsluguje funkcji Znajdz odwolania. + Trwa wyszukiwanie referencji... + Wybierz aktywny dokument Lua, aby zmienic nazwe symbolu. + Aktywny serwer jezyka Lua nie obsluguje funkcji Zmien nazwe symbolu. + Wybierz identyfikator Lua lub ustaw na nim kursor. + Nowa nazwa: + Nie zwrocono zadnych zmian nazwy. + Aktywny serwer jezyka Lua nie obsluguje formatowania dokumentu. + Wartość dziesiętna Wartość szesnastkowa Makro diff --git a/TombIDE/TombIDE.Shared/TIDE/LuaLS.zip b/TombIDE/TombIDE.Shared/TIDE/LuaLS.zip new file mode 100644 index 0000000000..7d8cb05550 Binary files /dev/null and b/TombIDE/TombIDE.Shared/TIDE/LuaLS.zip differ diff --git a/TombIDE/TombIDE.Shared/TombIDE.Shared.csproj b/TombIDE/TombIDE.Shared/TombIDE.Shared.csproj index ba20308e29..d9a9a1ffe7 100644 --- a/TombIDE/TombIDE.Shared/TombIDE.Shared.csproj +++ b/TombIDE/TombIDE.Shared/TombIDE.Shared.csproj @@ -1,257 +1,136 @@  + - net6.0-windows - 12 - Library false true - true - Debug;Release - x64;x86 - - ..\..\Build ($(Platform))\ - - - ..\..\BuildRelease ($(Platform))\ - none - true - - - x64 - - - x86 + + + $(MSBuildProjectDirectory)\TIDE\LuaLS.zip + + + + + + + + GlobalPaths.cs + + PreserveNewest + PreserveNewest - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - + - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - + + PreserveNewest - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest + + + + + + Never - - PreserveNewest + + + Never - \ No newline at end of file + + + + + + $(TargetDir)TIDE\LuaLS + $(IntermediateOutputPath)LuaLS + $(LuaLanguageServerOutputDirectory)\.extracted + + + + + + + + + + <_LuaLanguageServerArchive Include="$(LuaLanguageServerArchivePath)" /> + + + + + + + <_LuaLanguageServerRepresentativeStagingOutputPath Include="@(LuaLanguageServerRepresentativeOutput -> '$(LuaLanguageServerStagingDirectory)\%(Identity)')" /> + <_LuaLanguageServerRepresentativeOutputPath Include="@(LuaLanguageServerRepresentativeOutput -> '$(LuaLanguageServerOutputDirectory)\%(Identity)')" /> + <_LuaLanguageServerStagingFile Include="$(LuaLanguageServerStagingDirectory)\**\*" /> + <_LuaLanguageServerExpectedOutputFile Include="@(_LuaLanguageServerStagingFile -> '$(LuaLanguageServerOutputDirectory)\%(RecursiveDir)%(Filename)%(Extension)')" /> + <_LuaLanguageServerExistingOutputFile Include="$(LuaLanguageServerOutputDirectory)\**\*" /> + <_LuaLanguageServerObsoleteOutputFile Include="@(_LuaLanguageServerExistingOutputFile)" /> + <_LuaLanguageServerObsoleteOutputFile Remove="@(_LuaLanguageServerExpectedOutputFile)" /> + + + + + + + + + + + + + + + + + + + + <_LuaLanguageServerOutputFiles Include="$(LuaLanguageServerOutputDirectory)\**\*" /> + + + + + + diff --git a/TombIDE/TombIDE/Program.cs b/TombIDE/TombIDE/Program.cs index e75a2ae99a..d6475ae911 100644 --- a/TombIDE/TombIDE/Program.cs +++ b/TombIDE/TombIDE/Program.cs @@ -1,4 +1,4 @@ -using CustomMessageBox.WPF; +using Microsoft.Extensions.DependencyInjection; using System; using System.IO; using System.Linq; @@ -6,7 +6,8 @@ using System.Windows.Forms; using TombIDE.Shared; using TombIDE.Shared.SharedClasses; -using WPF = System.Windows; +using TombLib.WPF; +using TombLib.WPF.Services; namespace TombIDE { @@ -18,7 +19,8 @@ internal static class Program [STAThread] private static void Main(string[] args) { - InitializeWPF(); + var services = WPFInitializer.InitializeWPF(); + ServiceLocator.Configure(services.BuildServiceProvider()); Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); @@ -63,33 +65,6 @@ private static void Main(string[] args) Application.Run(form); } - private static void InitializeWPF() - { - // Initialize WPF resources - var wpfApp = new WPF.Application - { - ShutdownMode = WPF.ShutdownMode.OnExplicitShutdown - }; - - // Add the DarkUI theme to the WPF application - wpfApp.Resources.MergedDictionaries.Add(new WPF.ResourceDictionary - { - Source = new Uri("pack://application:,,,/DarkUI.WPF;component/Generic.xaml") - }); - - // Use DarkColors theme (default DarkUI look) - wpfApp.Resources.MergedDictionaries.Add(new WPF.ResourceDictionary - { - Source = new Uri("pack://application:,,,/DarkUI.WPF;component/Dictionaries/DarkColors.xaml") - }); - - CMessageBox.WindowStyleOverride = (WPF.Style)wpfApp.Resources["CustomWindowStyle"]; - CMessageBox.UsePathIconsByDefault = true; - - if (wpfApp.TryFindResource("Brush_Background_Alternative") is WPF.Media.SolidColorBrush brush) - CMessageBox.DefaultButtonsPanelBackground = brush; - } - private static void UpdateNGCompilerPaths() { try diff --git a/TombIDE/TombIDE/TombIDE.csproj b/TombIDE/TombIDE/TombIDE.csproj index 8b5c13a49b..17ed345059 100644 --- a/TombIDE/TombIDE/TombIDE.csproj +++ b/TombIDE/TombIDE/TombIDE.csproj @@ -7,28 +7,10 @@ - net6.0-windows WinExe false true true - true - Debug;Release - x64;x86 - - - ..\..\Build ($(Platform))\ - - - ..\..\BuildRelease ($(Platform))\ - none - true - - - x64 - - - x86 TIDE.ico diff --git a/TombLib/TombLib.Forms/Forms/FormAbout.designer.cs b/TombLib/TombLib.Forms/Forms/FormAbout.designer.cs index cbaa1bdcfa..bf3fdfce2f 100644 --- a/TombLib/TombLib.Forms/Forms/FormAbout.designer.cs +++ b/TombLib/TombLib.Forms/Forms/FormAbout.designer.cs @@ -44,6 +44,7 @@ private void InitializeComponent() linkLabel14 = new System.Windows.Forms.LinkLabel(); linkLabel13 = new System.Windows.Forms.LinkLabel(); linkLabel8 = new System.Windows.Forms.LinkLabel(); + linkLabel17 = new System.Windows.Forms.LinkLabel(); darkLabel13 = new DarkUI.Controls.DarkLabel(); linkLabel7 = new System.Windows.Forms.LinkLabel(); darkLabel12 = new DarkUI.Controls.DarkLabel(); @@ -57,6 +58,7 @@ private void InitializeComponent() darkLabel8 = new DarkUI.Controls.DarkLabel(); linkLabel3 = new System.Windows.Forms.LinkLabel(); darkLabel7 = new DarkUI.Controls.DarkLabel(); + darkLabel23 = new DarkUI.Controls.DarkLabel(); darkLabel16 = new DarkUI.Controls.DarkLabel(); linkLabel9 = new System.Windows.Forms.LinkLabel(); darkLabel14 = new DarkUI.Controls.DarkLabel(); @@ -76,7 +78,7 @@ private void InitializeComponent() tableLayoutPanel1.Controls.Add(butOk, 1, 0); tableLayoutPanel1.Controls.Add(darkLabel1, 0, 0); tableLayoutPanel1.Dock = System.Windows.Forms.DockStyle.Bottom; - tableLayoutPanel1.Location = new System.Drawing.Point(0, 390); + tableLayoutPanel1.Location = new System.Drawing.Point(0, 403); tableLayoutPanel1.Name = "tableLayoutPanel1"; tableLayoutPanel1.RowCount = 1; tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 100F)); @@ -128,6 +130,7 @@ private void InitializeComponent() panel1.Controls.Add(linkLabel14); panel1.Controls.Add(linkLabel13); panel1.Controls.Add(linkLabel8); + panel1.Controls.Add(linkLabel17); panel1.Controls.Add(darkLabel13); panel1.Controls.Add(linkLabel7); panel1.Controls.Add(darkLabel12); @@ -141,6 +144,7 @@ private void InitializeComponent() panel1.Controls.Add(darkLabel8); panel1.Controls.Add(linkLabel3); panel1.Controls.Add(darkLabel7); + panel1.Controls.Add(darkLabel23); panel1.Controls.Add(darkLabel16); panel1.Controls.Add(linkLabel9); panel1.Controls.Add(darkLabel14); @@ -149,7 +153,7 @@ private void InitializeComponent() panel1.Dock = System.Windows.Forms.DockStyle.Top; panel1.Location = new System.Drawing.Point(0, 64); panel1.Name = "panel1"; - panel1.Size = new System.Drawing.Size(614, 326); + panel1.Size = new System.Drawing.Size(614, 339); panel1.TabIndex = 8; // // tableLayoutPanel2 @@ -283,6 +287,21 @@ private void InitializeComponent() linkLabel8.VisitedLinkColor = System.Drawing.Color.FromArgb(184, 163, 233); linkLabel8.Click += btnLink_Click; // + // linkLabel17 + // + linkLabel17.ActiveLinkColor = System.Drawing.Color.FromArgb(184, 163, 233); + linkLabel17.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right; + linkLabel17.AutoEllipsis = true; + linkLabel17.LinkColor = System.Drawing.Color.FromArgb(184, 163, 233); + linkLabel17.Location = new System.Drawing.Point(319, 266); + linkLabel17.Name = "linkLabel17"; + linkLabel17.Size = new System.Drawing.Size(279, 13); + linkLabel17.TabIndex = 54; + linkLabel17.TabStop = true; + linkLabel17.Text = "github.com/microsoft/vscode-codicons"; + linkLabel17.VisitedLinkColor = System.Drawing.Color.FromArgb(184, 163, 233); + linkLabel17.Click += btnLink_Click; + // // darkLabel13 // darkLabel13.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left; @@ -450,13 +469,24 @@ private void InitializeComponent() darkLabel7.TabIndex = 34; darkLabel7.Text = "NCalc is used under MIT license."; // + // darkLabel23 + // + darkLabel23.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left; + darkLabel23.Font = new System.Drawing.Font("Segoe UI", 8.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point); + darkLabel23.ForeColor = System.Drawing.Color.FromArgb(220, 220, 220); + darkLabel23.Location = new System.Drawing.Point(7, 266); + darkLabel23.Name = "darkLabel23"; + darkLabel23.Size = new System.Drawing.Size(267, 13); + darkLabel23.TabIndex = 55; + darkLabel23.Text = "Codicons are used under MIT license."; + // // darkLabel16 // darkLabel16.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left; darkLabel16.AutoSize = true; darkLabel16.Font = new System.Drawing.Font("Segoe UI", 6.75F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point); darkLabel16.ForeColor = System.Drawing.Color.FromArgb(220, 220, 220); - darkLabel16.Location = new System.Drawing.Point(7, 290); + darkLabel16.Location = new System.Drawing.Point(7, 303); darkLabel16.Name = "darkLabel16"; darkLabel16.Size = new System.Drawing.Size(377, 24); darkLabel16.TabIndex = 28; @@ -468,7 +498,7 @@ private void InitializeComponent() linkLabel9.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left; linkLabel9.AutoSize = true; linkLabel9.LinkColor = System.Drawing.Color.FromArgb(184, 163, 233); - linkLabel9.Location = new System.Drawing.Point(364, 274); + linkLabel9.Location = new System.Drawing.Point(364, 287); linkLabel9.Name = "linkLabel9"; linkLabel9.Size = new System.Drawing.Size(64, 13); linkLabel9.TabIndex = 21; @@ -483,7 +513,7 @@ private void InitializeComponent() darkLabel14.AutoSize = true; darkLabel14.Font = new System.Drawing.Font("Segoe UI", 8.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point); darkLabel14.ForeColor = System.Drawing.Color.FromArgb(220, 220, 220); - darkLabel14.Location = new System.Drawing.Point(6, 274); + darkLabel14.Location = new System.Drawing.Point(6, 287); darkLabel14.Name = "darkLabel14"; darkLabel14.Size = new System.Drawing.Size(360, 13); darkLabel14.TabIndex = 20; @@ -517,7 +547,7 @@ private void InitializeComponent() AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; CancelButton = butOk; - ClientSize = new System.Drawing.Size(614, 422); + ClientSize = new System.Drawing.Size(614, 435); Controls.Add(panel1); Controls.Add(pictureBox); Controls.Add(tableLayoutPanel1); @@ -574,5 +604,7 @@ private void InitializeComponent() private System.Windows.Forms.LinkLabel linkLabel14; private DarkUI.Controls.DarkLabel darkLabel22; private System.Windows.Forms.LinkLabel linkLabel16; + private System.Windows.Forms.LinkLabel linkLabel17; + private DarkUI.Controls.DarkLabel darkLabel23; } } diff --git a/TombLib/TombLib.Forms/TombLib.Forms.csproj b/TombLib/TombLib.Forms/TombLib.Forms.csproj index 763fe52055..bdeddac36f 100644 --- a/TombLib/TombLib.Forms/TombLib.Forms.csproj +++ b/TombLib/TombLib.Forms/TombLib.Forms.csproj @@ -7,30 +7,10 @@ - net6.0-windows - 12 - Library TombLib false true true - true - Debug;Release - x64;x86 - - - ..\..\Build ($(Platform))\ - - - ..\..\BuildRelease ($(Platform))\ - none - true - - - x64 - - - x86 diff --git a/TombLib/TombLib.Rendering/TombLib.Rendering.csproj b/TombLib/TombLib.Rendering/TombLib.Rendering.csproj index 1e19851327..9873a30f2e 100644 --- a/TombLib/TombLib.Rendering/TombLib.Rendering.csproj +++ b/TombLib/TombLib.Rendering/TombLib.Rendering.csproj @@ -1,28 +1,9 @@  - net6.0-windows - Library TombLib false true - true - Debug;Release true - x64;x86 - - - ..\..\Build ($(Platform))\ - - - ..\..\BuildRelease ($(Platform))\ - none - true - - - x64 - - - x86 diff --git a/TombLib/TombLib.Scripting.ClassicScript/ClassicScriptEditor.cs b/TombLib/TombLib.Scripting.ClassicScript/ClassicScriptEditor.cs index 41fba200b5..11bfaffdcb 100644 --- a/TombLib/TombLib.Scripting.ClassicScript/ClassicScriptEditor.cs +++ b/TombLib/TombLib.Scripting.ClassicScript/ClassicScriptEditor.cs @@ -1,3 +1,5 @@ +#nullable enable + using ICSharpCode.AvalonEdit.CodeCompletion; using ICSharpCode.AvalonEdit.Document; using ICSharpCode.AvalonEdit.Rendering; @@ -5,6 +7,7 @@ using System.Collections.Generic; using System.ComponentModel; using System.Data; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Text.RegularExpressions; @@ -20,6 +23,7 @@ using TombLib.Scripting.ClassicScript.Utils; using TombLib.Scripting.Helpers; using TombLib.Scripting.Objects; +using TombLib.Scripting.Utils; using TombLib.Scripting.Workers; namespace TombLib.Scripting.ClassicScript @@ -66,6 +70,8 @@ public bool ShowSectionSeparators private IBackgroundRenderer _sectionRenderer; + private readonly record struct AutocompleteRequest(string Text, int CaretOffset, int ArgumentIndex); + #endregion Fields #region Construction @@ -80,6 +86,7 @@ public ClassicScriptEditor(Version engineVersion) : base(engineVersion) CommentPrefix = ";"; } + [MemberNotNull(nameof(_autocompleteWorker), nameof(_errorDetectionWorker))] private void InitializeBackgroundWorkers() { _autocompleteWorker = new BackgroundWorker(); @@ -90,6 +97,7 @@ private void InitializeBackgroundWorkers() _errorDetectionWorker.RunWorkerCompleted += ErrorWorker_RunWorkerCompleted; } + [MemberNotNull(nameof(_sectionRenderer))] private void InitializeRenderers() { _sectionRenderer = new SectionRenderer(this); @@ -112,33 +120,25 @@ private void BindEventMethods() #region Events - private void TextArea_TextEntering(object sender, TextCompositionEventArgs e) + private void TextArea_TextEntering(object? sender, TextCompositionEventArgs e) { - if (AutocompleteEnabled && !SuppressAutocomplete) - { - if (e.Text == " " && Keyboard.Modifiers.HasFlag(ModifierKeys.Control)) - { - if (_completionWindow == null) - HandleAutocompleteAfterSpaceCtrl(); - - e.Handled = true; - } - } + if (!SuppressAutocomplete) + TryHandleCtrlSpaceCompletion(e, HandleAutocompleteAfterSpaceCtrl); } - private void TextEditor_TextEntered(object sender, TextCompositionEventArgs e) + private void TextEditor_TextEntered(object? sender, TextCompositionEventArgs e) { if (AutocompleteEnabled && !SuppressAutocomplete) HandleAutocomplete(e); } - private void TextEditor_TextChanged(object sender, EventArgs e) + private void TextEditor_TextChanged(object? sender, EventArgs e) { if (LiveErrorUnderlining) _errorDetectionWorker.RunErrorCheckOnIdle(Text); } - private void TextEditor_KeyDown(object sender, KeyEventArgs e) + private void TextEditor_KeyDown(object? sender, KeyEventArgs e) { if (e.Key == Key.F12) { @@ -167,7 +167,7 @@ private void TextEditor_KeyDown(object sender, KeyEventArgs e) } } - private void TextEditor_MouseHover(object sender, MouseEventArgs e) + private void TextEditor_MouseHover(object? sender, MouseEventArgs e) => HandleDefinitionToolTips(e); #endregion Events @@ -180,7 +180,7 @@ private void HandleAutocomplete(TextCompositionEventArgs e) { if (_completionWindow == null) // Prevents window duplicates { - if (Document.GetLineByOffset(CaretOffset).Length == 1) + if (EditorCompletionTriggerHelper.IsSingleCharacterLine(Document.GetText(Document.GetLineByOffset(CaretOffset)))) HandleAutocompleteOnEmptyLine(); else if (e.Text != "_" && CaretOffset > 1) HandleAutocompleteAfterSpace(); @@ -201,14 +201,7 @@ private void HandleAutocompleteAfterSpaceCtrl() HandleAutocompleteOnEmptyLine(); else if (!_autocompleteWorker.IsBusy) { - var data = new List - { - Text, - CaretOffset, - -1 - }; - - _autocompleteWorker.RunWorkerAsync(data); + _autocompleteWorker.RunWorkerAsync(new AutocompleteRequest(Text, CaretOffset, -1)); } } @@ -230,14 +223,7 @@ private void HandleAutocompleteAfterSpace() || Document.GetCharAt(CaretOffset - 2) == '/') && !_autocompleteWorker.IsBusy) { - var data = new List - { - Text, - CaretOffset, - -1 - }; - - _autocompleteWorker.RunWorkerAsync(data); + _autocompleteWorker.RunWorkerAsync(new AutocompleteRequest(Text, CaretOffset, -1)); } else TryHandleIncludeAutocomplete(); @@ -250,10 +236,11 @@ private void TryHandleIncludeAutocomplete() if (Regex.IsMatch(lineText, Patterns.IncludeCommand, RegexOptions.IgnoreCase)) { - InitializeCompletionWindow(); + int? startOffset = null; + int? endOffset = null; if (Document.GetCharAt(CaretOffset - 1) == '\"') - _completionWindow.StartOffset = CaretOffset - 1; + startOffset = CaretOffset - 1; else if (Document.GetCharAt(CaretOffset - 1) != ' ') { int wordStartOffset = @@ -263,29 +250,33 @@ private void TryHandleIncludeAutocomplete() if (!word.StartsWith("#")) { - _completionWindow.StartOffset = wordStartOffset; + startOffset = wordStartOffset; if (wordStartOffset - 1 > 0 && Document.GetCharAt(wordStartOffset - 1) == '\"') - _completionWindow.StartOffset--; + startOffset--; } } if (CaretOffset < Document.TextLength && Document.GetCharAt(CaretOffset) == '\"') - _completionWindow.EndOffset = CaretOffset + 1; + endOffset = CaretOffset + 1; + + string? directoryPath = Path.GetDirectoryName(FilePath); + + if (string.IsNullOrWhiteSpace(directoryPath)) + return; - string directoryPath = Path.GetDirectoryName(FilePath); var fileDirectory = new DirectoryInfo(directoryPath); + var completionItems = new List(); foreach (FileInfo file in fileDirectory.GetFiles("*.txt", SearchOption.AllDirectories).Where(x => !x.FullName.Equals(FilePath))) { string pathPart = file.FullName.Replace(directoryPath, string.Empty).TrimStart('\\'); string completionDataString = $"\"{pathPart}\""; - _completionWindow.CompletionList.CompletionData.Add(new CompletionData(completionDataString)); + completionItems.Add(new CompletionData(completionDataString)); } - if (_completionWindow.CompletionList.CompletionData.Count > 0) - ShowCompletionWindow(); + TryOpenCompletionWindow(completionItems, startOffset, endOffset); } } @@ -299,14 +290,13 @@ private void HandleAutocompleteOnWordWithoutContext() if (!MnemonicData.AllConstantFlags.Any(x => x.StartsWith(word, StringComparison.OrdinalIgnoreCase))) return; - InitializeCompletionWindow(); - _completionWindow.StartOffset = wordStartOffset; + var completionItems = new List(); foreach (string mnemonicConstant in MnemonicData.AllConstantFlags) if (mnemonicConstant.StartsWith(word, StringComparison.OrdinalIgnoreCase)) - _completionWindow.CompletionList.CompletionData.Add(new CompletionData(mnemonicConstant)); + completionItems.Add(new CompletionData(mnemonicConstant)); - ShowCompletionWindow(); + TryOpenCompletionWindow(completionItems, wordStartOffset); } private void HandleAutocompleteOnEmptyLine() @@ -317,27 +307,23 @@ private void HandleAutocompleteOnEmptyLine() "Strings", "PSXStrings", "PCStrings", "ExtraNG")) return; - InitializeCompletionWindow(); - _completionWindow.StartOffset = Document.GetLineByOffset(CaretOffset).Offset; - - foreach (ICompletionData item in Autocomplete.GetNewLineAutocompleteList()) - _completionWindow.CompletionList.CompletionData.Add(item); - - ShowCompletionWindow(); + TryOpenCompletionWindow(Autocomplete.GetNewLineAutocompleteList(), Document.GetLineByOffset(CaretOffset).Offset); } - private void AutocompleteWorker_DoWork(object sender, DoWorkEventArgs e) + private void AutocompleteWorker_DoWork(object? sender, DoWorkEventArgs e) { - var data = e.Argument as List; + if (e.Argument is not AutocompleteRequest request) + { + e.Result = new List(); + return; + } - var document = new TextDocument(data[0].ToString()); - int caretOffset = (int)data[1]; - int argumentIndex = (int)data[2]; + var document = new TextDocument(request.Text); - e.Result = Autocomplete.GetCompletionData(document, caretOffset, argumentIndex); + e.Result = Autocomplete.GetCompletionData(document, request.CaretOffset, request.ArgumentIndex); } - private void AutocompleteWorker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e) + private void AutocompleteWorker_RunWorkerCompleted(object? sender, RunWorkerCompletedEventArgs e) { var completionData = e.Result as List; @@ -347,21 +333,17 @@ private void AutocompleteWorker_RunWorkerCompleted(object sender, RunWorkerCompl if (completionData.Count == 0) return; - InitializeCompletionWindow(); - int wordStartOffset = TextUtilities.GetNextCaretPosition(Document, CaretOffset, LogicalDirection.Backward, CaretPositioningMode.WordStart); string word = Document.GetText(wordStartOffset, CaretOffset - wordStartOffset); + int? startOffset = null; if (!word.StartsWith("=") && !word.StartsWith(",") && !word.StartsWith("+") && !word.StartsWith("-") && !word.StartsWith("*") && !word.StartsWith("/")) - _completionWindow.StartOffset = wordStartOffset; - - foreach (ICompletionData item in completionData) - _completionWindow.CompletionList.CompletionData.Add(item); + startOffset = wordStartOffset; - ShowCompletionWindow(); + TryOpenCompletionWindow(completionData, startOffset); } #endregion Autocomplete @@ -374,15 +356,12 @@ public void CheckForErrors() _errorDetectionWorker.CheckForErrorsAsync(Text); } - private void ErrorWorker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e) + private void ErrorWorker_RunWorkerCompleted(object? sender, RunWorkerCompletedEventArgs e) { - if (e.Result == null) + if (e.Result is not IReadOnlyList diagnostics) return; - ResetAllErrors(); - ApplyErrorsToLines(e.Result as List); - - TextArea.TextView.InvalidateLayer(KnownLayer.Caret); + SetDiagnostics(diagnostics); } #endregion Error handling @@ -417,7 +396,8 @@ public void InputFreeIndex() public override void UpdateSettings(Bases.ConfigurationBase configuration) { - var config = configuration as ClassicScriptEditorConfiguration; + if (configuration is not ClassicScriptEditorConfiguration config) + return; SyntaxHighlighting = new SyntaxHighlighting(config.ColorScheme); @@ -444,10 +424,10 @@ private void HandleDefinitionToolTips(MouseEventArgs e) DocumentLine hoveredLine = Document.GetLineByOffset(hoveredOffset); - if (hoveredLine.HasError) + if (HasDiagnosticsOnLine(hoveredLine)) return; - string hoveredWord = WordParser.GetWordFromOffset(Document, hoveredOffset); + string? hoveredWord = WordParser.GetWordFromOffset(Document, hoveredOffset); WordType type = WordParser.GetWordTypeFromOffset(Document, hoveredOffset); if (type == WordType.MnemonicConstant && !MnemonicData.AllConstantFlags.Any(x => x.Equals(hoveredWord, StringComparison.OrdinalIgnoreCase))) @@ -462,11 +442,14 @@ private void HandleDefinitionToolTips(MouseEventArgs e) hoveredWord = hoveredWord.Split(' ')[0]; } + if (string.IsNullOrEmpty(hoveredWord)) + return; + if (type != WordType.Unknown) { if (type == WordType.MnemonicConstant || type == WordType.Hexadecimal || type == WordType.Decimal) { - string currentFlagPrefix = ArgumentParser.GetFlagPrefixOfCurrentArgument(Document, hoveredOffset); + string? currentFlagPrefix = ArgumentParser.GetFlagPrefixOfCurrentArgument(Document, hoveredOffset); if (currentFlagPrefix == null) { @@ -478,25 +461,25 @@ private void HandleDefinitionToolTips(MouseEventArgs e) else { DataTable dataTable = MnemonicData.MnemonicConstantsDataTable; - DataRow row = null; + DataRow? row = null; switch (type) { case WordType.MnemonicConstant: row = dataTable.Rows.Cast().FirstOrDefault(r - => r[2].ToString().Equals(hoveredWord, StringComparison.OrdinalIgnoreCase)); + => string.Equals(r[2]?.ToString(), hoveredWord, StringComparison.OrdinalIgnoreCase)); break; case WordType.Hexadecimal: row = dataTable.Rows.Cast().FirstOrDefault(r - => r[1].ToString().Equals(hoveredWord, StringComparison.OrdinalIgnoreCase) - && r[2].ToString().StartsWith(currentFlagPrefix, StringComparison.OrdinalIgnoreCase)); + => string.Equals(r[1]?.ToString(), hoveredWord, StringComparison.OrdinalIgnoreCase) + && (r[2]?.ToString()?.StartsWith(currentFlagPrefix, StringComparison.OrdinalIgnoreCase) ?? false)); break; case WordType.Decimal: row = dataTable.Rows.Cast().FirstOrDefault(r - => r[0].ToString().Equals(hoveredWord, StringComparison.OrdinalIgnoreCase) - && r[2].ToString().StartsWith(currentFlagPrefix, StringComparison.OrdinalIgnoreCase)); + => string.Equals(r[0]?.ToString(), hoveredWord, StringComparison.OrdinalIgnoreCase) + && (r[2]?.ToString()?.StartsWith(currentFlagPrefix, StringComparison.OrdinalIgnoreCase) ?? false)); break; } @@ -518,22 +501,22 @@ private void HandleDefinitionToolTips(MouseEventArgs e) } } - private WordDefinitionEventArgs HoveredWordArgs = null; + private WordDefinitionEventArgs? HoveredWordArgs; public delegate void WordDefinitionRequestedEventHandler(object sender, WordDefinitionEventArgs e); - public event WordDefinitionRequestedEventHandler WordDefinitionRequested; + public event WordDefinitionRequestedEventHandler? WordDefinitionRequested; public void OnWordDefinitionRequested(WordDefinitionEventArgs e) => WordDefinitionRequested?.Invoke(this, e); [Obsolete("This method shouldn't be used for ClassicScript.\nUse WordParser.GetWordFromOffset() instead.")] public new void GetWordFromOffset(int offset) => base.GetWordFromOffset(offset); - public override void GoToObject(string objectName, object identifyingObject = null) + public override void GoToObject(string objectName, object? identifyingObject = null) { if (identifyingObject is ObjectType type) { - DocumentLine objectLine = DocumentParser.FindDocumentLineOfObject(Document, objectName, type); + DocumentLine? objectLine = DocumentParser.FindDocumentLineOfObject(Document, objectName, type); if (objectLine != null) { diff --git a/TombLib/TombLib.Scripting.ClassicScript/TombLib.Scripting.ClassicScript.csproj b/TombLib/TombLib.Scripting.ClassicScript/TombLib.Scripting.ClassicScript.csproj index 02113ddaa1..268c4df7e0 100644 --- a/TombLib/TombLib.Scripting.ClassicScript/TombLib.Scripting.ClassicScript.csproj +++ b/TombLib/TombLib.Scripting.ClassicScript/TombLib.Scripting.ClassicScript.csproj @@ -1,27 +1,8 @@  - net6.0-windows - Library false true true - true - Debug;Release - x64;x86 - - - ..\..\Build ($(Platform))\ - - - ..\..\BuildRelease ($(Platform))\ - none - true - - - x64 - - - x86 @@ -31,10 +12,7 @@ - - False - ..\..\Libs\ICSharpCode.AvalonEdit.dll - + diff --git a/TombLib/TombLib.Scripting.ClassicScript/Utils/ErrorDetector.cs b/TombLib/TombLib.Scripting.ClassicScript/Utils/ErrorDetector.cs index 23615135d3..a819e67358 100644 --- a/TombLib/TombLib.Scripting.ClassicScript/Utils/ErrorDetector.cs +++ b/TombLib/TombLib.Scripting.ClassicScript/Utils/ErrorDetector.cs @@ -7,6 +7,7 @@ using TombLib.Scripting.ClassicScript.Parsers; using TombLib.Scripting.ClassicScript.Resources; using TombLib.Scripting.Interfaces; +using TombLib.Scripting.Objects; namespace TombLib.Scripting.ClassicScript.Utils { @@ -14,16 +15,16 @@ public class ErrorDetector : IErrorDetector { #region Public methods - public object FindErrors(string editorContent, Version engineVersion) + public IReadOnlyList FindErrors(string editorContent, Version engineVersion) => DetectErrorLines(new TextDocument(editorContent)); #endregion Public methods #region Error line finding - private static List DetectErrorLines(TextDocument document) + private static List DetectErrorLines(TextDocument document) { - var errorLines = new List(); + var errorLines = new List(); bool commandSectionCheckRequired = DocumentParser.DocumentContainsSections(document); @@ -34,7 +35,7 @@ private static List DetectErrorLines(TextDocument document) if (LineParser.IsEmptyOrComments(processedLineText)) continue; - ErrorLine error = FindErrorsInLine(document, processedLine, processedLineText, commandSectionCheckRequired); + TextEditorDiagnostic error = FindErrorsInLine(document, processedLine, processedLineText, commandSectionCheckRequired); if (error != null) errorLines.Add(error); @@ -43,40 +44,41 @@ private static List DetectErrorLines(TextDocument document) return errorLines; } - private static ErrorLine FindErrorsInLine(TextDocument document, DocumentLine line, string lineText, bool commandSectionCheckRequired) + private static TextEditorDiagnostic FindErrorsInLine(TextDocument document, DocumentLine line, string lineText, bool commandSectionCheckRequired) { if (LineParser.IsSectionHeaderLine(lineText)) - return FindErrorsInSectionHeaderLine(line, lineText); + return FindErrorsInSectionHeaderLine(document, line, lineText); else { if (commandSectionCheckRequired && LineParser.IsLineInStandardStringSection(document, line)) return null; else if (commandSectionCheckRequired && LineParser.IsLineInExtraNGSection(document, line)) - return FindErrorsInNGStringLine(line, lineText); + return FindErrorsInNGStringLine(document, line, lineText); else return FindErrorsInCommandLine(document, line, lineText, commandSectionCheckRequired); } } - private static ErrorLine FindErrorsInSectionHeaderLine(DocumentLine line, string lineText) + private static TextEditorDiagnostic FindErrorsInSectionHeaderLine(TextDocument document, DocumentLine line, string lineText) { if (!IsValidSectionName(lineText)) - return new ErrorLine("Invalid section name. Please check its spelling.", - line.LineNumber, LineParser.RemoveComments(lineText)); + return CreateDiagnostic(document, line, + "Invalid section name. Please check its spelling.", LineParser.RemoveComments(lineText)); return null; } - private static ErrorLine FindErrorsInNGStringLine(DocumentLine line, string lineText) + private static TextEditorDiagnostic FindErrorsInNGStringLine(TextDocument document, DocumentLine line, string lineText) { if (!IsNGStringLineWellFormatted(lineText)) - return new ErrorLine("NG string must start with an index.\n\nExample:\n0: First String\n1: Second String", - line.LineNumber, LineParser.RemoveComments(lineText)); + return CreateDiagnostic(document, line, + "NG string must start with an index.\n\nExample:\n0: First String\n1: Second String", + LineParser.RemoveComments(lineText)); return null; } - private static ErrorLine FindErrorsInCommandLine(TextDocument document, DocumentLine line, string lineText, bool commandSectionCheckRequired) + private static TextEditorDiagnostic FindErrorsInCommandLine(TextDocument document, DocumentLine line, string lineText, bool commandSectionCheckRequired) { string commandKey = CommandParser.GetCommandKey(document, line.Offset); @@ -90,13 +92,14 @@ private static ErrorLine FindErrorsInCommandLine(TextDocument document, Document if (commandKey == null) errorSegmentText = lineText.TrimEnd(); - return new ErrorLine("Invalid command. Please check its spelling.", - line.LineNumber, errorSegmentText); + return CreateDiagnostic(document, line, + "Invalid command. Please check its spelling.", errorSegmentText); } if (commandSectionCheckRequired && !IsCommandLineInCorrectSection(document, line.LineNumber, commandKey)) - return new ErrorLine("Command is placed in the wrong section. Please check the command syntax.", - line.LineNumber, LineParser.RemoveComments(lineText)); + return CreateDiagnostic(document, line, + "Command is placed in the wrong section. Please check the command syntax.", + LineParser.RemoveComments(lineText)); if (ContainsBrokenNextLines(document, line.Offset)) { @@ -105,8 +108,9 @@ private static ErrorLine FindErrorsInCommandLine(TextDocument document, Document if (errorSegmentText.Length == 0) errorSegmentText = LineParser.RemoveComments(lineText); - return new ErrorLine("Misplaced \">\" symbols were found.\nYou can only use these symbols at the end of the line and there can only be one on each line.", - line.LineNumber, errorSegmentText); + return CreateDiagnostic(document, line, + "Misplaced \">\" symbols were found.\nYou can only use these symbols at the end of the line and there can only be one on each line.", + errorSegmentText); } if (!IsArgumentCountValid(document, line.Offset)) @@ -116,8 +120,8 @@ private static ErrorLine FindErrorsInCommandLine(TextDocument document, Document if (errorSegmentText.Length == 0) errorSegmentText = LineParser.RemoveComments(lineText); - return new ErrorLine("Invalid argument count. Please check the command syntax.", - line.LineNumber, errorSegmentText); + return CreateDiagnostic(document, line, + "Invalid argument count. Please check the command syntax.", errorSegmentText); } if (ContainsEmptyArguments(document, line.Offset)) @@ -127,13 +131,36 @@ private static ErrorLine FindErrorsInCommandLine(TextDocument document, Document if (errorSegmentText.Length == 0) errorSegmentText = LineParser.RemoveComments(lineText); - return new ErrorLine("Empty arguments were found.", - line.LineNumber, errorSegmentText); + return CreateDiagnostic(document, line, "Empty arguments were found.", errorSegmentText); } return null; } + private static TextEditorDiagnostic CreateDiagnostic(TextDocument document, DocumentLine line, string message, string errorSegmentText) + { + string lineText = document.GetText(line); + string segmentText = string.IsNullOrWhiteSpace(errorSegmentText) + ? lineText.Trim() + : errorSegmentText; + + int startOffset = line.Offset; + int endOffset = Math.Max(line.Offset + 1, line.EndOffset); + + if (!string.IsNullOrWhiteSpace(segmentText)) + { + int matchIndex = lineText.IndexOf(segmentText, StringComparison.Ordinal); + + if (matchIndex >= 0) + { + startOffset = line.Offset + matchIndex; + endOffset = startOffset + segmentText.Length; + } + } + + return new TextEditorDiagnostic(TextEditorDiagnosticSeverity.Error, message, startOffset, endOffset); + } + #endregion Error line finding #region Error detection methods diff --git a/TombLib/TombLib.Scripting.GameFlowScript/GameFlowEditor.cs b/TombLib/TombLib.Scripting.GameFlowScript/GameFlowEditor.cs index dbe648cb48..f4a6b61689 100644 --- a/TombLib/TombLib.Scripting.GameFlowScript/GameFlowEditor.cs +++ b/TombLib/TombLib.Scripting.GameFlowScript/GameFlowEditor.cs @@ -1,4 +1,5 @@ -using ICSharpCode.AvalonEdit.CodeCompletion; +#nullable enable + using ICSharpCode.AvalonEdit.Document; using System; using System.Windows; @@ -11,6 +12,7 @@ using TombLib.Scripting.GameFlowScript.Parsers; using TombLib.Scripting.GameFlowScript.Utils; using TombLib.Scripting.Objects; +using TombLib.Scripting.Utils; namespace TombLib.Scripting.GameFlowScript { @@ -33,28 +35,7 @@ private void BindEventMethods() private void TextArea_TextEntering(object sender, TextCompositionEventArgs e) { - if (AutocompleteEnabled && e.Text == " " && Keyboard.Modifiers.HasFlag(ModifierKeys.Control)) - { - if (_completionWindow == null) - { - InitializeCompletionWindow(); - - int wordStartOffset = - TextUtilities.GetNextCaretPosition(Document, CaretOffset, LogicalDirection.Backward, CaretPositioningMode.WordStartOrSymbol); - - string word = Document.GetText(wordStartOffset, CaretOffset - wordStartOffset); - - if (!word.StartsWith(":")) - _completionWindow.StartOffset = wordStartOffset; - - foreach (ICompletionData item in Autocomplete.GetAutocompleteData()) - _completionWindow.CompletionList.CompletionData.Add(item); - - ShowCompletionWindow(); - } - - e.Handled = true; - } + TryHandleCtrlSpaceCompletion(e, TryShowAutocompleteWindow); } private void TextEditor_TextEntered(object sender, TextCompositionEventArgs e) @@ -67,23 +48,19 @@ private void HandleAutocomplete() { string currentLineText = LineParser.EscapeComments(Document.GetText(Document.GetLineByOffset(CaretOffset))).Trim(); - if (currentLineText.Length == 1) - { - InitializeCompletionWindow(); - - int wordStartOffset = - TextUtilities.GetNextCaretPosition(Document, CaretOffset, LogicalDirection.Backward, CaretPositioningMode.WordStartOrSymbol); - - string word = Document.GetText(wordStartOffset, CaretOffset - wordStartOffset); + if (EditorCompletionTriggerHelper.IsSingleCharacterLine(currentLineText)) + TryShowAutocompleteWindow(); + } - if (!word.StartsWith(":")) - _completionWindow.StartOffset = wordStartOffset; + private void TryShowAutocompleteWindow() + { + int wordStartOffset = + TextUtilities.GetNextCaretPosition(Document, CaretOffset, LogicalDirection.Backward, CaretPositioningMode.WordStartOrSymbol); - foreach (ICompletionData item in Autocomplete.GetAutocompleteData()) - _completionWindow.CompletionList.CompletionData.Add(item); + string word = Document.GetText(wordStartOffset, CaretOffset - wordStartOffset); + int? startOffset = word.StartsWith(":") ? null : wordStartOffset; - ShowCompletionWindow(); - } + TryOpenCompletionWindow(Autocomplete.GetAutocompleteData(), startOffset); } public override void TidyCode(bool trimOnly = false) @@ -100,7 +77,8 @@ public override void TidyCode(bool trimOnly = false) public override void UpdateSettings(Bases.ConfigurationBase configuration) { - var config = configuration as GameFlowEditorConfiguration; + if (configuration is not GameFlowEditorConfiguration config) + return; SyntaxHighlighting = new SyntaxHighlighting(config.ColorScheme); @@ -110,11 +88,11 @@ public override void UpdateSettings(Bases.ConfigurationBase configuration) base.UpdateSettings(configuration); } - public override void GoToObject(string objectName, object identifyingObject = null) + public override void GoToObject(string objectName, object? identifyingObject = null) { if (identifyingObject is ObjectType type) { - DocumentLine objectLine = DocumentParser.FindDocumentLineOfObject(Document, objectName, type); + DocumentLine? objectLine = DocumentParser.FindDocumentLineOfObject(Document, objectName, type); if (objectLine != null) { diff --git a/TombLib/TombLib.Scripting.GameFlowScript/TombLib.Scripting.GameFlowScript.csproj b/TombLib/TombLib.Scripting.GameFlowScript/TombLib.Scripting.GameFlowScript.csproj index 1c1e9202f5..bb0c052139 100644 --- a/TombLib/TombLib.Scripting.GameFlowScript/TombLib.Scripting.GameFlowScript.csproj +++ b/TombLib/TombLib.Scripting.GameFlowScript/TombLib.Scripting.GameFlowScript.csproj @@ -1,31 +1,10 @@  - net6.0-windows - Library false true - true - Debug;Release - x64;x86 - - - ..\..\Build ($(Platform))\ - - - ..\..\BuildRelease ($(Platform))\ - none - true - - - x64 - - - x86 - - ..\..\Libs\ICSharpCode.AvalonEdit.dll - + diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Completion/LuaCompletionPayloads.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Completion/LuaCompletionPayloads.cs new file mode 100644 index 0000000000..87c0d49682 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Completion/LuaCompletionPayloads.cs @@ -0,0 +1,106 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace TombLib.Scripting.Lua.LanguageServer; + +/// +/// Represents a completion response that may arrive either as an item array or as an LSP completion list. +/// +[JsonConverter(typeof(LuaCompletionResponseJsonConverter))] +public sealed record LuaCompletionResponse(LuaCompletionItemPayload[]? Items); + +/// +/// Represents a single typed completion item returned by LuaLS. +/// +public sealed record LuaCompletionItemPayload +{ + [JsonPropertyName("label")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Label { get; init; } + + [JsonPropertyName("kind")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? Kind { get; init; } + + [JsonPropertyName("detail")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Detail { get; init; } + + [JsonPropertyName("documentation")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Documentation { get; init; } + + [JsonPropertyName("insertText")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? InsertText { get; init; } + + [JsonPropertyName("insertTextFormat")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? InsertTextFormat { get; init; } + + [JsonPropertyName("filterText")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? FilterText { get; init; } + + [JsonPropertyName("preselect")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Preselect { get; init; } + + [JsonPropertyName("textEdit")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public LuaCompletionTextEditPayload? TextEdit { get; init; } +} + +/// +/// Represents the supported completion text-edit shapes returned by LuaLS. +/// +public sealed record LuaCompletionTextEditPayload +{ + [JsonPropertyName("newText")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? NewText { get; init; } + + [JsonPropertyName("range")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public LuaProtocolRangePayload? Range { get; init; } + + [JsonPropertyName("insert")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public LuaProtocolRangePayload? Insert { get; init; } + + [JsonPropertyName("replace")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public LuaProtocolRangePayload? Replace { get; init; } +} + +/// +/// Deserializes completion responses from either LSP array or completion-list form. +/// +public sealed class LuaCompletionResponseJsonConverter : JsonConverter +{ + public override LuaCompletionResponse? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + return new LuaCompletionResponse(null); + + using JsonDocument document = JsonDocument.ParseValue(ref reader); + JsonElement root = document.RootElement; + LuaCompletionItemPayload[]? items = null; + + if (root.ValueKind == JsonValueKind.Array) + { + items = JsonSerializer.Deserialize(root.GetRawText(), options); + } + else if (root.ValueKind == JsonValueKind.Object + && root.TryGetProperty("items", out JsonElement itemsElement) + && itemsElement.ValueKind == JsonValueKind.Array) + { + items = JsonSerializer.Deserialize(itemsElement.GetRawText(), options); + } + + return new LuaCompletionResponse(items); + } + + public override void Write(Utf8JsonWriter writer, LuaCompletionResponse value, JsonSerializerOptions options) + => throw new NotSupportedException(); +} diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Completion/LuaLanguageServerResponseParser.Completion.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Completion/LuaLanguageServerResponseParser.Completion.cs new file mode 100644 index 0000000000..1c404627db --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Completion/LuaLanguageServerResponseParser.Completion.cs @@ -0,0 +1,447 @@ +using System.Text; +using System.Text.Json; +using TombLib.Scripting.Lua.Objects; + +namespace TombLib.Scripting.Lua.LanguageServer; + +public static partial class LuaLanguageServerResponseParser +{ + // Lua identifiers are case-sensitive, so `Player` and `player` must be reported as distinct + // completion items. Use ordinal (case-sensitive) comparison everywhere completion identity + // is computed; using OrdinalIgnoreCase here would silently hide legitimate symbols. + private sealed class CompletionIdentityComparer : IEqualityComparer<(string Label, string InsertText)> + { + public static CompletionIdentityComparer Instance { get; } = new(); + + public bool Equals((string Label, string InsertText) x, (string Label, string InsertText) y) + => StringComparer.Ordinal.Equals(x.Label, y.Label) + && StringComparer.Ordinal.Equals(x.InsertText, y.InsertText); + + public int GetHashCode((string Label, string InsertText) value) + => HashCode.Combine( + StringComparer.Ordinal.GetHashCode(value.Label), + StringComparer.Ordinal.GetHashCode(value.InsertText)); + } + + private enum LuaLanguageServerCompletionKind + { + Text = 1, + Method = 2, + Function = 3, + Constructor = 4, + Field = 5, + Variable = 6, + Class = 7, + Interface = 8, + Module = 9, + Property = 10, + Unit = 11, + Value = 12, + Enum = 13, + Keyword = 14, + Snippet = 15, + Color = 16, + File = 17, + Reference = 18, + Folder = 19, + EnumMember = 20, + Constant = 21, + Struct = 22, + Event = 23, + Operator = 24, + TypeParameter = 25 + } + + private readonly struct CompletionTextAnalysis + { + public CompletionTextAnalysis(string? detail, string? description) + { + HasLocalScope = ContainsToken(detail, "local") + || ContainsToken(description, "local"); + + HasUpvalueOrParameter = ContainsToken(detail, "upvalue") + || ContainsToken(description, "upvalue") + || ContainsToken(detail, "parameter") + || ContainsToken(description, "parameter"); + + IconKindOverride = ResolveCompletionIconKind(detail); + } + + public bool HasLocalScope { get; } + public bool HasUpvalueOrParameter { get; } + public LuaCompletionIconKind? IconKindOverride { get; } + } + + /// + /// Parses a sequence of typed completion-item payloads into editor completion entries. + /// + /// The typed completion-item payloads. + /// Builds an optional lazy-resolve callback for each item. + /// The parsed completion items. + public static IReadOnlyList ParseCompletionItems(IEnumerable itemPayloads, + Func>?>? resolveFactory = null) + { + var items = new List(); + var seenItems = new HashSet<(string Label, string InsertText)>(CompletionIdentityComparer.Instance); + int itemIndex = 0; + + foreach (LuaCompletionItemPayload itemPayload in itemPayloads) + { + LuaCompletionItem? item = ParseCompletionItem(itemPayload, itemIndex); + itemIndex++; + + if (item is null) + continue; + + if (resolveFactory is not null && CompletionItemNeedsResolve(item)) + { + Func>? resolveAsync = resolveFactory(item, itemPayload, itemIndex - 1); + + if (resolveAsync is not null) + item = item.WithResolveCallback(resolveAsync); + } + + if (seenItems.Add((item.Label, item.InsertText))) + items.Add(item); + } + + return items; + } + + /// + /// Parses a single typed LSP completion-item payload into a . + /// + /// The typed completion-item payload. + /// The zero-based response index used for priority weighting. + /// An optional lazy-resolve callback. + /// The parsed completion item, or when the payload is invalid. + public static LuaCompletionItem? ParseCompletionItem(LuaCompletionItemPayload itemPayload, int itemIndex, + Func>? resolveAsync = null) + { + string? label = itemPayload.Label; + + if (string.IsNullOrWhiteSpace(label)) + return null; + + LuaCompletionTextEdit? textEdit = ExtractCompletionTextEdit(itemPayload, out string? textEditText); + + string insertText = textEditText ?? string.Empty; + + if (string.IsNullOrWhiteSpace(insertText)) + insertText = itemPayload.InsertText ?? label; + + int? insertCaretOffset = null; + + if (itemPayload.InsertTextFormat == 2) + { + LuaSnippetPlaceholderResult snippetResult = StripSnippetPlaceholders(insertText); + insertText = snippetResult.Text; + insertCaretOffset = snippetResult.CaretOffset; + } + + string filterText = itemPayload.FilterText ?? label; + + LuaLanguageServerCompletionKind completionKind = TryReadCompletionKind(itemPayload, out LuaLanguageServerCompletionKind parsedCompletionKind) + ? parsedCompletionKind + : LuaLanguageServerCompletionKind.Text; + + string? detail = BuildCompletionDetail(itemPayload); + MarkupContent description = BuildCompletionDescription(itemPayload); + string? searchableDescription = NormalizeMarkupText(description.Text); + var textAnalysis = new CompletionTextAnalysis(detail, searchableDescription); + + return new LuaCompletionItem( + label, + insertText, + detail, + description.Text, + filterText, + BuildCompletionPriority(itemPayload, textAnalysis, itemIndex), + BuildCompletionIconKind(completionKind, textAnalysis), + description.IsMarkdown, + resolveAsync, + textEdit, + insertCaretOffset: insertCaretOffset); + } + + private static LuaCompletionTextEdit? ExtractCompletionTextEdit(LuaCompletionItemPayload itemPayload, out string? textEditText) + { + textEditText = null; + + if (itemPayload.TextEdit is not { } textEditElement) + return null; + + textEditText = textEditElement.NewText; + + return ParseCompletionTextEdit(textEditElement); + } + + private static LuaCompletionTextEdit? ParseCompletionTextEdit(LuaCompletionTextEditPayload textEditElement) + { + if (TryParseCompletionRange(textEditElement.Range, out LuaCompletionRange range)) + return new LuaCompletionTextEdit(range); + + if (TryParseCompletionRange(textEditElement.Insert, out LuaCompletionRange insertRange) + && TryParseCompletionRange(textEditElement.Replace, out LuaCompletionRange replaceRange)) + { + return new LuaCompletionTextEdit(insertRange, replaceRange); + } + + return null; + } + + private static bool TryParseCompletionRange(LuaProtocolRangePayload? rangeElement, out LuaCompletionRange range) + { + range = default; + + if (!TryParseCompletionPosition(rangeElement?.Start, out LuaCompletionPosition start) + || !TryParseCompletionPosition(rangeElement?.End, out LuaCompletionPosition end)) + { + return false; + } + + range = new LuaCompletionRange(start, end); + return true; + } + + private static bool TryParseCompletionPosition(LuaProtocolNullablePosition? positionElement, out LuaCompletionPosition position) + { + position = default; + + if (positionElement is not { Line: int line, Character: int character }) + return false; + + if (line < 0 || character < 0) + return false; + + position = new LuaCompletionPosition(line, character); + return true; + } + + private static class CompletionPriorityWeights + { + public const double PreselectedBonus = 1000000.0; + public const double ResponseOrderWeight = 100000.0; + public const double LocalScope = 20000.0; + public const double UpvalueOrParameter = 15000.0; + public const double VariableKind = 10000.0; + public const double FieldOrPropertyKind = 9000.0; + public const double MethodOrFunctionKind = 7000.0; + public const double KeywordKindPenalty = -5000.0; + } + + private static bool CompletionItemNeedsResolve(LuaCompletionItem item) + => string.IsNullOrEmpty(item.Detail) || string.IsNullOrEmpty(item.Description); + + private static double BuildCompletionPriority(LuaCompletionItemPayload itemPayload, CompletionTextAnalysis textAnalysis, int itemIndex) + { + double priority = CompletionPriorityWeights.ResponseOrderWeight - itemIndex; + + if (itemPayload.Preselect == true) + priority += CompletionPriorityWeights.PreselectedBonus; + + if (itemPayload.Kind is int completionKind) + { + priority += completionKind switch + { + (int)LuaLanguageServerCompletionKind.Variable => CompletionPriorityWeights.VariableKind, + (int)LuaLanguageServerCompletionKind.Field => CompletionPriorityWeights.FieldOrPropertyKind, + (int)LuaLanguageServerCompletionKind.Property => CompletionPriorityWeights.FieldOrPropertyKind, + (int)LuaLanguageServerCompletionKind.Method => CompletionPriorityWeights.MethodOrFunctionKind, + (int)LuaLanguageServerCompletionKind.Function => CompletionPriorityWeights.MethodOrFunctionKind, + (int)LuaLanguageServerCompletionKind.Keyword => CompletionPriorityWeights.KeywordKindPenalty, + _ => 0.0 + }; + } + + if (textAnalysis.HasLocalScope) + priority += CompletionPriorityWeights.LocalScope; + + if (textAnalysis.HasUpvalueOrParameter) + priority += CompletionPriorityWeights.UpvalueOrParameter; + + return priority; + } + + private static bool TryReadCompletionKind(LuaCompletionItemPayload itemPayload, out LuaLanguageServerCompletionKind kind) + { + kind = LuaLanguageServerCompletionKind.Text; + + if (itemPayload.Kind is not int rawKind + || !Enum.IsDefined(typeof(LuaLanguageServerCompletionKind), rawKind)) + { + return false; + } + + kind = (LuaLanguageServerCompletionKind)rawKind; + return true; + } + + private static LuaCompletionIconKind BuildCompletionIconKind(LuaLanguageServerCompletionKind kind, CompletionTextAnalysis textAnalysis) + { + if (textAnalysis.IconKindOverride is LuaCompletionIconKind iconKindOverride) + return iconKindOverride; + + return kind switch + { + LuaLanguageServerCompletionKind.Method => LuaCompletionIconKind.Method, + LuaLanguageServerCompletionKind.Function => LuaCompletionIconKind.Method, + LuaLanguageServerCompletionKind.Constructor => LuaCompletionIconKind.Method, + LuaLanguageServerCompletionKind.Field => LuaCompletionIconKind.Field, + LuaLanguageServerCompletionKind.Variable => LuaCompletionIconKind.Variable, + LuaLanguageServerCompletionKind.Class => LuaCompletionIconKind.Class, + LuaLanguageServerCompletionKind.Interface => LuaCompletionIconKind.Class, + LuaLanguageServerCompletionKind.Module => LuaCompletionIconKind.Namespace, + LuaLanguageServerCompletionKind.Property => LuaCompletionIconKind.Property, + LuaLanguageServerCompletionKind.Value => LuaCompletionIconKind.Variable, + LuaLanguageServerCompletionKind.Enum => LuaCompletionIconKind.Class, + LuaLanguageServerCompletionKind.Keyword => LuaCompletionIconKind.Keyword, + LuaLanguageServerCompletionKind.Snippet => LuaCompletionIconKind.Keyword, + LuaLanguageServerCompletionKind.File => LuaCompletionIconKind.File, + LuaLanguageServerCompletionKind.Reference => LuaCompletionIconKind.Variable, + LuaLanguageServerCompletionKind.Folder => LuaCompletionIconKind.Folder, + LuaLanguageServerCompletionKind.EnumMember => LuaCompletionIconKind.Constant, + LuaLanguageServerCompletionKind.Constant => LuaCompletionIconKind.Constant, + LuaLanguageServerCompletionKind.Struct => LuaCompletionIconKind.Class, + LuaLanguageServerCompletionKind.Event => LuaCompletionIconKind.Method, + LuaLanguageServerCompletionKind.Operator => LuaCompletionIconKind.Keyword, + LuaLanguageServerCompletionKind.TypeParameter => LuaCompletionIconKind.Class, + _ => LuaCompletionIconKind.Misc + }; + } + + private static bool ContainsToken(string? text, string token) + => !string.IsNullOrEmpty(text) && text.Contains(token, StringComparison.OrdinalIgnoreCase); + + private static LuaCompletionIconKind? ResolveCompletionIconKind(string? detailText) + { + if (ContainsToken(detailText, "parameter")) + return LuaCompletionIconKind.Parameter; + + if (ContainsToken(detailText, "module") || ContainsToken(detailText, "namespace")) + return LuaCompletionIconKind.Namespace; + + if (ContainsToken(detailText, "method") || ContainsToken(detailText, "function")) + return LuaCompletionIconKind.Method; + + if (ContainsToken(detailText, "field")) + return LuaCompletionIconKind.Field; + + if (ContainsToken(detailText, "property") || ContainsToken(detailText, "global") + || ContainsToken(detailText, "default library")) + { + return LuaCompletionIconKind.Property; + } + + if (ContainsToken(detailText, "constant")) + return LuaCompletionIconKind.Constant; + + if (ContainsToken(detailText, "keyword")) + return LuaCompletionIconKind.Keyword; + + if (ContainsToken(detailText, "class") || ContainsToken(detailText, "interface") + || ContainsToken(detailText, "enum") || ContainsToken(detailText, "struct")) + { + return LuaCompletionIconKind.Class; + } + + return null; + } + + private static string? BuildCompletionDetail(LuaCompletionItemPayload itemPayload) + { + string? detail = itemPayload.Detail; + return string.IsNullOrWhiteSpace(detail) ? null : detail.Trim(); + } + + private static MarkupContent BuildCompletionDescription(LuaCompletionItemPayload itemPayload) + { + if (itemPayload.Documentation is not { } documentationElement + || documentationElement.ValueKind == JsonValueKind.Undefined) + { + return default; + } + + MarkupContent documentation = ExtractMarkupContent(documentationElement); + + if (string.IsNullOrWhiteSpace(documentation.Text)) + return default; + + string? normalizedText = documentation.IsMarkdown + ? NormalizeMarkdownText(documentation.Text) + : NormalizeMarkupText(documentation.Text); + + return string.IsNullOrWhiteSpace(normalizedText) + ? default + : new MarkupContent(normalizedText, documentation.IsMarkdown); + } + + private static LuaSnippetPlaceholderResult StripSnippetPlaceholders(string snippet) + { + if (string.IsNullOrWhiteSpace(snippet)) + return new LuaSnippetPlaceholderResult(snippet, null); + + var builder = new StringBuilder(snippet.Length); + int? caretOffset = null; + int index = 0; + + while (index < snippet.Length) + { + if (snippet[index] == '$') + { + if (index + 1 < snippet.Length && snippet[index + 1] == '{') + { + int endIndex = snippet.IndexOf('}', index + 2); + + if (endIndex > index) + { + string placeholder = snippet[(index + 2)..endIndex]; + int separatorIndex = placeholder.IndexOf(':'); + + ReadOnlySpan placeholderNumber = separatorIndex >= 0 + ? placeholder.AsSpan(0, separatorIndex) + : placeholder.AsSpan(); + + if (int.TryParse(placeholderNumber, out int placeholderIndex)) + { + if (separatorIndex >= 0 && separatorIndex < placeholder.Length - 1) + builder.Append(placeholder[(separatorIndex + 1)..]); + + if (placeholderIndex == 0) + caretOffset ??= builder.Length; + + index = endIndex + 1; + continue; + } + + builder.Append(snippet, index, endIndex - index + 1); + index = endIndex + 1; + continue; + } + } + + index++; + int placeholderStart = index; + + while (index < snippet.Length && char.IsDigit(snippet[index])) + index++; + + if (placeholderStart < index) + { + if (index - placeholderStart == 1 && snippet[placeholderStart] == '0') + caretOffset ??= builder.Length; + + continue; + } + + builder.Append('$'); + continue; + } + + builder.Append(snippet[index]); + index++; + } + + return new LuaSnippetPlaceholderResult(builder.ToString(), caretOffset); + } +} diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Completion/LuaSnippetPlaceholderResult.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Completion/LuaSnippetPlaceholderResult.cs new file mode 100644 index 0000000000..7686de4394 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Completion/LuaSnippetPlaceholderResult.cs @@ -0,0 +1,6 @@ +namespace TombLib.Scripting.Lua.LanguageServer; + +/// +/// Represents snippet text after placeholder stripping and the resolved caret placement. +/// +public readonly record struct LuaSnippetPlaceholderResult(string Text, int? CaretOffset); diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Diagnostics/LuaLanguageServerDiagnosticsParser.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Diagnostics/LuaLanguageServerDiagnosticsParser.cs new file mode 100644 index 0000000000..4defa85d13 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Diagnostics/LuaLanguageServerDiagnosticsParser.cs @@ -0,0 +1,264 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text; +using System.Text.Json; +using TombLib.Scripting.Objects; + +namespace TombLib.Scripting.Lua.LanguageServer; + +public static class LuaLanguageServerDiagnosticsParser +{ + /// + /// Parses a LuaLS diagnostics notification into editor diagnostics for a tracked document. + /// + public static bool TryParse(LuaPublishDiagnosticsParams parameters, string filePath, + string documentContent, int documentVersion, [NotNullWhen(true)] out LuaPublishedDiagnostics? publishedDiagnostics) + { + publishedDiagnostics = null; + + int diagnosticsVersion = parameters.Version is > 0 ? parameters.Version.Value : 0; + + if (diagnosticsVersion > 0 && documentVersion > 0 && diagnosticsVersion != documentVersion) + return false; + + IReadOnlyList diagnostics = parameters.Diagnostics is { Length: > 0 } + ? BuildDiagnostics(documentContent, parameters.Diagnostics) + : []; + + publishedDiagnostics = new LuaPublishedDiagnostics(filePath, diagnostics, diagnosticsVersion); + return true; + } + + private static IReadOnlyList BuildDiagnostics(string content, LuaDiagnosticPayload[] diagnosticsPayloads) + { + LuaDocumentLineOffsets lineOffsets = LuaDocumentLineOffsets.Build(content); + var diagnostics = new List(); + + foreach (LuaDiagnosticPayload diagnosticElement in diagnosticsPayloads) + { + TextEditorDiagnosticSeverity severity = GetDiagnosticSeverity(diagnosticElement); + + if (severity > TextEditorDiagnosticSeverity.Warning) + continue; + + if (!TryCreateDiagnostic(lineOffsets, diagnosticElement, severity, out TextEditorDiagnostic? diagnostic)) + continue; + + diagnostics.Add(diagnostic); + } + + return [.. diagnostics + .OrderBy(diagnostic => diagnostic.StartOffset) + .ThenBy(diagnostic => diagnostic.Severity)]; + } + + private static bool TryCreateDiagnostic(LuaDocumentLineOffsets lineOffsets, LuaDiagnosticPayload diagnosticElement, + TextEditorDiagnosticSeverity severity, [NotNullWhen(true)] out TextEditorDiagnostic? diagnostic) + { + diagnostic = null; + + if (lineOffsets.LineCount == 0 + || diagnosticElement.Range is not { } rangeElement + || rangeElement.Start is not { } startElement + || startElement.Line is not int lineIndex) + { + return false; + } + + lineIndex = Math.Max(0, Math.Min(lineIndex, lineOffsets.LineCount - 1)); + + int startCharacter = startElement.Character is int character + ? Math.Max(0, character) + : 0; + + int endLineIndex = lineIndex; + int endCharacter = startCharacter; + + if (rangeElement.End is { } endElement && endElement.Line is int rawEndLineIndex) + { + endLineIndex = Math.Max(lineIndex, Math.Min(rawEndLineIndex, lineOffsets.LineCount - 1)); + + if (endElement.Character is int endCharacterValue) + endCharacter = Math.Max(0, endCharacterValue); + } + + if (!TryGetDiagnosticOffsets(lineOffsets, lineIndex, startCharacter, endLineIndex, endCharacter, + out int startOffset, out int endOffset)) + { + return false; + } + + diagnostic = new TextEditorDiagnostic(severity, BuildDiagnosticMessage(diagnosticElement, severity), startOffset, endOffset); + return true; + } + + private static bool TryGetDiagnosticOffsets(LuaDocumentLineOffsets lineOffsets, + int startLineIndex, int startCharacter, int endLineIndex, int endCharacter, + out int startOffset, out int endOffset) + { + startOffset = 0; + endOffset = 0; + + if (lineOffsets.LineCount == 0) + return false; + + startOffset = lineOffsets.GetOffset(startLineIndex, startCharacter); + endOffset = lineOffsets.GetOffset(endLineIndex, endCharacter); + + if (endOffset > startOffset) + return true; + + string lineText = lineOffsets.GetLineText(startLineIndex); + int lineStartOffset = lineOffsets.GetLineStartOffset(startLineIndex); + + if (string.IsNullOrEmpty(lineText)) + return TryGetEmptyLineFallbackOffsets(lineOffsets, startLineIndex, out startOffset, out endOffset); + + int safeCharacter = Math.Max(0, Math.Min(startCharacter, Math.Max(0, lineText.Length - 1))); + + if (TryGetWordBounds(lineText, safeCharacter, out int wordStart, out int wordEnd)) + { + startOffset = lineStartOffset + wordStart; + endOffset = lineStartOffset + wordEnd; + return endOffset > startOffset; + } + + int trimmedStart = 0; + int trimmedEnd = lineText.Length; + + while (trimmedStart < trimmedEnd && char.IsWhiteSpace(lineText[trimmedStart])) + trimmedStart++; + + while (trimmedEnd > trimmedStart && char.IsWhiteSpace(lineText[trimmedEnd - 1])) + trimmedEnd--; + + if (trimmedEnd > trimmedStart) + { + startOffset = lineStartOffset + trimmedStart; + endOffset = lineStartOffset + trimmedEnd; + return true; + } + + startOffset = lineStartOffset + safeCharacter; + endOffset = Math.Min(startOffset + 1, lineOffsets.TextLength); + return endOffset > startOffset; + } + + private static bool TryGetEmptyLineFallbackOffsets(LuaDocumentLineOffsets lineOffsets, int lineIndex, + out int startOffset, out int endOffset) + { + startOffset = 0; + endOffset = 0; + + for (int nextLineIndex = lineIndex + 1; nextLineIndex < lineOffsets.LineCount; nextLineIndex++) + { + if (lineOffsets.GetLineLength(nextLineIndex) == 0) + continue; + + startOffset = lineOffsets.GetLineStartOffset(nextLineIndex); + endOffset = Math.Min(startOffset + 1, lineOffsets.TextLength); + return endOffset > startOffset; + } + + for (int previousLineIndex = lineIndex - 1; previousLineIndex >= 0; previousLineIndex--) + { + int previousLineLength = lineOffsets.GetLineLength(previousLineIndex); + + if (previousLineLength == 0) + continue; + + startOffset = lineOffsets.GetLineStartOffset(previousLineIndex) + previousLineLength - 1; + endOffset = Math.Min(startOffset + 1, lineOffsets.TextLength); + return endOffset > startOffset; + } + + return false; + } + + private static bool TryGetWordBounds(string lineText, int index, out int wordStart, out int wordEnd) + { + wordStart = 0; + wordEnd = 0; + + if (string.IsNullOrEmpty(lineText)) + return false; + + int safeIndex = Math.Max(0, Math.Min(index, lineText.Length - 1)); + + if (!IsDiagnosticSegmentCharacter(lineText[safeIndex]) && safeIndex > 0 && IsDiagnosticSegmentCharacter(lineText[safeIndex - 1])) + safeIndex--; + + while (safeIndex < lineText.Length && !IsDiagnosticSegmentCharacter(lineText[safeIndex])) + { + safeIndex++; + + if (safeIndex >= lineText.Length) + return false; + } + + wordStart = safeIndex; + wordEnd = safeIndex; + + while (wordStart > 0 && IsDiagnosticSegmentCharacter(lineText[wordStart - 1])) + wordStart--; + + while (wordEnd < lineText.Length && IsDiagnosticSegmentCharacter(lineText[wordEnd])) + wordEnd++; + + return wordEnd > wordStart; + } + + private static bool IsDiagnosticSegmentCharacter(char c) + => char.IsLetterOrDigit(c) || c == '_' || c == '.' || c == ':' || c == '\'' || c == '"'; + + private static TextEditorDiagnosticSeverity GetDiagnosticSeverity(LuaDiagnosticPayload diagnosticElement) + { + return diagnosticElement.Severity is int severity + && severity > 0 + ? (TextEditorDiagnosticSeverity)severity + : TextEditorDiagnosticSeverity.Warning; + } + + private static string BuildDiagnosticMessage(LuaDiagnosticPayload diagnosticElement, TextEditorDiagnosticSeverity severity) + { + string? message = diagnosticElement.Message?.Trim(); + + if (string.IsNullOrWhiteSpace(message)) + message = "Unknown Lua diagnostic."; + + var builder = new StringBuilder(); + builder.Append(severity.GetLabel()); + builder.Append(": "); + builder.Append(message); + + string? source = diagnosticElement.Source; + + string? code = diagnosticElement.Code is { } codeElement + ? codeElement.ValueKind == JsonValueKind.String + ? codeElement.GetString() + : codeElement.ValueKind == JsonValueKind.Number + ? codeElement.GetRawText() + : null + : null; + + if (!string.IsNullOrWhiteSpace(source) || !string.IsNullOrWhiteSpace(code)) + { + builder.AppendLine(); + builder.AppendLine(); + builder.Append("Source: "); + + if (!string.IsNullOrWhiteSpace(source)) + builder.Append(source.Trim()); + else + builder.Append("Lua language server"); + + if (!string.IsNullOrWhiteSpace(code)) + { + builder.Append(" ("); + builder.Append(code.Trim()); + builder.Append(')'); + } + } + + return builder.ToString(); + } +} diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Diagnostics/LuaPublishedDiagnostics.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Diagnostics/LuaPublishedDiagnostics.cs new file mode 100644 index 0000000000..6424914d72 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Diagnostics/LuaPublishedDiagnostics.cs @@ -0,0 +1,37 @@ +using TombLib.Scripting.Objects; + +namespace TombLib.Scripting.Lua.LanguageServer; + +/// +/// Represents a diagnostics payload published by LuaLS for a specific document version. +/// +public sealed class LuaPublishedDiagnostics +{ + /// + /// Initializes a new instance of the class. + /// + /// The normalized file path associated with the diagnostics. + /// The parsed diagnostics for that file. + /// The synchronized document version that produced the diagnostics. + public LuaPublishedDiagnostics(string filePath, IReadOnlyList diagnostics, int version) + { + FilePath = filePath; + Diagnostics = diagnostics ?? []; + Version = version; + } + + /// + /// Gets the normalized file path associated with the diagnostics. + /// + public string FilePath { get; } + + /// + /// Gets the parsed diagnostics payload. + /// + public IReadOnlyList Diagnostics { get; } + + /// + /// Gets the synchronized document version that produced the diagnostics. + /// + public int Version { get; } +} diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Documents/LuaDocumentLineOffsets.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Documents/LuaDocumentLineOffsets.cs new file mode 100644 index 0000000000..b656c8782b --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Documents/LuaDocumentLineOffsets.cs @@ -0,0 +1,113 @@ +namespace TombLib.Scripting.Lua.LanguageServer; + +/// +/// Provides a precomputed table of line start offsets and lengths for a Lua document content string. +/// This avoids allocating heavyweight TextDocument instances when parsing LSP payloads such as +/// diagnostics and semantic tokens, which only need to translate between line/character and document offsets. +/// Both \r\n and lone \r are treated as line breaks for parity with LSP positions. +/// +public sealed class LuaDocumentLineOffsets +{ + private readonly string _content; + private readonly int[] _lineStartOffsets; + private readonly int[] _lineLengths; + + private LuaDocumentLineOffsets(string content, int[] lineStartOffsets, int[] lineLengths) + { + _content = content; + _lineStartOffsets = lineStartOffsets; + _lineLengths = lineLengths; + } + + /// + /// Gets the number of logical lines in the document. + /// + public int LineCount => _lineLengths.Length; + + /// + /// Gets the total document length in characters. + /// + public int TextLength => _content.Length; + + /// + /// Gets the length of the specified zero-based line. + /// + /// The zero-based line index. + /// The line length in characters. + public int GetLineLength(int lineIndex) => _lineLengths[lineIndex]; + + /// + /// Gets the absolute document offset where the specified zero-based line starts. + /// + /// The zero-based line index. + /// The absolute document offset. + public int GetLineStartOffset(int lineIndex) => _lineStartOffsets[lineIndex]; + + /// + /// Returns the offset within the document for the supplied zero-based line and character indices, + /// clamping the character index to the line length. + /// + /// The zero-based line index. + /// The zero-based character index within the line. + /// The absolute document offset. + public int GetOffset(int lineIndex, int character) + { + int safeLine = Math.Clamp(lineIndex, 0, _lineLengths.Length - 1); + int safeCharacter = Math.Clamp(character, 0, _lineLengths[safeLine]); + return _lineStartOffsets[safeLine] + safeCharacter; + } + + /// + /// Gets the text of the specified zero-based line. + /// + /// The zero-based line index. + /// The line text without its trailing newline sequence. + public string GetLineText(int lineIndex) + { + int safeLine = Math.Clamp(lineIndex, 0, _lineLengths.Length - 1); + return _content.Substring(_lineStartOffsets[safeLine], _lineLengths[safeLine]); + } + + /// + /// Builds the offset table from the given content in a single pass, avoiding the large intermediate + /// allocations of . + /// + /// The document text to analyze. + /// A line-offset table for the supplied content. + public static LuaDocumentLineOffsets Build(string? content) + { + string text = content ?? string.Empty; + + if (text.Length == 0) + return new LuaDocumentLineOffsets(text, [0], [0]); + + var startOffsets = new List(64) { 0 }; + var lengths = new List(64); + int currentStart = 0; + + for (int i = 0; i < text.Length; i++) + { + char c = text[i]; + + if (c == '\r') + { + lengths.Add(i - currentStart); + + if (i + 1 < text.Length && text[i + 1] == '\n') + i++; + + currentStart = i + 1; + startOffsets.Add(currentStart); + } + else if (c == '\n') + { + lengths.Add(i - currentStart); + currentStart = i + 1; + startOffsets.Add(currentStart); + } + } + + lengths.Add(text.Length - currentStart); + return new LuaDocumentLineOffsets(text, [.. startOffsets], [.. lengths]); + } +} diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Documents/LuaDocumentRenameRequest.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Documents/LuaDocumentRenameRequest.cs new file mode 100644 index 0000000000..3a1d7a2ce4 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Documents/LuaDocumentRenameRequest.cs @@ -0,0 +1,9 @@ +namespace TombLib.Scripting.Lua.LanguageServer; + +/// +/// Describes a tracked-document path rekey that may need to be mirrored to LuaLS as a close/open pair. +/// +public readonly record struct LuaDocumentRenameRequest( + LuaDocumentSnapshot? PreviousDocument, + LuaDocumentSnapshot RenamedDocument, + bool ReopenServerDocument); diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Documents/LuaDocumentSnapshot.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Documents/LuaDocumentSnapshot.cs new file mode 100644 index 0000000000..0930660f33 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Documents/LuaDocumentSnapshot.cs @@ -0,0 +1,42 @@ +namespace TombLib.Scripting.Lua.LanguageServer; + +/// +/// Captures the current synchronized state of a tracked Lua document. +/// +public sealed class LuaDocumentSnapshot +{ + /// + /// Initializes a new instance of the class. + /// + /// The normalized local file path. + /// The corresponding file URI sent to LuaLS. + /// The current document text. + /// The local synchronization version. + public LuaDocumentSnapshot(string filePath, string uri, string? content, int version) + { + FilePath = filePath; + Uri = uri; + Content = content ?? string.Empty; + Version = version; + } + + /// + /// Gets the normalized local file path. + /// + public string FilePath { get; } + + /// + /// Gets the file URI sent to LuaLS for this document. + /// + public string Uri { get; } + + /// + /// Gets the current document text. + /// + public string Content { get; } + + /// + /// Gets the local synchronization version. + /// + public int Version { get; } +} diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Documents/LuaDocumentSynchronizationKind.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Documents/LuaDocumentSynchronizationKind.cs new file mode 100644 index 0000000000..8f52ff11f2 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Documents/LuaDocumentSynchronizationKind.cs @@ -0,0 +1,17 @@ +namespace TombLib.Scripting.Lua.LanguageServer; + +/// +/// Identifies the LSP document-synchronization action that should be sent for a tracked file. +/// +public enum LuaDocumentSynchronizationKind +{ + /// + /// The document must be opened on the server. + /// + Open, + + /// + /// The document content changed and should be updated on the server. + /// + Change +} diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Documents/LuaDocumentSynchronizationRequest.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Documents/LuaDocumentSynchronizationRequest.cs new file mode 100644 index 0000000000..98b437fff0 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Documents/LuaDocumentSynchronizationRequest.cs @@ -0,0 +1,22 @@ +namespace TombLib.Scripting.Lua.LanguageServer; + +/// +/// Lightweight value-type carrier for an in-flight LuaLS document synchronization request. Returned +/// from as a nullable so the per-keystroke +/// path does not allocate a heap instance for every character. +/// +public readonly record struct LuaDocumentSynchronizationRequest( + LuaDocumentSynchronizationKind Kind, + LuaDocumentSnapshot Document, + LuaDocumentChangeRange? ChangeRange = null); + +/// +/// Describes a single incremental textDocument/didChange range edit computed from the difference +/// between the previously-synced and newly-synced document contents. +/// +public readonly record struct LuaDocumentChangeRange( + int StartLine, + int StartCharacter, + int EndLine, + int EndCharacter, + string Text); diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Documents/LuaDocumentSynchronizationResult.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Documents/LuaDocumentSynchronizationResult.cs new file mode 100644 index 0000000000..e822b55970 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Documents/LuaDocumentSynchronizationResult.cs @@ -0,0 +1,6 @@ +namespace TombLib.Scripting.Lua.LanguageServer; + +/// +/// Represents the outcome of synchronizing one document with the Lua language server. +/// +public readonly record struct LuaDocumentSynchronizationResult(bool Success, LuaDocumentSnapshot? Document); diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Documents/LuaIncrementalEditCalculator.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Documents/LuaIncrementalEditCalculator.cs new file mode 100644 index 0000000000..a4b59f010c --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Documents/LuaIncrementalEditCalculator.cs @@ -0,0 +1,85 @@ +namespace TombLib.Scripting.Lua.LanguageServer; + +/// +/// Computes a minimal single-range edit that transforms oldText into newText using a +/// common-prefix / common-suffix scan. The result is suitable for an LSP `textDocument/didChange` +/// incremental notification: a server applying the returned range and replacement text to the old +/// content reproduces the new content byte-for-byte. +/// +public static class LuaIncrementalEditCalculator +{ + /// + /// Computes the minimal single-range edit that transforms one document snapshot into another. + /// + /// The previously synchronized document content. + /// The updated document content. + /// The line-offset table for . + /// The incremental change range to send in textDocument/didChange. + public static LuaDocumentChangeRange Compute(string oldText, string newText, LuaDocumentLineOffsets oldOffsets) + { + oldText ??= string.Empty; + newText ??= string.Empty; + + int prefixLength = ComputeCommonPrefixLength(oldText, newText); + int suffixLength = ComputeCommonSuffixLength(oldText, newText, prefixLength); + + int oldEnd = oldText.Length - suffixLength; + int newEnd = newText.Length - suffixLength; + + string replacement = newEnd > prefixLength ? newText[prefixLength..newEnd] : string.Empty; + + (int startLine, int startCharacter) = OffsetToPosition(oldOffsets, prefixLength); + (int endLine, int endCharacter) = OffsetToPosition(oldOffsets, oldEnd); + + return new LuaDocumentChangeRange(startLine, startCharacter, endLine, endCharacter, replacement); + } + + private static int ComputeCommonPrefixLength(string a, string b) + { + int max = Math.Min(a.Length, b.Length); + int i = 0; + + while (i < max && a[i] == b[i]) + i++; + + return i; + } + + private static int ComputeCommonSuffixLength(string a, string b, int prefixLength) + { + int max = Math.Min(a.Length, b.Length) - prefixLength; + int i = 0; + + while (i < max && a[a.Length - 1 - i] == b[b.Length - 1 - i]) + i++; + + return i; + } + + private static (int Line, int Character) OffsetToPosition(LuaDocumentLineOffsets offsets, int offset) + { + int lineCount = offsets.LineCount; + + if (lineCount == 0) + return (0, 0); + + // Binary search across line start offsets. + int low = 0; + int high = lineCount - 1; + + while (low < high) + { + int mid = (low + high + 1) >>> 1; + + if (offsets.GetLineStartOffset(mid) <= offset) + low = mid; + else + high = mid - 1; + } + + int lineStart = offsets.GetLineStartOffset(low); + int character = Math.Max(0, offset - lineStart); + + return (low, character); + } +} diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Documents/LuaIntellisenseDocumentManager.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Documents/LuaIntellisenseDocumentManager.cs new file mode 100644 index 0000000000..a994d21590 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Documents/LuaIntellisenseDocumentManager.cs @@ -0,0 +1,378 @@ +using TombLib.Scripting.Lua.Objects; +using TombLib.Scripting.Objects; + +namespace TombLib.Scripting.Lua.LanguageServer; + +/// +/// Tracks the local document state mirrored to LuaLS, including versions, diagnostics, and semantic-token caches. +/// +public sealed class LuaIntellisenseDocumentManager +{ + private sealed class DocumentState + { + public required string FilePath { get; set; } + public required string Uri { get; set; } + public string Content { get; set; } = string.Empty; + public int Version { get; set; } + public bool IsOpen { get; set; } + + // Number of editor tabs that currently consider this document open. The provider only sends + // `textDocument/didClose` to LuaLS when this count returns to zero, so closing one tab while + // another tab still shows the same file does not strip diagnostics or semantic tokens from + // the surviving editor. + public int OpenReferenceCount { get; set; } + + public IReadOnlyList Diagnostics { get; set; } = []; + public int DiagnosticsVersion { get; set; } + + public IReadOnlyList SemanticTokens { get; set; } = []; + public int SemanticTokensVersion { get; set; } + + // Last raw `data` payload returned by `textDocument/semanticTokens/full(/delta)` for this + // file, kept around so an incoming delta `edits` payload can be applied without forcing the + // server to resend the entire token stream. + public int[]? SemanticTokensData { get; set; } + + public string? SemanticTokensResultId { get; set; } + } + + private readonly object _syncRoot = new(); + private readonly Dictionary _documents = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Gets the cached diagnostics for the specified normalized file path. + /// + /// The normalized file path. + /// The cached diagnostics, or an empty list when none are stored. + public IReadOnlyList GetDiagnostics(string filePath) + { + lock (_syncRoot) + return _documents.TryGetValue(filePath, out DocumentState? state) ? state.Diagnostics : []; + } + + /// + /// Gets the cached semantic tokens for the specified normalized file path. + /// + /// The normalized file path. + /// The cached semantic tokens, or an empty list when none are stored. + public IReadOnlyList GetSemanticTokens(string filePath) + { + lock (_syncRoot) + return _documents.TryGetValue(filePath, out DocumentState? state) ? state.SemanticTokens : []; + } + + /// + /// Synchronizes the tracked state for a document and returns the LSP action required to mirror it to LuaLS. + /// + /// The normalized file path. + /// The latest document content. + /// Whether an additional open-editor reference should be recorded. + /// A synchronization request when LuaLS must be updated; otherwise, . + public LuaDocumentSynchronizationRequest? Synchronize(string filePath, string? content, bool acquireOpenReference = false) + { + string safeContent = content ?? string.Empty; + + lock (_syncRoot) + { + if (!_documents.TryGetValue(filePath, out DocumentState? state)) + { + state = new DocumentState + { + FilePath = filePath, + Uri = LuaLanguageServerPathHelper.CreateFileUri(filePath), + Content = safeContent, + Version = 1, + IsOpen = true, + OpenReferenceCount = acquireOpenReference ? 1 : 0 + }; + + _documents[filePath] = state; + return new LuaDocumentSynchronizationRequest(LuaDocumentSynchronizationKind.Open, CreateSnapshot(state)); + } + + if (acquireOpenReference) + state.OpenReferenceCount++; + + if (!state.IsOpen) + { + state.Content = safeContent; + state.Version++; + state.IsOpen = true; + return new LuaDocumentSynchronizationRequest(LuaDocumentSynchronizationKind.Open, CreateSnapshot(state)); + } + + if (!string.Equals(state.Content, safeContent, StringComparison.Ordinal)) + { + string previousContent = state.Content; + state.Content = safeContent; + state.Version++; + + LuaDocumentLineOffsets previousOffsets = LuaDocumentLineOffsets.Build(previousContent); + LuaDocumentChangeRange changeRange = LuaIncrementalEditCalculator.Compute(previousContent, safeContent, previousOffsets); + return new LuaDocumentSynchronizationRequest(LuaDocumentSynchronizationKind.Change, CreateSnapshot(state), changeRange); + } + + return null; + } + } + + /// + /// Rekeys a tracked document to a new normalized file path and preserves any diagnostics or semantic tokens + /// that still match the current content. + /// + /// The current normalized file path. + /// The replacement normalized file path. + /// The latest editor content. + /// The rename request that should be mirrored to LuaLS, or when no document was tracked. + public LuaDocumentRenameRequest? Rename(string oldFilePath, string newFilePath, string? content = null) + { + if (string.Equals(oldFilePath, newFilePath, StringComparison.OrdinalIgnoreCase)) + return null; + + lock (_syncRoot) + { + if (!_documents.TryGetValue(oldFilePath, out DocumentState? state)) + return null; + + // The host blocks rename-to-existing-path in normal flows. If an external rename still + // targets a tracked document, keep both states intact instead of overwriting the destination. + if (_documents.ContainsKey(newFilePath)) + return null; + + string safeContent = content ?? state.Content; + bool contentChanged = !string.Equals(state.Content, safeContent, StringComparison.Ordinal); + LuaDocumentSnapshot? previousDocument = state.IsOpen ? CreateSnapshot(state) : null; + + _documents.Remove(oldFilePath); + + state.FilePath = newFilePath; + state.Uri = LuaLanguageServerPathHelper.CreateFileUri(newFilePath); + + if (contentChanged) + { + state.Content = safeContent; + state.Version++; + ClearCachedState(state); + } + + _documents[newFilePath] = state; + return new LuaDocumentRenameRequest(previousDocument, CreateSnapshot(state), previousDocument is not null); + } + } + + /// + /// Releases one open reference for . The document is only fully + /// removed once the reference count drops to zero, so multiple editor tabs sharing the same + /// file do not invalidate each other on close. When the server-side document is still open, + /// contains the snapshot that should be mirrored with didClose. + /// If a restart is pending and the server copy is already gone, the document is still removed + /// locally but is . + /// + /// The normalized file path. + /// When this method returns, contains the closing snapshot if the server copy is still open. + /// when the document was removed locally; otherwise, . + public bool TryClose(string filePath, out LuaDocumentSnapshot? document) + { + lock (_syncRoot) + { + if (!_documents.TryGetValue(filePath, out DocumentState? state)) + { + document = null; + return false; + } + + if (state.OpenReferenceCount > 0) + state.OpenReferenceCount--; + + if (state.OpenReferenceCount > 0) + { + document = null; + return false; + } + + document = state.IsOpen ? CreateSnapshot(state) : null; + _documents.Remove(filePath); + return true; + } + } + + /// + /// Marks every tracked document as closed on the server after a language-server restart. Documents + /// that still have live editor references are returned so the provider can reopen them eagerly on + /// the next successful start, while request-only tracked documents reopen lazily on demand. + /// + /// The snapshots that should be reopened on the next successful start. + public IReadOnlyList PrepareForRestart() + { + lock (_syncRoot) + { + var documentsToReopen = new List(); + + foreach (DocumentState state in _documents.Values) + { + state.IsOpen = false; + + if (state.OpenReferenceCount > 0) + documentsToReopen.Add(CreateSnapshot(state)); + } + + return documentsToReopen; + } + } + + /// + /// Gets the current snapshot for a tracked document. + /// + /// The normalized file path. + /// The current snapshot, or when the document is not tracked. + public LuaDocumentSnapshot? GetDocumentSnapshot(string filePath) + { + lock (_syncRoot) + { + return _documents.TryGetValue(filePath, out DocumentState? state) + ? CreateSnapshot(state) + : null; + } + } + + /// + /// Gets snapshots for all documents that are currently considered open. + /// + /// The open-document snapshots. + public IReadOnlyList GetOpenDocuments() + { + lock (_syncRoot) + { + var documents = new List(); + + foreach (DocumentState state in _documents.Values) + { + if (state.IsOpen) + documents.Add(CreateSnapshot(state)); + } + + return documents; + } + } + + /// + /// Stores a diagnostics payload when it is not stale for the tracked document version. + /// + /// The diagnostics payload to cache. + /// when the payload was stored; otherwise, . + public bool TryStoreDiagnostics(LuaPublishedDiagnostics publishedDiagnostics) + { + lock (_syncRoot) + { + if (!_documents.TryGetValue(publishedDiagnostics.FilePath, out DocumentState? state)) + return false; + + if (IsStaleVersion(state.DiagnosticsVersion, publishedDiagnostics.Version)) + return false; + + if (publishedDiagnostics.Version > 0) + state.DiagnosticsVersion = publishedDiagnostics.Version; + + state.Diagnostics = publishedDiagnostics.Diagnostics; + return true; + } + } + + /// + /// Stores semantic tokens when they are not stale for the tracked document version. + /// + /// The normalized file path. + /// The document version associated with the tokens. + /// The semantic tokens to cache. + /// when the token set was stored; otherwise, . + public bool TryStoreSemanticTokens(string filePath, int version, IReadOnlyList semanticTokens) + { + lock (_syncRoot) + { + if (!_documents.TryGetValue(filePath, out DocumentState? state)) + return false; + + if (IsStaleVersion(state.SemanticTokensVersion, version)) + return false; + + if (version > 0) + state.SemanticTokensVersion = version; + + state.SemanticTokens = semanticTokens ?? []; + return true; + } + } + + /// + /// Returns the cached semantic-tokens delta state for , if any. + /// Used by the provider to send `semanticTokens/full/delta` requests with the previous result id. + /// + /// The normalized file path. + /// The cached delta state, if available. + public LuaSemanticTokensDeltaState GetSemanticTokensDeltaState(string filePath) + { + lock (_syncRoot) + { + if (!_documents.TryGetValue(filePath, out DocumentState? state)) + return new LuaSemanticTokensDeltaState(null, null); + + return new LuaSemanticTokensDeltaState(state.SemanticTokensResultId, state.SemanticTokensData); + } + } + + /// + /// Stores the raw `data` payload returned by `semanticTokens/full(/delta)` along with the + /// associated `resultId`, so subsequent requests can ask LuaLS for incremental edits. + /// + /// The normalized file path. + /// The server-provided semantic-token result id. + /// The cached integer token stream. + public void StoreSemanticTokensDeltaState(string filePath, string? resultId, int[]? data) + { + lock (_syncRoot) + { + if (!_documents.TryGetValue(filePath, out DocumentState? state)) + return; + + state.SemanticTokensResultId = resultId; + state.SemanticTokensData = data; + } + } + + /// + /// Marks the specified document as needing a fresh server-side open/sync before incremental updates can resume. + /// + /// The normalized file path. + /// when the document was found and invalidated; otherwise, . + public bool InvalidateServerSynchronization(string filePath) + { + lock (_syncRoot) + { + if (!_documents.TryGetValue(filePath, out DocumentState? state)) + return false; + + state.IsOpen = false; + state.SemanticTokensVersion = 0; + state.SemanticTokensResultId = null; + state.SemanticTokensData = null; + + return true; + } + } + + private static void ClearCachedState(DocumentState state) + { + state.Diagnostics = []; + state.DiagnosticsVersion = 0; + state.SemanticTokens = []; + state.SemanticTokensVersion = 0; + state.SemanticTokensData = null; + state.SemanticTokensResultId = null; + } + + private static bool IsStaleVersion(int currentVersion, int incomingVersion) + => incomingVersion > 0 && currentVersion > 0 && incomingVersion < currentVersion; + + private static LuaDocumentSnapshot CreateSnapshot(DocumentState state) + => new(state.FilePath, state.Uri, state.Content, state.Version); +} diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Documents/LuaTextDocumentSyncKind.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Documents/LuaTextDocumentSyncKind.cs new file mode 100644 index 0000000000..72ff25cabf --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Documents/LuaTextDocumentSyncKind.cs @@ -0,0 +1,22 @@ +namespace TombLib.Scripting.Lua.LanguageServer; + +/// +/// Describes the text-document synchronization mode negotiated with LuaLS. +/// +public enum LuaTextDocumentSyncKind +{ + /// + /// No document synchronization is supported. + /// + None = 0, + + /// + /// Each change sends the full document content. + /// + Full = 1, + + /// + /// Each change sends an incremental range edit. + /// + Incremental = 2 +} diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Formatting/LuaLanguageServerResponseParser.Formatting.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Formatting/LuaLanguageServerResponseParser.Formatting.cs new file mode 100644 index 0000000000..5a255dc9bd --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Formatting/LuaLanguageServerResponseParser.Formatting.cs @@ -0,0 +1,19 @@ +using TombLib.Scripting.Lua.Objects; + +namespace TombLib.Scripting.Lua.LanguageServer; + +public static partial class LuaLanguageServerResponseParser +{ + /// + /// Parses document-formatting edits from a LuaLS formatting response. + /// + public static IReadOnlyList ParseDocumentFormattingEdits(IReadOnlyList? response) + { + if (response is null) + return []; + + var textEdits = new List(); + AppendTextEdits(response, textEdits); + return textEdits; + } +} diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Hover/LuaHoverPayloads.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Hover/LuaHoverPayloads.cs new file mode 100644 index 0000000000..d3e62e0a95 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Hover/LuaHoverPayloads.cs @@ -0,0 +1,13 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace TombLib.Scripting.Lua.LanguageServer; + +/// +/// Represents the typed top-level hover payload returned by LuaLS. +/// +public sealed record LuaHoverResponse +{ + [JsonPropertyName("contents")] + public JsonElement Contents { get; init; } +} diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Hover/LuaLanguageServerResponseParser.Hover.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Hover/LuaLanguageServerResponseParser.Hover.cs new file mode 100644 index 0000000000..c24e7d3df6 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Hover/LuaLanguageServerResponseParser.Hover.cs @@ -0,0 +1,22 @@ +using System.Text.Json; +using TombLib.Scripting.Lua.Objects; + +namespace TombLib.Scripting.Lua.LanguageServer; + +public static partial class LuaLanguageServerResponseParser +{ + /// + /// Parses hover content from a LuaLS hover response. + /// + public static LuaHoverInfo? ParseHoverInfo(LuaHoverResponse? response) + { + if (response is null || response.Contents.ValueKind == JsonValueKind.Undefined) + return null; + + MarkupContent hoverContent = ExtractMarkupContent(response.Contents); + + return string.IsNullOrWhiteSpace(hoverContent.Text) + ? null + : new LuaHoverInfo(hoverContent.Text.Trim(), hoverContent.IsMarkdown); + } +} diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Infrastructure/Client/ILuaLanguageServerClient.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Infrastructure/Client/ILuaLanguageServerClient.cs new file mode 100644 index 0000000000..274b71c20c --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Infrastructure/Client/ILuaLanguageServerClient.cs @@ -0,0 +1,97 @@ +namespace TombLib.Scripting.Lua.LanguageServer; + +/// +/// Defines the transport and capability surface used by the Lua IntelliSense provider to talk to LuaLS. +/// +public interface ILuaLanguageServerClient : IDisposable +{ + /// + /// Gets a value indicating whether the language server finished initialization and can accept requests. + /// + bool IsReady { get; } + + /// + /// Gets the current transport generation for the active language-server session. + /// + long TransportGeneration { get; } + + /// + /// Gets the text-document synchronization mode negotiated with the language server. + /// + LuaTextDocumentSyncKind TextDocumentSyncKind { get; } + + /// + /// Gets the semantic token types reported by the server capabilities. + /// + IReadOnlyList SemanticTokenTypes { get; } + + /// + /// Gets the semantic token modifiers reported by the server capabilities. + /// + IReadOnlyList SemanticTokenModifiers { get; } + + /// + /// Gets a value indicating whether the server supports completionItem/resolve. + /// + bool SupportsCompletionResolve { get; } + + /// + /// Gets a value indicating whether the server supports textDocument/references. + /// + bool SupportsReferences { get; } + + /// + /// Gets a value indicating whether the server supports textDocument/rename. + /// + bool SupportsRename { get; } + + /// + /// Gets a value indicating whether the server supports textDocument/formatting. + /// + bool SupportsFormatting { get; } + + /// + /// Gets a value indicating whether the server supports semantic-token delta responses. + /// + bool SupportsSemanticTokensDelta { get; } + + /// + /// Occurs when the server publishes diagnostics for a tracked document. + /// + event Action? DiagnosticsPublished; + + /// + /// Occurs when the server requests a semantic-token refresh for open documents. + /// + event Action? SemanticTokensRefreshRequested; + + /// + /// Starts the language server process and completes the LSP initialization handshake. + /// + /// A token that can cancel startup. + /// when the client is ready; otherwise, . + Task StartAsync(CancellationToken cancellationToken); + + /// + /// Marks the current transport unhealthy so the next startup check restarts the server session. + /// + void MarkTransportUnhealthy(); + + /// + /// Sends a JSON-RPC notification to the language server. + /// + /// The LSP method name. + /// The notification payload. + /// A token that can cancel the send operation. + Task SendNotificationAsync(string method, object parameters, CancellationToken cancellationToken); + + /// + /// Sends a JSON-RPC request to the language server and waits for a typed response payload. + /// + /// The typed response payload to deserialize. + /// The LSP method name. + /// The request payload. + /// A token that can cancel the request. + /// The typed response payload. + Task SendRequestAsync(string method, object parameters, CancellationToken cancellationToken); +} diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Infrastructure/Client/LuaLanguageServerCallbackPayloads.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Infrastructure/Client/LuaLanguageServerCallbackPayloads.cs new file mode 100644 index 0000000000..daf308b4b7 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Infrastructure/Client/LuaLanguageServerCallbackPayloads.cs @@ -0,0 +1,46 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace TombLib.Scripting.Lua.LanguageServer; + +public readonly record struct LuaEmptyParams(); + +public readonly record struct LuaWorkspaceConfigurationParams( + [property: JsonPropertyName("items")] LuaWorkspaceConfigurationItem[]? Items); + +public readonly record struct LuaWorkspaceConfigurationItem( + [property: JsonPropertyName("section")] string? Section); + +public readonly record struct LuaWorkspaceFolder( + [property: JsonPropertyName("uri")] string Uri, + [property: JsonPropertyName("name")] string Name); + +public readonly record struct LuaWindowMessageParams( + [property: JsonPropertyName("type")] int? Type, + [property: JsonPropertyName("message")] string? Message); + +/// +/// Represents a typed diagnostics notification raised by LuaLS for a tracked document. +/// +public readonly record struct LuaPublishDiagnosticsParams( + [property: JsonPropertyName("uri")] string? Uri, + [property: JsonPropertyName("version")] int? Version, + [property: JsonPropertyName("diagnostics")] LuaDiagnosticPayload[]? Diagnostics); + +/// +/// Represents a single diagnostic entry from a publish-diagnostics notification. +/// +public readonly record struct LuaDiagnosticPayload( + [property: JsonPropertyName("range")] LuaProtocolRangePayload? Range, + [property: JsonPropertyName("severity")] int? Severity, + [property: JsonPropertyName("message")] string? Message, + [property: JsonPropertyName("source")] string? Source, + [property: JsonPropertyName("code")] JsonElement? Code); + +public readonly record struct LuaProtocolRangePayload( + [property: JsonPropertyName("start")] LuaProtocolNullablePosition? Start, + [property: JsonPropertyName("end")] LuaProtocolNullablePosition? End); + +public readonly record struct LuaProtocolNullablePosition( + [property: JsonPropertyName("line")] int? Line, + [property: JsonPropertyName("character")] int? Character); diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Infrastructure/Client/LuaLanguageServerCapabilityPayloads.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Infrastructure/Client/LuaLanguageServerCapabilityPayloads.cs new file mode 100644 index 0000000000..eaeb96141a --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Infrastructure/Client/LuaLanguageServerCapabilityPayloads.cs @@ -0,0 +1,195 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace TombLib.Scripting.Lua.LanguageServer; + +/// +/// Represents the typed result of the LSP initialize request. +/// +public sealed record LuaInitializeResponse +{ + [JsonPropertyName("capabilities")] + public LuaServerCapabilities? Capabilities { get; init; } +} + +/// +/// Represents the subset of LuaLS capabilities consumed by the host Lua IntelliSense provider. +/// +public sealed record LuaServerCapabilities +{ + [JsonPropertyName("textDocumentSync")] + public LuaTextDocumentSyncCapability? TextDocumentSync { get; init; } + + [JsonPropertyName("completionProvider")] + public LuaCompletionProviderCapability? CompletionProvider { get; init; } + + [JsonPropertyName("referencesProvider")] + public LuaSupportedCapability? ReferencesProvider { get; init; } + + [JsonPropertyName("renameProvider")] + public LuaSupportedCapability? RenameProvider { get; init; } + + [JsonPropertyName("documentFormattingProvider")] + public LuaSupportedCapability? DocumentFormattingProvider { get; init; } + + [JsonPropertyName("semanticTokensProvider")] + public LuaSemanticTokensProviderCapability? SemanticTokensProvider { get; init; } +} + +public sealed record LuaCompletionProviderCapability +{ + [JsonPropertyName("resolveProvider")] + public bool? ResolveProvider { get; init; } +} + +public sealed record LuaSemanticTokensProviderCapability +{ + [JsonPropertyName("full")] + public LuaSemanticTokensFullCapability? Full { get; init; } + + [JsonPropertyName("legend")] + public LuaSemanticTokensLegendCapability? Legend { get; init; } +} + +public sealed record LuaSemanticTokensLegendCapability +{ + [JsonPropertyName("tokenTypes")] + public string[]? TokenTypes { get; init; } + + [JsonPropertyName("tokenModifiers")] + public string[]? TokenModifiers { get; init; } +} + +[JsonConverter(typeof(LuaSupportedCapabilityJsonConverter))] +public readonly record struct LuaSupportedCapability(bool IsSupported); + +[JsonConverter(typeof(LuaTextDocumentSyncCapabilityJsonConverter))] +public readonly record struct LuaTextDocumentSyncCapability(LuaTextDocumentSyncKind Kind); + +[JsonConverter(typeof(LuaSemanticTokensFullCapabilityJsonConverter))] +public readonly record struct LuaSemanticTokensFullCapability(bool SupportsDelta); + +/// +/// Deserializes LSP capability fields that may be advertised as either booleans or objects. +/// +public sealed class LuaSupportedCapabilityJsonConverter : JsonConverter +{ + public override LuaSupportedCapability Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return reader.TokenType switch + { + JsonTokenType.True => new LuaSupportedCapability(true), + JsonTokenType.False => new LuaSupportedCapability(false), + JsonTokenType.StartObject => ReadObject(ref reader), + JsonTokenType.Null => default, + _ => ReadUnsupported(ref reader) + }; + } + + public override void Write(Utf8JsonWriter writer, LuaSupportedCapability value, JsonSerializerOptions options) + => throw new NotSupportedException(); + + private static LuaSupportedCapability ReadObject(ref Utf8JsonReader reader) + { + using JsonDocument ignored = JsonDocument.ParseValue(ref reader); + return new LuaSupportedCapability(true); + } + + private static LuaSupportedCapability ReadUnsupported(ref Utf8JsonReader reader) + { + using JsonDocument ignored = JsonDocument.ParseValue(ref reader); + return new LuaSupportedCapability(false); + } +} + +/// +/// Deserializes the LSP text-document sync capability from either numeric or object form. +/// +public sealed class LuaTextDocumentSyncCapabilityJsonConverter : JsonConverter +{ + public override LuaTextDocumentSyncCapability Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + switch (reader.TokenType) + { + case JsonTokenType.Number: + return reader.TryGetInt32(out int rawSyncKind) + ? new LuaTextDocumentSyncCapability(ParseTextDocumentSyncKind(rawSyncKind)) + : new LuaTextDocumentSyncCapability(LuaTextDocumentSyncKind.None); + + case JsonTokenType.StartObject: + return ReadObject(ref reader); + + case JsonTokenType.Null: + return default; + + default: + using (JsonDocument ignored = JsonDocument.ParseValue(ref reader)) + { } + + return new LuaTextDocumentSyncCapability(LuaTextDocumentSyncKind.None); + } + } + + public override void Write(Utf8JsonWriter writer, LuaTextDocumentSyncCapability value, JsonSerializerOptions options) + => throw new NotSupportedException(); + + private static LuaTextDocumentSyncCapability ReadObject(ref Utf8JsonReader reader) + { + using JsonDocument document = JsonDocument.ParseValue(ref reader); + JsonElement root = document.RootElement; + + if (!root.TryGetProperty("change", out JsonElement changeElement) + || !changeElement.TryGetInt32(out int rawSyncKind)) + { + return new LuaTextDocumentSyncCapability(LuaTextDocumentSyncKind.None); + } + + return new LuaTextDocumentSyncCapability(ParseTextDocumentSyncKind(rawSyncKind)); + } + + private static LuaTextDocumentSyncKind ParseTextDocumentSyncKind(int rawSyncKind) => rawSyncKind switch + { + 0 => LuaTextDocumentSyncKind.None, + 1 => LuaTextDocumentSyncKind.Full, + 2 => LuaTextDocumentSyncKind.Incremental, + _ => LuaTextDocumentSyncKind.None + }; +} + +/// +/// Deserializes the semantic-token full capability and whether delta refresh is supported. +/// +public sealed class LuaSemanticTokensFullCapabilityJsonConverter : JsonConverter +{ + public override LuaSemanticTokensFullCapability Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return reader.TokenType switch + { + JsonTokenType.True => new LuaSemanticTokensFullCapability(false), + JsonTokenType.False => new LuaSemanticTokensFullCapability(false), + JsonTokenType.StartObject => ReadObject(ref reader), + JsonTokenType.Null => default, + _ => ReadUnsupported(ref reader) + }; + } + + public override void Write(Utf8JsonWriter writer, LuaSemanticTokensFullCapability value, JsonSerializerOptions options) + => throw new NotSupportedException(); + + private static LuaSemanticTokensFullCapability ReadObject(ref Utf8JsonReader reader) + { + using JsonDocument document = JsonDocument.ParseValue(ref reader); + JsonElement root = document.RootElement; + + bool supportsDelta = root.TryGetProperty("delta", out JsonElement deltaElement) + && deltaElement.ValueKind == JsonValueKind.True; + + return new LuaSemanticTokensFullCapability(supportsDelta); + } + + private static LuaSemanticTokensFullCapability ReadUnsupported(ref Utf8JsonReader reader) + { + using JsonDocument ignored = JsonDocument.ParseValue(ref reader); + return new LuaSemanticTokensFullCapability(false); + } +} diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Infrastructure/Client/LuaLanguageServerClient.Diagnostics.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Infrastructure/Client/LuaLanguageServerClient.Diagnostics.cs new file mode 100644 index 0000000000..ee042eac58 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Infrastructure/Client/LuaLanguageServerClient.Diagnostics.cs @@ -0,0 +1,86 @@ +using System.Collections.Concurrent; +using System.Threading.Channels; + +namespace TombLib.Scripting.Lua.LanguageServer; + +public sealed partial class LuaLanguageServerClient +{ + private readonly record struct QueuedDiagnostics(long TransportGeneration, LuaPublishDiagnosticsParams Parameters); + + private readonly ConcurrentDictionary _pendingDiagnostics = new(StringComparer.OrdinalIgnoreCase); + + // Diagnostics arrive on the LSP read loop and are stored as the latest payload per file URI. + // A bounded single-slot channel acts only as a wake signal for the pump, so bursty notifications + // for the same file collapse to one queued wake-up instead of building an unbounded backlog. + private readonly Channel _diagnosticsSignal = Channel.CreateBounded( + new BoundedChannelOptions(1) + { + SingleReader = true, + SingleWriter = true, + AllowSynchronousContinuations = false, + FullMode = BoundedChannelFullMode.DropWrite + }); + + private Task? _diagnosticsPumpTask; + private long _diagnosticsFallbackSequence; + + /// + /// Occurs when the server publishes diagnostics for a tracked document. + /// + public event Action? DiagnosticsPublished; + + private void RaiseDiagnosticsPublished(long transportGeneration, LuaPublishDiagnosticsParams parameters) + { + // Stash only the newest diagnostics payload per file and wake the pump if it is idle. + // Parameters were already cloned by the dispatcher in HandleMessageAsync. + _pendingDiagnostics[GetDiagnosticsQueueKey(parameters)] = new QueuedDiagnostics(transportGeneration, parameters); + _diagnosticsSignal.Writer.TryWrite(true); + } + + private async Task PumpDiagnosticsAsync() + { + ChannelReader reader = _diagnosticsSignal.Reader; + + try + { + while (await reader.WaitToReadAsync(_lifetimeCts.Token).ConfigureAwait(false)) + { + while (reader.TryRead(out _)) + { } + + while (!_pendingDiagnostics.IsEmpty) + { + KeyValuePair[] pendingDiagnostics = [.. _pendingDiagnostics]; + + for (int i = 0; i < pendingDiagnostics.Length; i++) + { + if (!_pendingDiagnostics.TryRemove(pendingDiagnostics[i].Key, out QueuedDiagnostics queuedDiagnostics) + || queuedDiagnostics.TransportGeneration != Volatile.Read(ref _activeTransportGeneration)) + continue; + + try + { + DiagnosticsPublished?.Invoke(queuedDiagnostics.Parameters); + } + catch (Exception exception) + { + Log.Warn(exception, "Lua diagnostics handler threw; the diagnostics pump is being kept alive."); + } + } + } + } + } + catch (OperationCanceledException) + { + // Expected on dispose. + } + } + + private string GetDiagnosticsQueueKey(LuaPublishDiagnosticsParams parameters) + { + if (!string.IsNullOrWhiteSpace(parameters.Uri)) + return parameters.Uri; + + return "diagnostics:" + Interlocked.Increment(ref _diagnosticsFallbackSequence); + } +} diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Infrastructure/Client/LuaLanguageServerClient.Protocol.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Infrastructure/Client/LuaLanguageServerClient.Protocol.cs new file mode 100644 index 0000000000..91730853c5 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Infrastructure/Client/LuaLanguageServerClient.Protocol.cs @@ -0,0 +1,146 @@ +using StreamJsonRpc; +using System.Text.Json; + +namespace TombLib.Scripting.Lua.LanguageServer; + +public sealed partial class LuaLanguageServerClient +{ + private Task SendNotificationCoreAsync(LuaLanguageServerTransportSession session, string method, object parameters, CancellationToken cancellationToken, bool allowDisposed) + { + ThrowIfDisposed(allowDisposed); + + if (cancellationToken.IsCancellationRequested) + return Task.FromCanceled(cancellationToken); + + JsonRpc jsonRpc = session.JsonRpc + ?? throw new IOException("The Lua language server JSON-RPC transport is not available."); + + return jsonRpc.NotifyWithParameterObjectAsync(method, parameters); + } + + private Task SendRequestCoreAsync(LuaLanguageServerTransportSession session, string method, object parameters, CancellationToken cancellationToken, bool allowDisposed) + { + ThrowIfDisposed(allowDisposed); + + JsonRpc jsonRpc = session.JsonRpc + ?? throw new IOException("The Lua language server JSON-RPC transport is not available."); + + return jsonRpc.InvokeWithParameterObjectAsync(method, parameters, cancellationToken); + } + + private object[] BuildConfigurationResponse(LuaWorkspaceConfigurationParams parameters) + { + LuaWorkspaceConfigurationItem[] items = parameters.Items ?? []; + + if (items.Length == 0) + return []; + + JsonElement settingsElement = JsonSerializer.SerializeToElement(_settingsProvider()); + JsonElement luaElement = settingsElement.GetProperty("Lua"); + + var results = new List(); + + foreach (LuaWorkspaceConfigurationItem item in items) + results.Add(GetConfigurationSection(settingsElement, luaElement, item.Section)); + + return [.. results]; + } + + private static object GetConfigurationSection(JsonElement settingsElement, JsonElement luaElement, string? section) + { + if (string.IsNullOrWhiteSpace(section)) + return settingsElement.Clone(); + + if (section.Equals("Lua", StringComparison.OrdinalIgnoreCase)) + return luaElement.Clone(); + + if (section.StartsWith("Lua.", StringComparison.OrdinalIgnoreCase)) + { + JsonElement nestedSection = luaElement; + string[] parts = section[4..].Split('.'); + + foreach (string part in parts) + { + if (!nestedSection.TryGetProperty(part, out JsonElement nextSection)) + return new { }; + + nestedSection = nextSection; + } + + return nestedSection.Clone(); + } + + return new { }; + } + + private sealed class LuaLanguageServerClientRpcTarget + { + private readonly LuaLanguageServerClient _owner; + private readonly long _transportGeneration; + + public LuaLanguageServerClientRpcTarget(LuaLanguageServerClient owner, long transportGeneration) + { + _owner = owner; + _transportGeneration = transportGeneration; + } + + [JsonRpcMethod("workspace/configuration", UseSingleObjectParameterDeserialization = true)] + public object[] WorkspaceConfiguration(LuaWorkspaceConfigurationParams parameters) + => _owner.BuildConfigurationResponse(parameters); + + [JsonRpcMethod("workspace/workspaceFolders")] + public LuaWorkspaceFolder[] WorkspaceFolders() => + [ + new LuaWorkspaceFolder( + LuaLanguageServerPathHelper.CreateFileUri(_owner._workspaceRootDirectoryPath), + Path.GetFileName(_owner._workspaceRootDirectoryPath)) + ]; + + [JsonRpcMethod("workspace/semanticTokens/refresh")] + public Task RefreshSemanticTokensAsync() + { + try + { + _owner.SemanticTokensRefreshRequested?.Invoke(); + } + catch (Exception exception) + { + Log.Warn(exception, "Lua semantic-tokens refresh request handler threw; acknowledging the request anyway."); + } + + return Task.FromResult(null); + } + + [JsonRpcMethod("client/registerCapability", UseSingleObjectParameterDeserialization = true)] + public object? RegisterCapability(LuaEmptyParams parameters) + => null; + + [JsonRpcMethod("client/unregisterCapability", UseSingleObjectParameterDeserialization = true)] + public object? UnregisterCapability(LuaEmptyParams parameters) + => null; + + [JsonRpcMethod("window/workDoneProgress/create", UseSingleObjectParameterDeserialization = true)] + public object? CreateWorkDoneProgress(LuaEmptyParams parameters) + => null; + + [JsonRpcMethod("textDocument/publishDiagnostics", UseSingleObjectParameterDeserialization = true)] + public void PublishDiagnostics(LuaPublishDiagnosticsParams parameters) + => _owner.RaiseDiagnosticsPublished(_transportGeneration, parameters); + + [JsonRpcMethod("window/logMessage", UseSingleObjectParameterDeserialization = true)] + public void LogMessage(LuaWindowMessageParams parameters) + => LogServerMessage("window/logMessage", parameters); + + [JsonRpcMethod("window/showMessage", UseSingleObjectParameterDeserialization = true)] + public void ShowMessage(LuaWindowMessageParams parameters) + => LogServerMessage("window/showMessage", parameters); + + [JsonRpcMethod("telemetry/event", UseSingleObjectParameterDeserialization = true)] + public void TelemetryEvent(LuaEmptyParams parameters) + { } + + [JsonRpcMethod("$/progress", UseSingleObjectParameterDeserialization = true)] + public void Progress(LuaEmptyParams parameters) + { } + } +} diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Infrastructure/Client/LuaLanguageServerClient.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Infrastructure/Client/LuaLanguageServerClient.cs new file mode 100644 index 0000000000..d304cc9ec8 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Infrastructure/Client/LuaLanguageServerClient.cs @@ -0,0 +1,785 @@ +using NLog; +using StreamJsonRpc; +using System.Diagnostics; +using System.Text.Json; + +namespace TombLib.Scripting.Lua.LanguageServer; + +/// +/// Hosts the LuaLS process, performs the LSP handshake, and transports JSON-RPC requests and notifications. +/// +public sealed partial class LuaLanguageServerClient : ILuaLanguageServerClient +{ + private static readonly Logger Log = LogManager.GetCurrentClassLogger(); + + private static readonly TimeSpan DisposeWaitTimeout = TimeSpan.FromSeconds(3); + + private readonly string _workspaceRootDirectoryPath; + private readonly string _serverExecutablePath; + private readonly Func _settingsProvider; + + private readonly SemaphoreSlim _startLock = new(1, 1); + private readonly CancellationTokenSource _lifetimeCts = new(); + + private static readonly string[] SupportedSemanticTokenTypes = + [ + "namespace", "type", "class", "enum", "interface", "struct", "typeParameter", + "parameter", "variable", "property", "enumMember", "event", "function", "method", + "macro", "keyword", "modifier", "comment", "string", "number", "regexp", + "operator", "decorator" + ]; + + private static readonly string[] SupportedSemanticTokenModifiers = + [ + "declaration", "definition", "readonly", "static", "deprecated", "abstract", + "async", "modification", "documentation", "defaultLibrary", "global" + ]; + + private long _transportGeneration; + private long _activeTransportGeneration; + private volatile bool _isDisposed; + private volatile bool _isReady; + + private LuaLanguageServerTransportSession? _activeSession; + private LuaTextDocumentSyncKind _textDocumentSyncKind = LuaTextDocumentSyncKind.Incremental; + private string[] _semanticTokenTypes = []; + private string[] _semanticTokenModifiers = []; + private bool _supportsCompletionResolve; + private bool? _supportsReferences; + private bool? _supportsRename; + private bool? _supportsFormatting; + private bool _supportsSemanticTokensDelta; + + /// + /// Gets a value indicating whether the client finished initialization and can accept requests. + /// + public bool IsReady + { + get => _isReady; + private set => _isReady = value; + } + + /// + /// Gets the current transport generation for the active language-server session. + /// + public long TransportGeneration => Volatile.Read(ref _activeTransportGeneration); + + /// + /// Gets the text-document synchronization mode negotiated with the server. + /// + public LuaTextDocumentSyncKind TextDocumentSyncKind => _textDocumentSyncKind; + + /// + /// Gets the semantic token types advertised by the server. + /// + public IReadOnlyList SemanticTokenTypes => _semanticTokenTypes; + + /// + /// Gets the semantic token modifiers advertised by the server. + /// + public IReadOnlyList SemanticTokenModifiers => _semanticTokenModifiers; + + /// + /// Gets a value indicating whether the server supports completion-item resolve requests. + /// + public bool SupportsCompletionResolve => _supportsCompletionResolve; + + /// + /// Gets a value indicating whether the server supports reference requests. + /// + public bool SupportsReferences => _supportsReferences ?? true; + + /// + /// Gets a value indicating whether the server supports rename requests. + /// + public bool SupportsRename => _supportsRename ?? true; + + /// + /// Gets a value indicating whether the server supports document formatting requests. + /// + public bool SupportsFormatting => _supportsFormatting ?? true; + + /// + /// Gets a value indicating whether the server supports semantic-token delta responses. + /// + public bool SupportsSemanticTokensDelta => _supportsSemanticTokensDelta; + + /// + /// Occurs when the server requests that semantic tokens be refreshed. + /// + public event Action? SemanticTokensRefreshRequested; + + /// + /// Initializes a new instance of the class. + /// + /// The normalized workspace root directory. + /// The LuaLS executable path. + /// Produces the current settings payload for workspace/didChangeConfiguration. + public LuaLanguageServerClient(string workspaceRootDirectoryPath, string serverExecutablePath, Func settingsProvider) + { + _workspaceRootDirectoryPath = workspaceRootDirectoryPath; + _serverExecutablePath = serverExecutablePath; + _settingsProvider = settingsProvider; + } + + /// + /// Starts the LuaLS process and completes the initialize/initialized handshake. + /// + /// A token that can cancel startup. + /// when startup succeeded; otherwise, . + public async Task StartAsync(CancellationToken cancellationToken) + { + if (IsReady) + return true; + + await _startLock.WaitAsync(cancellationToken).ConfigureAwait(false); + + try + { + if (IsReady) + return true; + + ThrowIfDisposed(allowDisposed: false); + + LuaLanguageServerTransportSession? previousSession = DetachActiveSession(); + + if (previousSession is not null) + await DisposeSessionAsync(previousSession).ConfigureAwait(false); + + var startInfo = new ProcessStartInfo + { + FileName = _serverExecutablePath, + WorkingDirectory = Path.GetDirectoryName(_serverExecutablePath) ?? Environment.CurrentDirectory, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true }; + + if (!process.Start()) + { + process.Dispose(); + return false; + } + + if (OperatingSystem.IsWindows()) + LuaProcessJobObject.TryAssignProcess(process); + + LuaLanguageServerTransportSession session = CreateTransportSession(process); + SetActiveSession(session); + + _diagnosticsPumpTask ??= Task.Run(PumpDiagnosticsAsync, CancellationToken.None); + + // Complete the LSP handshake before marking the client ready for provider requests. + using var initializeTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + initializeTimeout.CancelAfter(TimeSpan.FromSeconds(10)); + + LuaInitializeResponse initializeResponse = await SendRequestCoreAsync(session, + "initialize", BuildInitializeParams(), initializeTimeout.Token, allowDisposed: false).ConfigureAwait(false); + + CaptureServerCapabilities(initializeResponse); + + IsReady = true; + + await SendNotificationCoreAsync(session, "initialized", new LuaEmptyParams(), cancellationToken, allowDisposed: false).ConfigureAwait(false); + + await SendNotificationCoreAsync(session, + "workspace/didChangeConfiguration", + new LuaDidChangeConfigurationParams(_settingsProvider()), + cancellationToken, + allowDisposed: false).ConfigureAwait(false); + + return true; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Caller-driven cancellation should tear down the half-started process so later retries begin cleanly. + await DisposeActiveSessionAsync().ConfigureAwait(false); + throw; + } + catch (Exception exception) + { + Log.Warn(exception, "Failed to start the Lua language server (executable='{Executable}', workspace='{Workspace}').", + _serverExecutablePath, _workspaceRootDirectoryPath); + + await DisposeActiveSessionAsync().ConfigureAwait(false); + return false; + } + finally + { + _startLock.Release(); + } + } + + /// + /// Sends a JSON-RPC notification to the language server. + /// + /// The LSP method name. + /// The notification payload. + /// A token that can cancel the send operation. + public Task SendNotificationAsync(string method, object parameters, CancellationToken cancellationToken) + => SendNotificationCoreAsync(GetRequiredActiveSession(allowDisposed: false), method, parameters, cancellationToken, allowDisposed: false); + + /// + /// Sends a JSON-RPC request to the language server and returns the typed response payload. + /// + /// The typed response payload to deserialize. + /// The LSP method name. + /// The request payload. + /// A token that can cancel the request. + /// The typed response payload. + public Task SendRequestAsync(string method, object parameters, CancellationToken cancellationToken) + => SendRequestCoreAsync(GetRequiredActiveSession(allowDisposed: false), method, parameters, cancellationToken, allowDisposed: false); + + /// + /// Marks the current transport unhealthy so the provider restarts it on the next request. + /// + public void MarkTransportUnhealthy() + { + if (_isDisposed) + return; + + IsReady = false; + } + + private object BuildInitializeParams() => new + { + processId = Environment.ProcessId, + initializationOptions = new + { + changeConfiguration = true, + viewDocument = true, + // Do NOT set trustByClient = true: LuaLS uses that flag to skip the user prompt before + // loading workspace-supplied plugins (runtime.plugin in .luarc.json). The host editor has no + // equivalent workspace-trust gate, so leaving the prompt enabled keeps malicious or + // accidental third-party Lua scripts from being executed silently inside the host process. + trustByClient = false, + useSemanticByRange = false + }, + rootUri = LuaLanguageServerPathHelper.CreateFileUri(_workspaceRootDirectoryPath), + workspaceFolders = new[] + { + new + { + uri = LuaLanguageServerPathHelper.CreateFileUri(_workspaceRootDirectoryPath), + name = Path.GetFileName(_workspaceRootDirectoryPath) + } + }, + capabilities = new + { + workspace = new + { + workspaceFolders = true, + configuration = true, + didChangeWatchedFiles = new { dynamicRegistration = false } + }, + textDocument = new + { + completion = new + { + contextSupport = true, + completionItem = new + { + snippetSupport = false, + // Prefer markdown so the editor's MdXaml renderer can show formatted documentation. + documentationFormat = new[] { "markdown", "plaintext" }, + resolveSupport = new + { + properties = new[] { "detail", "documentation" } + } + } + }, + hover = new + { + contentFormat = new[] { "markdown", "plaintext" } + }, + definition = new + { + linkSupport = true + }, + references = new + { + dynamicRegistration = false + }, + rename = new + { + dynamicRegistration = false, + prepareSupport = false + }, + formatting = new + { + dynamicRegistration = false + }, + publishDiagnostics = new + { + versionSupport = true + }, + signatureHelp = new + { + signatureInformation = new + { + documentationFormat = new[] { "markdown", "plaintext" }, + parameterInformation = new + { + labelOffsetSupport = true + } + }, + contextSupport = true + }, + semanticTokens = new + { + requests = new + { + range = false, + full = new { delta = true } + }, + tokenTypes = SupportedSemanticTokenTypes, + tokenModifiers = SupportedSemanticTokenModifiers, + formats = new[] { "relative" }, + multilineTokenSupport = false, + overlappingTokenSupport = false, + augmentsSyntaxTokens = true + } + } + } + }; + + private void CaptureServerCapabilities(LuaInitializeResponse initializeResponse) + { + _supportsCompletionResolve = false; + _supportsReferences = false; + _supportsRename = false; + _supportsFormatting = false; + _supportsSemanticTokensDelta = false; + _textDocumentSyncKind = LuaTextDocumentSyncKind.Incremental; + _semanticTokenTypes = []; + _semanticTokenModifiers = []; + + if (initializeResponse.Capabilities is not { } capabilities) + return; + + if (capabilities.TextDocumentSync is { } textDocumentSync) + { + if (textDocumentSync.Kind == LuaTextDocumentSyncKind.None) + { + throw new NotSupportedException( + "The Lua language server does not advertise full or incremental text synchronization required by the Lua IntelliSense provider."); + } + + _textDocumentSyncKind = textDocumentSync.Kind; + } + + _supportsCompletionResolve = capabilities.CompletionProvider?.ResolveProvider == true; + _supportsReferences = capabilities.ReferencesProvider?.IsSupported == true; + _supportsRename = capabilities.RenameProvider?.IsSupported == true; + _supportsFormatting = capabilities.DocumentFormattingProvider?.IsSupported == true; + + if (capabilities.SemanticTokensProvider is not { } semanticTokensProvider) + return; + + _supportsSemanticTokensDelta = semanticTokensProvider.Full?.SupportsDelta == true; + + if (semanticTokensProvider.Legend is not { } legend) + return; + + _semanticTokenTypes = legend.TokenTypes ?? []; + _semanticTokenModifiers = legend.TokenModifiers ?? []; + } + + private LuaLanguageServerTransportSession CreateTransportSession(Process process) + { + long generation = Interlocked.Increment(ref _transportGeneration); + + var session = new LuaLanguageServerTransportSession(generation, + process, + process.StandardOutput.BaseStream, + process.StandardInput.BaseStream); + + session.MessageHandler = CreateMessageHandler(session.OutputStream, session.InputStream); + session.RpcTarget = new LuaLanguageServerClientRpcTarget(this, generation); + session.JsonRpc = CreateJsonRpc(session); + session.RpcCompletionTask = session.JsonRpc.Completion; + + session.ProcessExitedHandler = (_, _) => Process_Exited(session); + process.Exited += session.ProcessExitedHandler; + session.StderrLoopTask = Task.Run(() => ReadStandardErrorLoopAsync(session), CancellationToken.None); + session.JsonRpc.StartListening(); + + return session; + } + + private static HeaderDelimitedMessageHandler CreateMessageHandler(Stream outputStream, Stream inputStream) + { + var formatter = new SystemTextJsonFormatter + { + JsonSerializerOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + } + }; + + return new HeaderDelimitedMessageHandler(outputStream, inputStream, formatter); + } + + private JsonRpc CreateJsonRpc(LuaLanguageServerTransportSession session) + { + HeaderDelimitedMessageHandler messageHandler = session.MessageHandler + ?? throw new InvalidOperationException("The Lua language server transport session is missing a JSON-RPC message handler."); + + LuaLanguageServerClientRpcTarget rpcTarget = session.RpcTarget + ?? throw new InvalidOperationException("The Lua language server transport session is missing a JSON-RPC callback target."); + + var jsonRpc = new JsonRpc(messageHandler, rpcTarget) + { + CancelLocallyInvokedMethodsWhenConnectionIsClosed = true + }; + + jsonRpc.Disconnected += (_, eventArgs) => JsonRpc_Disconnected(session, eventArgs); + return jsonRpc; + } + + private void SetActiveSession(LuaLanguageServerTransportSession session) + { + _activeSession = session; + Volatile.Write(ref _activeTransportGeneration, session.Generation); + } + + private LuaLanguageServerTransportSession GetRequiredActiveSession(bool allowDisposed) + { + ThrowIfDisposed(allowDisposed); + + LuaLanguageServerTransportSession? session = Volatile.Read(ref _activeSession); + + if (session is null) + throw new IOException("The Lua language server transport is not available."); + + return session; + } + + private LuaLanguageServerTransportSession? DetachActiveSession() + { + LuaLanguageServerTransportSession? session = Interlocked.Exchange(ref _activeSession, null); + Volatile.Write(ref _activeTransportGeneration, 0); + + IsReady = false; + return session; + } + + private async Task DisposeActiveSessionAsync() + { + LuaLanguageServerTransportSession? session = DetachActiveSession(); + + if (session is not null) + await DisposeSessionAsync(session).ConfigureAwait(false); + } + + private void Process_Exited(LuaLanguageServerTransportSession session) + { + bool isCurrentSession = IsCurrentSession(session); + + if (isCurrentSession) + IsReady = false; + + int? exitCode = TryReadProcessExitCode(session.Process); + + if (!_isDisposed && isCurrentSession) + Log.Warn("Lua language server process exited unexpectedly{ExitCodeSuffix}.", exitCode is not null ? $" with code {exitCode.Value}" : string.Empty); + } + + private bool IsCurrentSession(LuaLanguageServerTransportSession session) + => ReferenceEquals(Volatile.Read(ref _activeSession), session); + + private void JsonRpc_Disconnected(LuaLanguageServerTransportSession session, JsonRpcDisconnectedEventArgs eventArgs) + { + if (!IsCurrentSession(session)) + return; + + IsReady = false; + + if (_isDisposed) + return; + + Exception? exception = eventArgs.Exception; + + if (exception is not null) + { + Log.Warn(exception, "Lua language server JSON-RPC transport disconnected: {Description}", eventArgs.Description); + return; + } + + Log.Warn("Lua language server JSON-RPC transport disconnected: {Description}", eventArgs.Description); + } + + private async Task ReadStandardErrorLoopAsync(LuaLanguageServerTransportSession session) + { + try + { + while (!_isDisposed) + { + Process? process = session.Process; + + if (process is null || process.HasExited) + break; + + string? line = await process.StandardError.ReadLineAsync(_lifetimeCts.Token).ConfigureAwait(false); + + if (line is null) + break; + + if (!string.IsNullOrWhiteSpace(line)) + Log.Debug("[LuaLS stderr] {Line}", line); + } + } + catch (OperationCanceledException) + { } + catch + { + // Ignore stderr read failures. + } + } + + private static void LogServerMessage(string method, LuaWindowMessageParams parameters) + { + string? messageText = parameters.Message; + + if (string.IsNullOrWhiteSpace(messageText)) + return; + + int messageType = parameters.Type ?? 4; + + switch (messageType) + { + case 1: Log.Error("[LuaLS {Method}] {Message}", method, messageText); break; + case 2: Log.Warn("[LuaLS {Method}] {Message}", method, messageText); break; + case 3: Log.Info("[LuaLS {Method}] {Message}", method, messageText); break; + default: Log.Debug("[LuaLS {Method}] {Message}", method, messageText); break; + } + } + + private static int? TryReadProcessExitCode(Process? process) + { + if (process is null) + return null; + + try + { + return process.HasExited ? process.ExitCode : null; + } + catch (InvalidOperationException) + { + // Includes ObjectDisposedException; the process state is no longer accessible. + return null; + } + } + + private async Task DisposeSessionAsync(LuaLanguageServerTransportSession session) + { + Task? rpcCompletionTask = session.RpcCompletionTask; + Task? stderrLoopTask = session.StderrLoopTask; + + try + { + if (session.Process is not null && session.ProcessExitedHandler is not null) + session.Process.Exited -= session.ProcessExitedHandler; + } + catch + { + // Ignore event detach failures. + } + + try + { + if (session.Process is not null && !session.Process.HasExited) + { + await TrySendShutdownAsync(session).ConfigureAwait(false); + await TrySendExitNotificationAsync(session).ConfigureAwait(false); + + if (!session.Process.HasExited) + session.Process.Kill(true); + } + } + catch + { + // Ignore process disposal failures. + } + finally + { + try + { + session.JsonRpc?.Dispose(); + } + catch + { + // Ignore JSON-RPC disposal failures. + } + + try + { + if (session.MessageHandler is not null) + await session.MessageHandler.DisposeAsync().ConfigureAwait(false); + } + catch + { + // Ignore message-handler disposal failures. + } + + CleanupSessionResources(session); + } + + await WaitForBackgroundLoopsAsync(rpcCompletionTask, stderrLoopTask).ConfigureAwait(false); + } + + private static void CleanupSessionResources(LuaLanguageServerTransportSession session) + { + try + { + session.InputStream.Dispose(); + session.OutputStream.Dispose(); + session.Process?.Dispose(); + } + catch + { + // Ignore stream disposal failures. + } + + session.RpcCompletionTask = null; + session.StderrLoopTask = null; + session.JsonRpc = null; + session.MessageHandler = null; + session.RpcTarget = null; + } + + private async Task TrySendShutdownAsync(LuaLanguageServerTransportSession session) + { + try + { + Task shutdownTask = SendRequestCoreAsync(session, "shutdown", new LuaEmptyParams(), CancellationToken.None, allowDisposed: true); + await shutdownTask.WaitAsync(TimeSpan.FromSeconds(1)).ConfigureAwait(false); + } + catch + { + // Ignore shutdown failures. + } + } + + private async Task TrySendExitNotificationAsync(LuaLanguageServerTransportSession session) + { + using var exitTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + + try + { + await SendNotificationCoreAsync(session, "exit", new { }, exitTimeout.Token, allowDisposed: true).ConfigureAwait(false); + } + catch + { + // Ignore exit notification failures. + } + } + + private static async Task WaitForBackgroundLoopsAsync(Task? readLoopTask, Task? stderrLoopTask) + { + Task combined = Task.WhenAll( + readLoopTask ?? Task.CompletedTask, + stderrLoopTask ?? Task.CompletedTask); + + try + { + await combined.WaitAsync(DisposeWaitTimeout).ConfigureAwait(false); + } + catch (TimeoutException) + { + Log.Warn("Lua language server background loops did not complete within the dispose timeout."); + } + catch + { + // Ignore loop completion failures during disposal. + } + } + + private void ThrowIfDisposed(bool allowDisposed) + { + if (!allowDisposed) + ObjectDisposedException.ThrowIf(_isDisposed, nameof(LuaLanguageServerClient)); + } + + /// + /// Stops the language-server process, completes pending requests, and releases transport resources. + /// + public void Dispose() + { + if (_isDisposed) + return; + + _isDisposed = true; + _diagnosticsSignal.Writer.TryComplete(); + + try + { + _lifetimeCts.Cancel(); + } + catch (ObjectDisposedException) + { } + + try + { + // DisposeProcessAsync also waits for the read/stderr loops, so by the time it returns no + // background task should still be holding the write semaphore. We then release the locks. + if (!DisposeActiveSessionAsync().Wait(DisposeWaitTimeout)) + Log.Warn("Disposing Lua language server timed out; abandoning background tasks."); + } + catch (AggregateException exception) + { + Log.Warn(exception.Flatten(), "Disposing Lua language server raised exceptions."); + } + + if (_diagnosticsPumpTask is not null) + { + try + { + if (!_diagnosticsPumpTask.Wait(DisposeWaitTimeout)) + Log.Warn("Lua language server diagnostics pump did not complete within the dispose timeout."); + } + catch (AggregateException exception) + { + Log.Warn(exception.Flatten(), "Disposing the Lua language server diagnostics pump raised exceptions."); + } + } + + _lifetimeCts.Dispose(); + _startLock.Dispose(); + } + + private sealed class LuaLanguageServerTransportSession + { + public LuaLanguageServerTransportSession(long generation, Process? process, Stream inputStream, Stream outputStream) + { + Generation = generation; + Process = process; + InputStream = inputStream; + OutputStream = outputStream; + } + + public long Generation { get; } + + public Process? Process { get; } + + public Stream InputStream { get; } + + public Stream OutputStream { get; } + + public HeaderDelimitedMessageHandler? MessageHandler { get; set; } + + public JsonRpc? JsonRpc { get; set; } + + public LuaLanguageServerClientRpcTarget? RpcTarget { get; set; } + + public EventHandler? ProcessExitedHandler { get; set; } + + public Task? RpcCompletionTask { get; set; } + + public Task? StderrLoopTask { get; set; } + } +} diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Infrastructure/Pathing/LuaLanguageServerPathHelper.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Infrastructure/Pathing/LuaLanguageServerPathHelper.cs new file mode 100644 index 0000000000..a1d68cd9bd --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Infrastructure/Pathing/LuaLanguageServerPathHelper.cs @@ -0,0 +1,103 @@ +namespace TombLib.Scripting.Lua.LanguageServer; + +/// +/// Normalizes local paths and file URIs so LuaLS and the host editor use a consistent document identity. +/// +public static class LuaLanguageServerPathHelper +{ + /// + /// Converts a local file path into a normalized file URI for LuaLS requests. + /// + /// The local file path to convert. + /// The absolute file URI. + public static string CreateFileUri(string filePath) + => new Uri(NormalizeLocalPath(filePath)).AbsoluteUri; + + /// + /// Normalizes a local path into the absolute form used by the language server. + /// + /// The path to normalize. + /// The normalized absolute path. + public static string NormalizeLocalPath(string filePath) + { + if (string.IsNullOrWhiteSpace(filePath)) + throw new ArgumentException("File path must not be empty.", nameof(filePath)); + + string sanitizedFilePath = filePath.Replace('/', Path.DirectorySeparatorChar); + return Path.GetFullPath(sanitizedFilePath); + } + + /// + /// Normalizes a file URI into the absolute local-path form used by the language server client. + /// + /// The file URI to normalize. + /// The normalized absolute local path. + public static string NormalizeLocalPath(Uri uri) + { + string localPath = uri.LocalPath; + + // On Windows, Uri.LocalPath may produce "/C:/..." which needs the leading slash trimmed. + if (Path.DirectorySeparatorChar == '\\' + && localPath.Length >= 3 + && localPath[0] == '/' + && char.IsLetter(localPath[1]) + && localPath[2] == ':') + { + localPath = localPath[1..]; + } + + return NormalizeLocalPath(localPath); + } + + /// + /// Attempts to normalize a local path without throwing for invalid input. + /// + /// The path to normalize. + /// The normalized absolute path when successful. + /// when normalization succeeded; otherwise, . + public static bool TryNormalizeLocalPath(string filePath, out string normalizedFilePath) + { + normalizedFilePath = string.Empty; + + if (string.IsNullOrWhiteSpace(filePath)) + return false; + + try + { + normalizedFilePath = NormalizeLocalPath(filePath); + return true; + } + catch + { + return false; + } + } + + /// + /// Attempts to extract and normalize a local file path from a file URI. + /// + /// The file URI text. + /// The normalized local file path when successful. + /// when a local file path was resolved; otherwise, . + public static bool TryGetFilePath(string? uriText, out string filePath) + { + filePath = string.Empty; + + if (string.IsNullOrWhiteSpace(uriText) + || !Uri.TryCreate(uriText, UriKind.Absolute, out Uri? uri) + || uri?.IsFile != true) + { + return false; + } + + try + { + filePath = NormalizeLocalPath(uri); + return true; + } + catch + { + return false; + } + } +} diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Infrastructure/Pathing/LuaLanguageServerSettingsFactory.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Infrastructure/Pathing/LuaLanguageServerSettingsFactory.cs new file mode 100644 index 0000000000..d86f1e7fca --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Infrastructure/Pathing/LuaLanguageServerSettingsFactory.cs @@ -0,0 +1,46 @@ +namespace TombLib.Scripting.Lua.LanguageServer; + +public static class LuaLanguageServerSettingsFactory +{ + /// + /// Builds the Lua language server settings payload for the active script workspace. + /// + /// The root directory of the current Lua script workspace. + /// An anonymous settings object serialized into the LuaLS configuration request. + public static object Create(string workspaceRootDirectoryPath) + { + string apiDirectory = Path.Combine(workspaceRootDirectoryPath, ".API"); + string[] library = Directory.Exists(apiDirectory) ? [apiDirectory] : []; + + return new + { + Lua = new + { + runtime = new + { + version = "Lua 5.4" + }, + workspace = new + { + checkThirdParty = "Disable", + library + }, + completion = new + { + callSnippet = "Disable" + }, + semantic = new + { + enable = true, + annotation = true, + variable = true, + keyword = false + }, + diagnostics = new + { + disable = new[] { "duplicate-set-field" } + } + } + }; + } +} diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Infrastructure/Startup/LuaLanguageServerStartupFailure.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Infrastructure/Startup/LuaLanguageServerStartupFailure.cs new file mode 100644 index 0000000000..680c621933 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Infrastructure/Startup/LuaLanguageServerStartupFailure.cs @@ -0,0 +1,8 @@ +namespace TombLib.Scripting.Lua.LanguageServer; + +/// +/// Describes a Lua language server startup failure that should be surfaced to the UI. +/// +/// The user-facing failure message. +/// Whether IntelliSense is disabled until the host application restarts. +public readonly record struct LuaLanguageServerStartupFailure(string Message, bool IsPersistent); diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Infrastructure/Startup/LuaProcessJobObject.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Infrastructure/Startup/LuaProcessJobObject.cs new file mode 100644 index 0000000000..8c9ac75ab6 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Infrastructure/Startup/LuaProcessJobObject.cs @@ -0,0 +1,169 @@ +using NLog; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace TombLib.Scripting.Lua.LanguageServer; + +/// +/// Wraps a Windows job object configured with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE so that any +/// child processes assigned to it are forcibly terminated when the host application crashes or the +/// last handle to the job is released. This prevents stranded lua-language-server.exe +/// processes if the host never gets a chance to run its disposal path. +/// +[SupportedOSPlatform("windows")] +internal static class LuaProcessJobObject +{ + private static readonly Logger Log = LogManager.GetCurrentClassLogger(); + private static readonly object SyncRoot = new(); + private static IntPtr _jobHandle = IntPtr.Zero; + private static bool _initializationFailed; + + /// + /// Attempts to assign the supplied process to the shared kill-on-close Windows job object. + /// + /// The process to attach. + public static void TryAssignProcess(Process process) + { + if (process is null) + return; + + if (!OperatingSystem.IsWindows()) + return; + + IntPtr jobHandle = EnsureJobHandle(); + + if (jobHandle == IntPtr.Zero) + return; + + try + { + if (!AssignProcessToJobObject(jobHandle, process.Handle)) + { + int errorCode = Marshal.GetLastWin32Error(); + + // 5 = ERROR_ACCESS_DENIED. Pre-Windows 8 systems or processes already inside an + // unbreakable job will return this; we just log and move on. + Log.Debug("AssignProcessToJobObject failed with Win32 error {ErrorCode} for the Lua language server process.", errorCode); + } + } + catch (Exception exception) + { + Log.Debug(exception, "Failed to assign the Lua language server process to the kill-on-close job object."); + } + } + + private static IntPtr EnsureJobHandle() + { + if (_jobHandle != IntPtr.Zero) + return _jobHandle; + + if (_initializationFailed) + return IntPtr.Zero; + + lock (SyncRoot) + { + if (_jobHandle != IntPtr.Zero) + return _jobHandle; + + if (_initializationFailed) + return IntPtr.Zero; + + IntPtr handle = CreateJobObject(IntPtr.Zero, lpName: null); + + if (handle == IntPtr.Zero) + { + _initializationFailed = true; + Log.Debug("CreateJobObject returned NULL (Win32 error {ErrorCode}); the Lua language server will rely on graceful shutdown.", Marshal.GetLastWin32Error()); + return IntPtr.Zero; + } + + JobObjectExtendedLimitInformation extendedLimit = default; + extendedLimit.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + + int payloadSize = Marshal.SizeOf(); + IntPtr payloadPointer = Marshal.AllocHGlobal(payloadSize); + + try + { + Marshal.StructureToPtr(extendedLimit, payloadPointer, fDeleteOld: false); + + if (!SetInformationJobObject(handle, JobObjectInformationClass.ExtendedLimitInformation, payloadPointer, (uint)payloadSize)) + { + int errorCode = Marshal.GetLastWin32Error(); + + CloseHandle(handle); + + _initializationFailed = true; + Log.Debug("SetInformationJobObject failed with Win32 error {ErrorCode}; the Lua language server will rely on graceful shutdown.", errorCode); + return IntPtr.Zero; + } + } + finally + { + Marshal.FreeHGlobal(payloadPointer); + } + + _jobHandle = handle; + AppDomain.CurrentDomain.ProcessExit += (_, _) => CloseHandle(handle); + return handle; + } + } + + private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000; + + private enum JobObjectInformationClass + { + ExtendedLimitInformation = 9 + } + + [StructLayout(LayoutKind.Sequential)] + private struct IoCounters + { + public ulong ReadOperationCount; + public ulong WriteOperationCount; + public ulong OtherOperationCount; + public ulong ReadTransferCount; + public ulong WriteTransferCount; + public ulong OtherTransferCount; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JobObjectBasicLimitInformation + { + public long PerProcessUserTimeLimit; + public long PerJobUserTimeLimit; + public uint LimitFlags; + public UIntPtr MinimumWorkingSetSize; + public UIntPtr MaximumWorkingSetSize; + public uint ActiveProcessLimit; + public UIntPtr Affinity; + public uint PriorityClass; + public uint SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JobObjectExtendedLimitInformation + { + public JobObjectBasicLimitInformation BasicLimitInformation; + public IoCounters IoInfo; + public UIntPtr ProcessMemoryLimit; + public UIntPtr JobMemoryLimit; + public UIntPtr PeakProcessMemoryUsed; + public UIntPtr PeakJobMemoryUsed; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string? lpName); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool SetInformationJobObject(IntPtr hJob, JobObjectInformationClass infoType, IntPtr lpJobObjectInfo, uint cbJobObjectInfoLength); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseHandle(IntPtr hObject); +} diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Infrastructure/Workspace/LuaWorkspaceFileWatcher.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Infrastructure/Workspace/LuaWorkspaceFileWatcher.cs new file mode 100644 index 0000000000..6cf264d6a7 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Infrastructure/Workspace/LuaWorkspaceFileWatcher.cs @@ -0,0 +1,335 @@ +using NLog; +using System.Collections.Concurrent; + +namespace TombLib.Scripting.Lua.LanguageServer; + +/// +/// Watches the Lua workspace root for relevant external file changes (*.lua, .luarc.json, +/// .luarc.jsonc) and forwards them to LuaLS through workspace/didChangeWatchedFiles. +/// LuaLS does not poll the file system itself for clients that opt into this capability, +/// so without this hook changes made by Git pull, file copy, or any other out-of-band tool would not reach +/// the language server until the user touched the affected document inside the host editor. +/// +internal sealed class LuaWorkspaceFileWatcher : IDisposable +{ + private static readonly Logger Log = LogManager.GetCurrentClassLogger(); + private static readonly TimeSpan DispatchDebounce = TimeSpan.FromMilliseconds(250); + + private readonly string _workspaceRootDirectoryPath; + private readonly Func _dispatchAsync; + private readonly Action? _watcherFailed; + private readonly ConcurrentDictionary _pendingChanges = new(StringComparer.OrdinalIgnoreCase); + private readonly SemaphoreSlim _dispatchGate = new(1, 1); + private readonly CancellationTokenSource _lifetimeCts = new(); + + private FileSystemWatcher? _luaWatcher; + private FileSystemWatcher? _apiDirectoryWatcher; + private FileSystemWatcher? _configWatcher; + private Timer? _debounceTimer; + private int _activeDispatchCount; + private int _dispatchResourcesDisposed; + private int _watcherFailureReported; + private volatile bool _isDisposed; + + /// + /// Initializes a new instance of the class. + /// + /// The workspace root directory to watch. + /// The callback that forwards coalesced changes to LuaLS. + public LuaWorkspaceFileWatcher( + string workspaceRootDirectoryPath, + Func dispatchAsync, + Action? watcherFailed = null) + { + _workspaceRootDirectoryPath = workspaceRootDirectoryPath; + _dispatchAsync = dispatchAsync; + _watcherFailed = watcherFailed; + } + + /// + /// Starts watching the configured workspace for external file changes. + /// + /// when the watcher is running; otherwise, . + public bool Start() + { + if (_isDisposed || _luaWatcher is not null) + return _luaWatcher is not null; + + if (!Directory.Exists(_workspaceRootDirectoryPath)) + return false; + + try + { + // .API/*.lua changes are already covered by the recursive Lua watcher. + // This watcher exists so creating, deleting, or renaming the .API directory itself is also observed. + _apiDirectoryWatcher = CreateWatcher(".API", includeSubdirectories: false); + _luaWatcher = CreateWatcher("*.lua", includeSubdirectories: true); + _configWatcher = CreateWatcher(".luarc.*", includeSubdirectories: false); + _debounceTimer = new Timer(OnDebounceTick, state: null, dueTime: Timeout.Infinite, period: Timeout.Infinite); + + return true; + } + catch (Exception exception) + { + Log.Debug(exception, "Failed to start the Lua workspace file watcher for '{Workspace}'.", _workspaceRootDirectoryPath); + Dispose(); + + return false; + } + } + + private FileSystemWatcher CreateWatcher(string filter, bool includeSubdirectories) + { + var watcher = new FileSystemWatcher(_workspaceRootDirectoryPath, filter) + { + IncludeSubdirectories = includeSubdirectories, + NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime | NotifyFilters.DirectoryName, + InternalBufferSize = 64 * 1024 + }; + + watcher.Created += (_, e) => QueueChange(e.FullPath, FileChangeKind.Created); + watcher.Changed += (_, e) => QueueChange(e.FullPath, FileChangeKind.Changed); + watcher.Deleted += (_, e) => QueueChange(e.FullPath, FileChangeKind.Deleted); + watcher.Renamed += (_, e) => + { + QueueChange(e.OldFullPath, FileChangeKind.Deleted); + QueueChange(e.FullPath, FileChangeKind.Created); + }; + + watcher.Error += (_, e) => HandleWatcherError(e.GetException()); + watcher.EnableRaisingEvents = true; + + return watcher; + } + + private void QueueChange(string filePath, FileChangeKind kind) + { + if (_isDisposed || string.IsNullOrEmpty(filePath)) + return; + + // Coalesce multiple events for the same path: a Created event keeps winning over later Changed + // events for the same file, while a transient delete-then-create collapse is reported as a + // single Changed notification because the path exists again but its contents may have changed. + _pendingChanges.AddOrUpdate(filePath, kind, (_, existing) => Combine(existing, kind)); + _debounceTimer?.Change(DispatchDebounce, Timeout.InfiniteTimeSpan); + } + + private static FileChangeKind Combine(FileChangeKind existing, FileChangeKind incoming) + { + if (existing == FileChangeKind.Created && incoming == FileChangeKind.Deleted) + return FileChangeKind.Deleted; + + if (existing == FileChangeKind.Deleted && incoming == FileChangeKind.Created) + return FileChangeKind.Changed; + + return existing == FileChangeKind.Created ? FileChangeKind.Created : incoming; + } + + private void OnDebounceTick(object? _) + { + if (_isDisposed || _pendingChanges.IsEmpty) + return; + + _ = DispatchPendingChangesAsync(); + } + + private async Task DispatchPendingChangesAsync() + { + Interlocked.Increment(ref _activeDispatchCount); + + try + { + await DispatchPendingChangesCoreAsync().ConfigureAwait(false); + } + finally + { + if (Interlocked.Decrement(ref _activeDispatchCount) == 0 && _isDisposed) + DisposeDispatchResources(); + } + } + + private async Task DispatchPendingChangesCoreAsync() + { + bool dispatchGateHeld = false; + + try + { + await _dispatchGate.WaitAsync(_lifetimeCts.Token).ConfigureAwait(false); + dispatchGateHeld = true; + + if (_pendingChanges.IsEmpty) + return; + + var batch = new FileChangeBatch(); + + foreach (var entry in _pendingChanges) + { + if (_pendingChanges.TryRemove(entry.Key, out FileChangeKind removedKind)) + batch.Add(entry.Key, removedKind); + } + + if (batch.Count == 0) + return; + + await _dispatchAsync(batch, _lifetimeCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { } + catch (ObjectDisposedException) + { } + catch (Exception exception) + { + Log.Debug(exception, "Lua workspace file watcher dispatch failed."); + } + finally + { + if (dispatchGateHeld) + { + try + { + _dispatchGate.Release(); + } + catch (ObjectDisposedException) + { } + } + } + } + + private void HandleWatcherError(Exception? exception) + { + if (_isDisposed) + return; + + StopWatching(); + + if (!_pendingChanges.IsEmpty) + _ = DispatchPendingChangesAsync(); + + if (Interlocked.Exchange(ref _watcherFailureReported, 1) != 0) + return; + + Log.Warn(exception, + "Lua workspace file watcher was disabled for '{Workspace}'. External workspace changes will no longer be forwarded to LuaLS until the host application is restarted.", + _workspaceRootDirectoryPath); + + try + { + _watcherFailed?.Invoke(exception); + } + catch (Exception callbackException) + { + Log.Warn(callbackException, "Lua workspace watcher failure handler threw."); + } + } + + private void StopWatching() + { + TryDisposeAndClear(ref _apiDirectoryWatcher, nameof(_apiDirectoryWatcher)); + TryDisposeAndClear(ref _luaWatcher, nameof(_luaWatcher)); + TryDisposeAndClear(ref _configWatcher, nameof(_configWatcher)); + TryDisposeAndClear(ref _debounceTimer, nameof(_debounceTimer)); + } + + /// + /// Releases all native file-system watchers and pending dispatch resources. + /// + public void Dispose() + { + if (_isDisposed) + return; + + _isDisposed = true; + + StopWatching(); + + try { _lifetimeCts.Cancel(); } catch (ObjectDisposedException) { } + + if (Volatile.Read(ref _activeDispatchCount) == 0) + DisposeDispatchResources(); + } + + private void DisposeDispatchResources() + { + if (Interlocked.Exchange(ref _dispatchResourcesDisposed, 1) != 0) + return; + + _lifetimeCts.Dispose(); + _dispatchGate.Dispose(); + } + + private static void TryDisposeAndClear(ref T? disposable, string resourceName) where T : class, IDisposable + { + T? value = disposable; + disposable = null; + + if (value is null) + return; + + try + { + value.Dispose(); + } + catch (Exception exception) + { + Log.Debug(exception, "Failed to dispose Lua workspace watcher resource '{ResourceName}'.", resourceName); + } + } + + internal void QueueChangeForTest(string filePath, FileChangeKind kind) + => QueueChange(filePath, kind); + + internal Task DispatchPendingChangesForTestAsync() + => DispatchPendingChangesAsync(); + + internal void ReportErrorForTest(Exception? exception) + => HandleWatcherError(exception); + + internal bool HasActiveWatchers + => _apiDirectoryWatcher is not null || _luaWatcher is not null || _configWatcher is not null; +} + +/// +/// Identifies the file-system change kind reported to LuaLS. +/// +internal enum FileChangeKind +{ + /// + /// A file or directory was created. + /// + Created = 1, + + /// + /// A file or directory changed in place. + /// + Changed = 2, + + /// + /// A file or directory was deleted. + /// + Deleted = 3 +} + +/// +/// Represents a coalesced batch of workspace file changes ready to forward to LuaLS. +/// +internal sealed class FileChangeBatch +{ + private readonly List<(string Path, FileChangeKind Kind)> _entries = []; + + /// + /// Gets the number of coalesced entries in the batch. + /// + public int Count => _entries.Count; + + /// + /// Gets the coalesced file-change entries. + /// + public IReadOnlyList<(string Path, FileChangeKind Kind)> Entries => _entries; + + /// + /// Adds a file-change entry to the batch. + /// + /// The changed local path. + /// The coalesced change kind. + public void Add(string path, FileChangeKind kind) => _entries.Add((path, kind)); +} diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Infrastructure/Workspace/LuaWorkspaceWatcherFailure.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Infrastructure/Workspace/LuaWorkspaceWatcherFailure.cs new file mode 100644 index 0000000000..7b9e20d1b6 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Infrastructure/Workspace/LuaWorkspaceWatcherFailure.cs @@ -0,0 +1,7 @@ +namespace TombLib.Scripting.Lua.LanguageServer; + +/// +/// Describes a workspace-watcher failure that should be surfaced to the UI. +/// +/// The user-facing failure message. +public readonly record struct LuaWorkspaceWatcherFailure(string Message); diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/LuaLanguageServerResponseParser.Shared.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/LuaLanguageServerResponseParser.Shared.cs new file mode 100644 index 0000000000..5934794a85 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/LuaLanguageServerResponseParser.Shared.cs @@ -0,0 +1,116 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using TombLib.Scripting.Lua.Objects; + +namespace TombLib.Scripting.Lua.LanguageServer; + +/// +/// Provides shared markup and payload-conversion helpers used by the LuaLS response parsers. +/// +public static partial class LuaLanguageServerResponseParser +{ + private readonly struct MarkupContent(string? text, bool isMarkdown) + { + public string Text { get; } = text ?? string.Empty; + public bool IsMarkdown { get; } = isMarkdown; + } + + private static string? ExtractMarkupText(JsonElement element) + => NormalizeMarkupText(ExtractMarkupContent(element).Text); + + private static MarkupContent ExtractMarkupContent(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => new MarkupContent(element.GetString(), true), + JsonValueKind.Array => CombineArrayMarkupContent(element), + JsonValueKind.Object when element.TryGetProperty("value", out JsonElement valueElement) + && element.TryGetProperty("kind", out JsonElement kindElement) + => new MarkupContent(valueElement.GetString(), + string.Equals(kindElement.GetString(), "markdown", StringComparison.OrdinalIgnoreCase)), + JsonValueKind.Object when element.TryGetProperty("language", out JsonElement languageElement) + && element.TryGetProperty("value", out JsonElement codeValueElement) + => new MarkupContent($"```{languageElement.GetString()}\n{codeValueElement.GetString()}\n```", true), + JsonValueKind.Object when element.TryGetProperty("value", out JsonElement plainValueElement) + => new MarkupContent(plainValueElement.GetString(), false), + _ => default + }; + } + + private static MarkupContent CombineArrayMarkupContent(JsonElement arrayElement) + { + bool isMarkdown = false; + List? parts = null; + + foreach (JsonElement child in arrayElement.EnumerateArray()) + { + MarkupContent item = ExtractMarkupContent(child); + + if (string.IsNullOrWhiteSpace(item.Text)) + continue; + + parts ??= []; + parts.Add(item.Text.Trim()); + isMarkdown |= item.IsMarkdown; + } + + return parts is null + ? default + : new MarkupContent(string.Join(Environment.NewLine + Environment.NewLine, parts), isMarkdown); + } + + private static string? NormalizeMarkupText(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return null; + + string normalized = text + .Replace("```lua", string.Empty, StringComparison.OrdinalIgnoreCase) + .Replace("```", string.Empty, StringComparison.Ordinal) + .Replace("`", string.Empty, StringComparison.Ordinal) + .Replace("\r", string.Empty, StringComparison.Ordinal) + .Trim(); + + string[] lines = [.. normalized + .Split('\n') + .Select(line => line.TrimEnd())]; + + return string.Join(Environment.NewLine, lines).Trim(); + } + + private static string? NormalizeMarkdownText(string? text) + { + return string.IsNullOrWhiteSpace(text) + ? null + : text.Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n') + .Trim(); + } + + private static bool TryParseDocumentRange(LuaProtocolRangePayload? rangePayload, [NotNullWhen(true)] out LuaDocumentRange? range) + { + range = null; + + if (!TryGetOneBasedLineAndColumn(rangePayload?.Start, out int startLineNumber, out int startColumnNumber) + || !TryGetOneBasedLineAndColumn(rangePayload?.End, out int endLineNumber, out int endColumnNumber)) + { + return false; + } + + range = new LuaDocumentRange(startLineNumber, startColumnNumber, endLineNumber, endColumnNumber); + return true; + } + + private static bool TryGetOneBasedLineAndColumn(LuaProtocolNullablePosition? position, out int lineNumber, out int columnNumber) + { + lineNumber = 1; + columnNumber = 1; + + if (position is not { Line: int parsedLine, Character: int parsedCharacter }) + return false; + + lineNumber = parsedLine + 1; + columnNumber = parsedCharacter + 1; + return true; + } +} diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/LuaTextEditPayloads.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/LuaTextEditPayloads.cs new file mode 100644 index 0000000000..169b60816f --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/LuaTextEditPayloads.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace TombLib.Scripting.Lua.LanguageServer; + +/// +/// Represents a single text edit returned by LuaLS. +/// +public readonly record struct LuaTextEditPayload( + [property: JsonPropertyName("range")] LuaProtocolRangePayload? Range, + [property: JsonPropertyName("newText")] string? NewText); + +/// +/// Represents the typed top-level workspace edit response used by rename. +/// +public readonly record struct LuaWorkspaceEditResponse( + [property: JsonPropertyName("changes")] Dictionary? Changes, + [property: JsonPropertyName("documentChanges")] LuaWorkspaceDocumentChangePayload[]? DocumentChanges); + +public readonly record struct LuaWorkspaceDocumentChangePayload( + [property: JsonPropertyName("textDocument")] LuaTextDocumentUriPayload? TextDocument, + [property: JsonPropertyName("edits")] LuaTextEditPayload[]? Edits); + +public readonly record struct LuaTextDocumentUriPayload( + [property: JsonPropertyName("uri")] string? Uri); diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Navigation/LuaDefinitionPayloads.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Navigation/LuaDefinitionPayloads.cs new file mode 100644 index 0000000000..1a040722f6 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Navigation/LuaDefinitionPayloads.cs @@ -0,0 +1,82 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace TombLib.Scripting.Lua.LanguageServer; + +/// +/// Represents the first usable definition target returned by LuaLS. +/// +[JsonConverter(typeof(LuaDefinitionResponseJsonConverter))] +public readonly record struct LuaDefinitionResponse(string? Uri, int LineNumber, int ColumnNumber); + +/// +/// Deserializes definition responses from LSP location and location-link payloads. +/// +public sealed class LuaDefinitionResponseJsonConverter : JsonConverter +{ + private static readonly string[] RangeProperties = + [ + "targetSelectionRange", + "targetRange", + "range" + ]; + + public override LuaDefinitionResponse Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + return default; + + using JsonDocument document = JsonDocument.ParseValue(ref reader); + JsonElement root = document.RootElement; + JsonElement definitionElement = root; + + if (root.ValueKind == JsonValueKind.Array) + { + definitionElement = root.EnumerateArray().FirstOrDefault(); + + if (definitionElement.ValueKind == JsonValueKind.Undefined) + return default; + } + + if (definitionElement.ValueKind != JsonValueKind.Object) + return default; + + string? uri = definitionElement.TryGetProperty("targetUri", out JsonElement targetUriElement) + ? targetUriElement.GetString() + : definitionElement.TryGetProperty("uri", out JsonElement uriElement) + ? uriElement.GetString() + : null; + + if (string.IsNullOrWhiteSpace(uri) || !TryGetStartElement(definitionElement, out JsonElement startElement)) + return default; + + int lineNumber = startElement.TryGetProperty("line", out JsonElement lineElement) + && lineElement.TryGetInt32(out int parsedLine) ? parsedLine + 1 : 1; + + int columnNumber = startElement.TryGetProperty("character", out JsonElement characterElement) + && characterElement.TryGetInt32(out int parsedCharacter) ? parsedCharacter + 1 : 1; + + return new LuaDefinitionResponse(uri, lineNumber, columnNumber); + } + + public override void Write(Utf8JsonWriter writer, LuaDefinitionResponse value, JsonSerializerOptions options) + => throw new NotSupportedException(); + + private static bool TryGetStartElement(JsonElement definitionElement, out JsonElement startElement) + { + for (int i = 0; i < RangeProperties.Length; i++) + { + string rangeProperty = RangeProperties[i]; + + if (definitionElement.TryGetProperty(rangeProperty, out JsonElement rangeElement) + && rangeElement.TryGetProperty("start", out startElement) + && startElement.ValueKind == JsonValueKind.Object) + { + return true; + } + } + + startElement = default; + return false; + } +} diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Navigation/LuaLanguageServerResponseParser.Navigation.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Navigation/LuaLanguageServerResponseParser.Navigation.cs new file mode 100644 index 0000000000..0d1bc492bf --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Navigation/LuaLanguageServerResponseParser.Navigation.cs @@ -0,0 +1,24 @@ +using TombLib.Scripting.Lua.Objects; + +namespace TombLib.Scripting.Lua.LanguageServer; + +public static partial class LuaLanguageServerResponseParser +{ + /// + /// Parses a definition location from a LuaLS definition response. + /// + public static LuaDefinitionLocation? ParseDefinitionLocation(LuaDefinitionResponse response) + { + if (string.IsNullOrWhiteSpace(response.Uri) + || !Uri.TryCreate(response.Uri, UriKind.Absolute, out Uri? parsedUri) + || parsedUri?.IsFile != true) + { + return null; + } + + return new LuaDefinitionLocation( + LuaLanguageServerPathHelper.NormalizeLocalPath(parsedUri), + response.LineNumber, + response.ColumnNumber); + } +} diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Properties/AssemblyInfo.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..37fefc243d --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.Versioning; + +[assembly: SupportedOSPlatform("windows")] diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Properties/InternalsVisibleTo.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Properties/InternalsVisibleTo.cs new file mode 100644 index 0000000000..d34753098b --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Properties/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("TombLib.Test")] diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Provider/LuaLanguageServerIntellisenseProvider.Documents.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Provider/LuaLanguageServerIntellisenseProvider.Documents.cs new file mode 100644 index 0000000000..f629b6d0c9 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Provider/LuaLanguageServerIntellisenseProvider.Documents.cs @@ -0,0 +1,361 @@ +namespace TombLib.Scripting.Lua.LanguageServer; + +public sealed partial class LuaLanguageServerIntellisenseProvider +{ + private Task EnqueueDocumentOperationAsync( + Func> operation, + CancellationToken cancellationToken) + { + var completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + lock (_documentOperationSyncRoot) + { + Task previousOperation = _queuedDocumentOperation; + _queuedDocumentOperation = RunQueuedDocumentOperationAsync(previousOperation, operation, completionSource, cancellationToken); + } + + return completionSource.Task; + } + + private static async Task RunQueuedDocumentOperationAsync( + Task previousOperation, + Func> operation, + TaskCompletionSource completionSource, + CancellationToken cancellationToken) + { + try + { + await WaitForQueuedDocumentOperationAsync(previousOperation).ConfigureAwait(false); + TResult result = await operation(cancellationToken).ConfigureAwait(false); + completionSource.TrySetResult(result); + } + catch (OperationCanceledException exception) when (exception.CancellationToken == cancellationToken) + { + completionSource.TrySetCanceled(cancellationToken); + } + catch (Exception exception) + { + completionSource.TrySetException(exception); + } + } + + private static async Task WaitForQueuedDocumentOperationAsync(Task previousOperation) + { + try + { + await previousOperation.ConfigureAwait(false); + } + catch + { } + } + + private async Task SynchronizeDocumentAsync(string filePath, string content, + bool acquireOpenReference, bool refreshSemanticTokens, CancellationToken cancellationToken) + { + if (_isDisposed || string.IsNullOrWhiteSpace(filePath) || _client is null) + return false; + + try + { + LuaDocumentSynchronizationResult synchronizationResult = await EnqueueDocumentOperationAsync( + token => SynchronizeDocumentCoreAsync(filePath, content, acquireOpenReference, token), + cancellationToken).ConfigureAwait(false); + + if (!synchronizationResult.Success) + return false; + + if (refreshSemanticTokens && synchronizationResult.Document is { } synchronizedDocument) + await RefreshSemanticTokensAsync(synchronizedDocument, cancellationToken).ConfigureAwait(false); + + return true; + } + catch (OperationCanceledException) + { + throw; + } + catch (IOException) + { + InvalidateDocumentSynchronization(filePath); + return false; + } + catch (ObjectDisposedException) + { + if (!_isDisposed) + InvalidateDocumentSynchronization(filePath); + + return false; + } + } + + private void InvalidateDocumentSynchronization(string filePath) + { + _startupSucceeded = false; + _documents.InvalidateServerSynchronization(filePath); + CancelSemanticTokenRequest(filePath); + } + + public void RenameDocument(string oldFilePath, string newFilePath, string content) + { + if (!LuaLanguageServerPathHelper.TryNormalizeLocalPath(oldFilePath, out string normalizedOldFilePath) + || !LuaLanguageServerPathHelper.TryNormalizeLocalPath(newFilePath, out string normalizedNewFilePath) + || string.Equals(normalizedOldFilePath, normalizedNewFilePath, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + ObserveBackgroundTask( + RenameDocumentAsync(normalizedOldFilePath, normalizedNewFilePath, content, CancellationToken.None), + "Document rename"); + } + + private async Task RenameDocumentAsync(string oldFilePath, string newFilePath, string content, CancellationToken cancellationToken) + { + if (_isDisposed || _client is null) + return false; + + try + { + LuaDocumentRenameRequest? request = await EnqueueDocumentOperationAsync( + token => RenameDocumentCoreAsync(oldFilePath, newFilePath, content, token), + cancellationToken).ConfigureAwait(false); + + if (request is not { } renameRequest) + return false; + + string filePath = renameRequest.RenamedDocument.FilePath; + RaiseDiagnosticsUpdated(filePath, _documents.GetDiagnostics(filePath)); + RaiseSemanticTokensUpdated(filePath, _documents.GetSemanticTokens(filePath)); + + return true; + } + catch (OperationCanceledException) + { + throw; + } + catch (IOException) + { + InvalidateDocumentSynchronization(newFilePath); + return false; + } + catch (ObjectDisposedException) + { + if (!_isDisposed) + InvalidateDocumentSynchronization(newFilePath); + + return false; + } + } + + private async Task SynchronizeDocumentCoreAsync( + string filePath, + string content, + bool acquireOpenReference, + CancellationToken cancellationToken) + { + if (!await EnsureStartedAsync(cancellationToken).ConfigureAwait(false)) + return new LuaDocumentSynchronizationResult(false, null); + + LuaDocumentSynchronizationRequest? request = _documents.Synchronize(filePath, content, acquireOpenReference); + + if (request is not { } pendingRequest) + return new LuaDocumentSynchronizationResult(true, null); + + await SendDocumentSynchronizationNotificationAsync(pendingRequest, cancellationToken).ConfigureAwait(false); + return new LuaDocumentSynchronizationResult(true, pendingRequest.Document); + } + + private async Task RenameDocumentCoreAsync( + string oldFilePath, + string newFilePath, + string content, + CancellationToken cancellationToken) + { + if (_client is null) + return null; + + LuaDocumentRenameRequest? request = _documents.Rename(oldFilePath, newFilePath, content); + + if (request is not { } renameRequest) + return null; + + CancelSemanticTokenRequest(oldFilePath); + CancelSemanticTokenRequest(newFilePath); + + if (!renameRequest.ReopenServerDocument) + return renameRequest; + + if (!_startupSucceeded || !_client.IsReady) + { + _documents.InvalidateServerSynchronization(newFilePath); + return renameRequest; + } + + if (renameRequest.PreviousDocument is not null) + { + await _client.SendNotificationAsync("textDocument/didClose", + new LuaDidCloseTextDocumentParams(new LuaTextDocumentIdentifier(renameRequest.PreviousDocument.Uri)), + cancellationToken).ConfigureAwait(false); + } + + await SendDocumentSynchronizationNotificationAsync( + new LuaDocumentSynchronizationRequest(LuaDocumentSynchronizationKind.Open, renameRequest.RenamedDocument), + cancellationToken).ConfigureAwait(false); + + return renameRequest; + } + + private async Task ReopenTrackedDocumentsAsync(IReadOnlyList documents, CancellationToken cancellationToken) + { + for (int i = 0; i < documents.Count; i++) + { + LuaDocumentSnapshot document = documents[i]; + LuaDocumentSynchronizationRequest? request = _documents.Synchronize(document.FilePath, document.Content); + + if (request is not { } pendingRequest) + continue; + + try + { + await SendDocumentSynchronizationNotificationAsync(pendingRequest, cancellationToken).ConfigureAwait(false); + await RefreshSemanticTokensAsync(pendingRequest.Document, cancellationToken).ConfigureAwait(false); + } + catch (IOException) + { + return false; + } + catch (ObjectDisposedException) + { + return false; + } + } + + return true; + } + + private async Task SendDocumentSynchronizationNotificationAsync(LuaDocumentSynchronizationRequest request, CancellationToken cancellationToken) + { + if (_client is null) + return; + + if (request.Kind == LuaDocumentSynchronizationKind.Open) + { + await _client.SendNotificationAsync("textDocument/didOpen", + new LuaDidOpenTextDocumentParams( + new LuaDidOpenTextDocumentPayload( + request.Document.Uri, + "lua", + request.Document.Version, + request.Document.Content)), + cancellationToken).ConfigureAwait(false); + } + else if (request.Kind == LuaDocumentSynchronizationKind.Change) + { + LuaTextDocumentContentChangePayload contentChange = _client.TextDocumentSyncKind switch + { + LuaTextDocumentSyncKind.Incremental when request.ChangeRange is { } changeRange => new( + changeRange.Text, + new LuaProtocolRangePayload( + new LuaProtocolNullablePosition(changeRange.StartLine, changeRange.StartCharacter), + new LuaProtocolNullablePosition(changeRange.EndLine, changeRange.EndCharacter))), + LuaTextDocumentSyncKind.Full => new(request.Document.Content), + LuaTextDocumentSyncKind.Incremental => new(request.Document.Content), + _ => throw new InvalidOperationException( + "The Lua language server does not support document changes required by the Lua IntelliSense provider.") + }; + + await _client.SendNotificationAsync("textDocument/didChange", + new LuaDidChangeTextDocumentParams( + new LuaVersionedTextDocumentIdentifierPayload(request.Document.Uri, request.Document.Version), + [contentChange]), + cancellationToken).ConfigureAwait(false); + } + } + + private async Task CloseDocumentAsync(string filePath, CancellationToken cancellationToken) + { + if (_client is null) + return; + + try + { + await EnqueueDocumentOperationAsync( + async token => + { + if (!_documents.TryClose(filePath, out LuaDocumentSnapshot? document)) + return false; + + // The document just dropped its last open reference; cancel any in-flight + // semantic-token request for it now that no editor will display the result. + CancelSemanticTokenRequest(filePath); + + // Only forward the close notification if the server is already running. + // Starting the server just to send didClose would be wasteful and can race with disposal. + if (document is null || !_startupSucceeded || !_client.IsReady) + return false; + + await _client.SendNotificationAsync("textDocument/didClose", + new LuaDidCloseTextDocumentParams(new LuaTextDocumentIdentifier(document.Uri)), token).ConfigureAwait(false); + + return true; + }, + cancellationToken).ConfigureAwait(false); + } + catch + { + // Ignore best-effort close failures. + } + } + + private void HandleDiagnosticsPublished(LuaPublishDiagnosticsParams parameters) + { + if (!LuaLanguageServerPathHelper.TryGetFilePath(parameters.Uri, out string filePath)) + { + Log.Debug("Lua diagnostics could not be matched to a local file path."); + return; + } + + LuaDocumentSnapshot? document = _documents.GetDocumentSnapshot(filePath); + + // Diagnostics for documents we have never opened are ignored: there is no editor to render them on, + // and reading the file from disk on the LSP read loop just to discard the result is wasteful. + if (document is null) + return; + + if (!LuaLanguageServerDiagnosticsParser.TryParse(parameters, filePath, + document.Content, document.Version, out LuaPublishedDiagnostics? publishedDiagnostics)) + { + Log.Debug("Lua diagnostics payload could not be parsed for '{FilePath}'.", filePath); + return; + } + + if (!_documents.TryStoreDiagnostics(publishedDiagnostics)) + return; + + RaiseDiagnosticsUpdated(publishedDiagnostics.FilePath, publishedDiagnostics.Diagnostics); + } + + private static void ObserveBackgroundTask(Task task, string operation) + { + _ = ObserveAsync(task, operation); + + static async Task ObserveAsync(Task observedTask, string observedOperation) + { + try + { + await observedTask.ConfigureAwait(false); + } + catch (OperationCanceledException) + { } + catch (IOException exception) + { + Log.Debug(exception, "Lua language server background operation '{Operation}' failed with a transport error.", observedOperation); + } + catch (ObjectDisposedException) + { } + catch (Exception exception) + { + Log.Warn(exception, "Lua language server background operation '{Operation}' failed.", observedOperation); + } + } + } +} diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Provider/LuaLanguageServerIntellisenseProvider.Requests.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Provider/LuaLanguageServerIntellisenseProvider.Requests.cs new file mode 100644 index 0000000000..302bcb9917 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Provider/LuaLanguageServerIntellisenseProvider.Requests.cs @@ -0,0 +1,388 @@ +using TombLib.Scripting.Lua.Objects; + +namespace TombLib.Scripting.Lua.LanguageServer; + +public sealed partial class LuaLanguageServerIntellisenseProvider +{ + /// + /// Requests completion items for the specified document position. + /// + /// The local file path. + /// The current document content. + /// The zero-based line index. + /// The zero-based column index. + /// The optional trigger character that caused completion. + /// A token that can cancel the request. + /// The completion items returned by LuaLS. + public async Task> GetCompletionItemsAsync(string filePath, string content, + int line, int column, char? triggerCharacter = null, CancellationToken cancellationToken = default) + { + return await SendPositionRequestAsync>( + filePath, content, line, column, "textDocument/completion", + (textDocument, position) => new LuaCompletionParams(textDocument, position, BuildCompletionContext(triggerCharacter)), + response => + { + IReadOnlyList itemPayloads = response?.Items ?? []; + + if (itemPayloads.Count == 0) + return []; + + Func>?>? resolveFactory = + _client is not null && _client.SupportsCompletionResolve + ? (unresolvedItem, itemPayload, itemIndex) => + cancellationToken => ResolveCompletionItemAsync(unresolvedItem, itemPayload, itemIndex, cancellationToken) + : null; + + return LuaLanguageServerResponseParser.ParseCompletionItems(itemPayloads, resolveFactory); + }, + timeoutValue: null, + defaultValue: [], + cancellationToken).ConfigureAwait(false); + } + + /// + /// Requests hover information for the specified document position. + /// + /// The local file path. + /// The current document content. + /// The zero-based line index. + /// The zero-based column index. + /// A token that can cancel the request. + /// The hover payload, or when none exists. + public Task GetHoverAsync(string filePath, string content, + int line, int column, CancellationToken cancellationToken = default) + { + return SendPositionRequestAsync( + filePath, content, line, column, "textDocument/hover", + static (textDocument, position) => new LuaTextDocumentPositionParams(textDocument, position), + LuaLanguageServerResponseParser.ParseHoverInfo, + timeoutValue: null, + defaultValue: null, + cancellationToken); + } + + /// + /// Requests a definition location for the specified document position. + /// + /// The local file path. + /// The current document content. + /// The zero-based line index. + /// The zero-based column index. + /// A token that can cancel the request. + /// The definition location, or when none exists. + public Task GetDefinitionAsync(string filePath, string content, + int line, int column, CancellationToken cancellationToken = default) + { + return SendPositionRequestAsync( + filePath, content, line, column, "textDocument/definition", + static (textDocument, position) => new LuaTextDocumentPositionParams(textDocument, position), + LuaLanguageServerResponseParser.ParseDefinitionLocation, + timeoutValue: default, + defaultValue: null, + cancellationToken); + } + + /// + /// Requests all known references for the specified document position. + /// + /// The local file path. + /// The current document content. + /// The zero-based line index. + /// The zero-based column index. + /// A token that can cancel the request. + /// The reference locations returned by LuaLS. + public async Task> GetReferencesAsync(string filePath, string content, + int line, int column, CancellationToken cancellationToken = default) + { + ILuaLanguageServerClient? client = _client; + + if (client is null) + return []; + + if (!LuaLanguageServerPathHelper.TryNormalizeLocalPath(filePath, out string normalizedFilePath)) + return []; + + if (!await SynchronizeDocumentAsync(normalizedFilePath, content, + acquireOpenReference: false, refreshSemanticTokens: false, cancellationToken).ConfigureAwait(false)) + { + return []; + } + + if (!client.SupportsReferences) + return []; + + var textDocument = new LuaTextDocumentIdentifier(LuaLanguageServerPathHelper.CreateFileUri(normalizedFilePath)); + var position = new LuaProtocolPosition(line, column); + + LuaReferenceResponse[]? response = await SendBoundedRequestAsync(client, "textDocument/references", + new LuaReferenceParams(textDocument, position, new LuaReferenceContextPayload(IncludeDeclaration: true)), + timeoutValue: null, + cancellationToken).ConfigureAwait(false); + + return LuaLanguageServerResponseParser.ParseReferenceLocations(response); + } + + /// + /// Requests workspace edits to rename the symbol at the specified document position. + /// + /// The local file path. + /// The current document content. + /// The zero-based line index. + /// The zero-based column index. + /// The requested replacement symbol name. + /// A token that can cancel the request. + /// The workspace edit returned by LuaLS, or when unavailable. + public async Task RenameSymbolAsync(string filePath, string content, + int line, int column, string newName, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(newName)) + return null; + + ILuaLanguageServerClient? client = _client; + + if (client is null) + return null; + + if (!LuaLanguageServerPathHelper.TryNormalizeLocalPath(filePath, out string normalizedFilePath)) + return null; + + if (!await SynchronizeDocumentAsync(normalizedFilePath, content, + acquireOpenReference: false, refreshSemanticTokens: false, cancellationToken).ConfigureAwait(false)) + { + return null; + } + + if (!client.SupportsRename) + return null; + + var textDocument = new LuaTextDocumentIdentifier(LuaLanguageServerPathHelper.CreateFileUri(normalizedFilePath)); + var position = new LuaProtocolPosition(line, column); + + LuaWorkspaceEditResponse? response = await SendBoundedRequestAsync(client, "textDocument/rename", + new LuaRenameParams(textDocument, position, newName), + timeoutValue: null, + cancellationToken).ConfigureAwait(false); + + return LuaLanguageServerResponseParser.ParseWorkspaceEdit(response); + } + + /// + /// Requests formatting edits for the specified Lua document. + /// + /// The local file path. + /// The current document content. + /// The editor formatting preferences to pass to LuaLS. + /// A token that can cancel the request. + /// The text edits returned by LuaLS. + public async Task> FormatDocumentAsync(string filePath, string content, + LuaFormattingOptions options, CancellationToken cancellationToken = default) + { + ILuaLanguageServerClient? client = _client; + + if (client is null) + return []; + + if (!LuaLanguageServerPathHelper.TryNormalizeLocalPath(filePath, out string normalizedFilePath)) + return []; + + if (!await SynchronizeDocumentAsync(normalizedFilePath, content, + acquireOpenReference: false, refreshSemanticTokens: false, cancellationToken).ConfigureAwait(false)) + { + return []; + } + + if (!client.SupportsFormatting) + return []; + + LuaTextEditPayload[]? response = await SendBoundedRequestAsync(client, "textDocument/formatting", + new LuaDocumentFormattingParams( + new LuaTextDocumentIdentifier(LuaLanguageServerPathHelper.CreateFileUri(normalizedFilePath)), + new LuaFormattingOptionsPayload(options.TabSize, options.InsertSpaces)), + timeoutValue: null, + cancellationToken).ConfigureAwait(false); + + return LuaLanguageServerResponseParser.ParseDocumentFormattingEdits(response); + } + + /// + /// Requests signature-help information for the specified document position. + /// + /// The local file path. + /// The current document content. + /// The zero-based line index. + /// The zero-based column index. + /// A token that can cancel the request. + /// The signature-help payload, or when none exists. + public Task GetSignatureHelpAsync(string filePath, string content, + int line, int column, CancellationToken cancellationToken = default) + { + return SendPositionRequestAsync( + filePath, content, line, column, "textDocument/signatureHelp", + static (textDocument, position) => new LuaTextDocumentPositionParams(textDocument, position), + LuaLanguageServerResponseParser.ParseSignatureHelp, + timeoutValue: null, + defaultValue: null, + cancellationToken); + } + + private static LuaCompletionContextPayload BuildCompletionContext(char? triggerCharacter) + { + return triggerCharacter is null + ? new LuaCompletionContextPayload(TriggerKind: 1) + : new LuaCompletionContextPayload(TriggerKind: 2, triggerCharacter.ToString()); + } + + private async Task ResolveCompletionItemAsync(LuaCompletionItem unresolvedItem, LuaCompletionItemPayload itemPayload, int itemIndex, CancellationToken cancellationToken) + { + ILuaLanguageServerClient? client = _client; + + if (client is null) + return unresolvedItem; + + if (!client.SupportsCompletionResolve) + return unresolvedItem; + + try + { + LuaCompletionItemPayload? resolvedItem = await SendBoundedRequestAsync(client, "completionItem/resolve", itemPayload, + timeoutValue: null, cancellationToken).ConfigureAwait(false); + + if (resolvedItem is not null) + { + LuaCompletionItem? parsedItem = LuaLanguageServerResponseParser.ParseCompletionItem(resolvedItem, itemIndex); + + if (parsedItem is not null) + return unresolvedItem.WithResolvedContent(parsedItem); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + Log.Warn(exception, "Failed to resolve Lua completion item '{Label}'; falling back to the unresolved item.", unresolvedItem.Label); + } + + return unresolvedItem; + } + + private async Task SendPositionRequestAsync( + string filePath, string content, int line, int column, + string method, + Func buildParameters, + Func parseResponse, + TResponse timeoutValue, + TResult defaultValue, + CancellationToken cancellationToken) + { + ILuaLanguageServerClient? client = _client; + + if (client is null) + return defaultValue; + + if (!LuaLanguageServerPathHelper.TryNormalizeLocalPath(filePath, out string normalizedFilePath)) + return defaultValue; + + if ( + // Request-driven sync paths (completion / hover / definition / signature) intentionally + // skip the semantic-token refresh: typing a single identifier character can otherwise turn + // into didChange + completion + semanticTokens/full per keystroke, which is the dominant + // performance regression observed during normal editing. UpdateDocument (TextChangedDelayed) + // remains the single owner of post-edit semantic-token refresh. + !await SynchronizeDocumentAsync(normalizedFilePath, content, + acquireOpenReference: false, refreshSemanticTokens: false, cancellationToken).ConfigureAwait(false)) + { + return defaultValue; + } + + var textDocument = new LuaTextDocumentIdentifier(LuaLanguageServerPathHelper.CreateFileUri(normalizedFilePath)); + var position = new LuaProtocolPosition(line, column); + + TResponse response = await SendBoundedRequestAsync(client, method, + buildParameters(textDocument, position), timeoutValue, cancellationToken).ConfigureAwait(false); + + if (response is null) + return defaultValue; + + return parseResponse(response); + } + + private async Task SendBoundedRequestAsync( + ILuaLanguageServerClient client, + string method, + object parameters, + TResponse timeoutValue, + CancellationToken cancellationToken) + { + long transportGeneration = client.TransportGeneration; + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(_requestTimeout); + + try + { + TResponse response = await client.SendRequestAsync(method, parameters, timeoutCts.Token).ConfigureAwait(false); + ResetRequestTimeoutTracking(transportGeneration); + return response; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + RecordRequestTimeout(client, method, transportGeneration); + return timeoutValue; + } + } + + private void RecordRequestTimeout(ILuaLanguageServerClient client, string method, long transportGeneration) + { + int timeoutCount; + bool shouldMarkTransportUnhealthy = false; + + lock (_requestTimeoutSyncRoot) + { + if (_timedOutRequestGeneration != transportGeneration) + { + _timedOutRequestGeneration = transportGeneration; + _consecutiveRequestTimeouts = 0; + _restartRequestedGeneration = -1; + } + + timeoutCount = ++_consecutiveRequestTimeouts; + + if (timeoutCount >= _requestTimeoutRestartThreshold && _restartRequestedGeneration != transportGeneration) + { + _restartRequestedGeneration = transportGeneration; + shouldMarkTransportUnhealthy = true; + } + } + + if (shouldMarkTransportUnhealthy) + { + Log.Warn("Lua language server request '{Method}' timed out after {Timeout}s {Count} times on transport generation {Generation}; the transport will restart on the next IntelliSense request.", + method, + _requestTimeout.TotalSeconds, + timeoutCount, + transportGeneration); + + client.MarkTransportUnhealthy(); + return; + } + + Log.Debug("Lua language server request '{Method}' timed out after {Timeout}s (consecutive {Count}/{Threshold}, generation {Generation}).", + method, + _requestTimeout.TotalSeconds, + timeoutCount, + _requestTimeoutRestartThreshold, + transportGeneration); + } + + private void ResetRequestTimeoutTracking(long transportGeneration) + { + lock (_requestTimeoutSyncRoot) + { + _timedOutRequestGeneration = transportGeneration; + _consecutiveRequestTimeouts = 0; + _restartRequestedGeneration = -1; + } + } +} diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Provider/LuaLanguageServerIntellisenseProvider.SemanticTokens.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Provider/LuaLanguageServerIntellisenseProvider.SemanticTokens.cs new file mode 100644 index 0000000000..b0d4075f9e --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Provider/LuaLanguageServerIntellisenseProvider.SemanticTokens.cs @@ -0,0 +1,223 @@ +using TombLib.Scripting.Lua.Objects; + +namespace TombLib.Scripting.Lua.LanguageServer; + +public sealed partial class LuaLanguageServerIntellisenseProvider +{ + private void HandleSemanticTokensRefreshRequested() + => ObserveBackgroundTask(RefreshTrackedSemanticTokensAsync(CancellationToken.None), "Semantic tokens refresh"); + + private async Task RefreshTrackedSemanticTokensAsync(CancellationToken cancellationToken) + { + if (_isDisposed || _client is null || _client.SemanticTokenTypes.Count == 0) + return; + + IReadOnlyList documents = _documents.GetOpenDocuments(); + + for (int i = 0; i < documents.Count; i++) + await RefreshSemanticTokensAsync(documents[i], cancellationToken).ConfigureAwait(false); + } + + private async Task RefreshSemanticTokensAsync(LuaDocumentSnapshot document, CancellationToken cancellationToken) + { + if (_client is null || _client.SemanticTokenTypes.Count == 0) + return; + + CancellationToken effectiveToken = ReplaceSemanticTokenRequest(document.FilePath, cancellationToken, out CancellationTokenSource? linkedSource); + + try + { + LuaSemanticTokensDeltaState deltaState = _documents.GetSemanticTokensDeltaState(document.FilePath); + + bool useDelta = _client.SupportsSemanticTokensDelta + && deltaState.PreviousResultId is not null + && deltaState.PreviousData is not null; + + LuaSemanticTokensWireResponse? response = await SendSemanticTokensRequestAsync(document, deltaState.PreviousResultId, useDelta, effectiveToken) + .ConfigureAwait(false); + + if (response is null) + return; + + LuaSemanticTokensDecodeResult decodeResult = DecodeSemanticTokensResponse(response, document, deltaState.PreviousData, useDelta); + + if (decodeResult.RetryWithFullRefresh) + { + _documents.StoreSemanticTokensDeltaState(document.FilePath, null, null); + + LuaSemanticTokensWireResponse? fullResponse = await SendSemanticTokensRequestAsync(document, previousResultId: null, useDelta: false, effectiveToken) + .ConfigureAwait(false); + + if (fullResponse is null) + return; + + decodeResult = DecodeSemanticTokensResponse(fullResponse, document, previousData: null, deltaWasRequested: false); + } + + _documents.StoreSemanticTokensDeltaState(document.FilePath, decodeResult.ResultId, decodeResult.Data); + + if (!_documents.TryStoreSemanticTokens(document.FilePath, document.Version, decodeResult.Tokens)) + return; + + RaiseSemanticTokensUpdated(document.FilePath, decodeResult.Tokens); + } + catch (OperationCanceledException) when (effectiveToken.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + // A newer document version superseded this request. + } + catch (OperationCanceledException) + { + throw; + } + catch (IOException exception) + { + Log.Debug(exception, "Lua semantic tokens request failed for '{FilePath}' due to a transport error; falling back to TextMate highlighting until the next sync.", + document.FilePath); + } + catch (ObjectDisposedException) + { + // The client was torn down between scheduling and dispatch. + } + catch (Exception exception) + { + Log.Warn(exception, "Lua semantic tokens request failed for '{FilePath}'; falling back to TextMate highlighting.", + document.FilePath); + } + finally + { + ClearSemanticTokenRequest(document.FilePath, linkedSource); + } + } + + private Task SendSemanticTokensRequestAsync( + LuaDocumentSnapshot document, + string? previousResultId, + bool useDelta, + CancellationToken cancellationToken) + { + if (_client is null) + return Task.FromResult(null); + + string method = useDelta ? "textDocument/semanticTokens/full/delta" : "textDocument/semanticTokens/full"; + var parameters = new LuaSemanticTokensParams( + new LuaTextDocumentIdentifier(document.Uri), + useDelta ? previousResultId : null); + + return SendBoundedRequestAsync(_client, method, parameters, timeoutValue: null, cancellationToken); + } + + private LuaSemanticTokensDecodeResult DecodeSemanticTokensResponse( + LuaSemanticTokensWireResponse? response, LuaDocumentSnapshot document, int[]? previousData, bool deltaWasRequested) + { + if (_client is null || response is null) + return new LuaSemanticTokensDecodeResult([], null, null, false); + + if (deltaWasRequested) + { + LuaSemanticTokensDeltaResponse delta = LuaLanguageServerSemanticTokensDeltaParser.Parse(response); + + if (delta.Edits is { } edits && previousData is not null) + { + int[]? patchedData = LuaLanguageServerSemanticTokensDeltaParser.ApplyEdits(previousData, edits); + + if (patchedData is not null) + { + IReadOnlyList tokens = LuaLanguageServerSemanticTokensDecoder.Decode( + patchedData, document, _client.SemanticTokenTypes, _client.SemanticTokenModifiers); + + return new LuaSemanticTokensDecodeResult(tokens, patchedData, delta.ResultId, false); + } + + Log.Debug("Lua semantic-tokens delta edits could not be applied for '{FilePath}'; falling back to a full reparse.", document.FilePath); + return new LuaSemanticTokensDecodeResult([], null, null, true); + } + + if (delta.Data is { } fullData) + { + IReadOnlyList tokens = LuaLanguageServerSemanticTokensDecoder.Decode( + fullData, document, _client.SemanticTokenTypes, _client.SemanticTokenModifiers); + + return new LuaSemanticTokensDecodeResult(tokens, fullData, delta.ResultId, false); + } + + Log.Debug("Lua semantic-tokens delta response for '{FilePath}' did not contain usable data; requesting a full refresh.", document.FilePath); + return new LuaSemanticTokensDecodeResult([], null, null, true); + } + + LuaSemanticTokensDeltaResponse fullResponse = LuaLanguageServerSemanticTokensDeltaParser.Parse(response); + + if (fullResponse.Data is { } data) + { + IReadOnlyList tokens = LuaLanguageServerSemanticTokensDecoder.Decode( + data, document, _client.SemanticTokenTypes, _client.SemanticTokenModifiers); + + return new LuaSemanticTokensDecodeResult(tokens, data, fullResponse.ResultId, false); + } + + return new LuaSemanticTokensDecodeResult([], null, fullResponse.ResultId, false); + } + + private CancellationToken ReplaceSemanticTokenRequest(string filePath, CancellationToken cancellationToken, out CancellationTokenSource? linkedSource) + { + var freshSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + CancellationTokenSource? previousSource = null; + + _semanticTokenRequests.AddOrUpdate( + filePath, + freshSource, + (_, existing) => + { + previousSource = existing; + return freshSource; + }); + + CancelAndDispose(previousSource); + + linkedSource = freshSource; + return freshSource.Token; + } + + private void ClearSemanticTokenRequest(string filePath, CancellationTokenSource? linkedSource) + { + if (linkedSource is null) + return; + + _semanticTokenRequests.TryRemove(new KeyValuePair(filePath, linkedSource)); + linkedSource.Dispose(); + } + + private void CancelSemanticTokenRequest(string filePath) + { + if (_semanticTokenRequests.TryRemove(filePath, out CancellationTokenSource? source)) + CancelAndDispose(source); + } + + private void CancelAllSemanticTokenRequests() + { + if (_semanticTokenRequests.IsEmpty) + return; + + foreach (KeyValuePair entry in _semanticTokenRequests) + { + if (_semanticTokenRequests.TryRemove(entry.Key, out CancellationTokenSource? source)) + CancelAndDispose(source); + } + } + + private static void CancelAndDispose(CancellationTokenSource? source) + { + if (source is null) + return; + + try + { + source.Cancel(); + } + catch (ObjectDisposedException) + { } + finally + { + source.Dispose(); + } + } +} diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Provider/LuaLanguageServerIntellisenseProvider.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Provider/LuaLanguageServerIntellisenseProvider.cs new file mode 100644 index 0000000000..2adf8ad6d7 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Provider/LuaLanguageServerIntellisenseProvider.cs @@ -0,0 +1,475 @@ +using NLog; +using System.Collections.Concurrent; +using TombLib.Scripting.Lua.Objects; +using TombLib.Scripting.Lua.Services; +using TombLib.Scripting.Objects; + +namespace TombLib.Scripting.Lua.LanguageServer; + +/// +/// Implements the Lua IntelliSense provider by synchronizing editor documents with LuaLS and caching its responses. +/// +public sealed partial class LuaLanguageServerIntellisenseProvider : ILuaIntellisenseProvider +{ + private static readonly Logger Log = LogManager.GetCurrentClassLogger(); + + private static readonly TimeSpan DefaultRequestTimeout = TimeSpan.FromSeconds(10); + private const int DefaultRequestTimeoutRestartThreshold = 2; + private const int HardStartupFailureThreshold = 3; + + private readonly string _workspaceApiDirectoryPath; + private readonly string _workspaceRootDirectoryPath; + private readonly ILuaLanguageServerClient? _client; + private readonly LuaIntellisenseDocumentManager _documents = new(); + private readonly object _documentOperationSyncRoot = new(); + private readonly object _requestTimeoutSyncRoot = new(); + private readonly ConcurrentDictionary _semanticTokenRequests = new(StringComparer.OrdinalIgnoreCase); + private readonly SemaphoreSlim _startLock = new(1, 1); + private readonly TimeSpan _requestTimeout; + private readonly int _requestTimeoutRestartThreshold; + private LuaWorkspaceFileWatcher? _workspaceFileWatcher; + private Task _queuedDocumentOperation = Task.CompletedTask; + + private bool _startupSucceeded; + private int _consecutiveStartupFailures; + private int _consecutiveRequestTimeouts; + private long _timedOutRequestGeneration = -1; + private long _restartRequestedGeneration = -1; + private bool _permanentStartupFailureReported; + private bool _transientStartupFailureReported; + private int _workspaceWatcherFailureReported; + private volatile bool _isDisposed; + + /// + /// Gets a value indicating whether IntelliSense requests can currently be served. + /// + public bool IsAvailable => !_isDisposed && _client is not null + && _consecutiveStartupFailures < HardStartupFailureThreshold; + + /// + /// Gets a value indicating whether reference requests are supported by the active Lua language server. + /// + public bool SupportsReferences => !_isDisposed && _client is not null && _client.SupportsReferences; + + /// + /// Gets a value indicating whether rename requests are supported by the active Lua language server. + /// + public bool SupportsRename => !_isDisposed && _client is not null && _client.SupportsRename; + + /// + /// Gets a value indicating whether formatting requests are supported by the active Lua language server. + /// + public bool SupportsFormatting => !_isDisposed && _client is not null && _client.SupportsFormatting; + + /// + /// Occurs when diagnostics for a tracked document change. + /// + public event Action>? DiagnosticsUpdated; + + /// + /// Occurs when semantic tokens for a tracked document change. + /// + public event Action>? SemanticTokensUpdated; + + /// + /// Occurs when repeated language-server startup failures should be surfaced to the user. + /// + public event Action? StartupFailed; + + /// + /// Occurs when the external workspace watcher becomes unavailable for the rest of the session. + /// + public event Action? WorkspaceWatcherFailed; + + /// + /// Initializes a new instance of the class. + /// + /// The root directory of the current Lua script workspace. + /// The LuaLS executable path, or when unavailable. + public LuaLanguageServerIntellisenseProvider(string workspaceRootDirectoryPath, string? serverExecutablePath) + : this(workspaceRootDirectoryPath, + CreateClient(workspaceRootDirectoryPath, serverExecutablePath), + DefaultRequestTimeout, + DefaultRequestTimeoutRestartThreshold) + { } + + internal LuaLanguageServerIntellisenseProvider(string workspaceRootDirectoryPath, ILuaLanguageServerClient? client, + TimeSpan? requestTimeout = null, int requestTimeoutRestartThreshold = DefaultRequestTimeoutRestartThreshold) + { + _workspaceRootDirectoryPath = LuaLanguageServerPathHelper.NormalizeLocalPath(workspaceRootDirectoryPath); + _workspaceApiDirectoryPath = Path.Combine(_workspaceRootDirectoryPath, ".API"); + _client = client; + _requestTimeout = requestTimeout ?? DefaultRequestTimeout; + _requestTimeoutRestartThreshold = Math.Max(1, requestTimeoutRestartThreshold); + + if (_client is not null) + { + _client.DiagnosticsPublished += HandleDiagnosticsPublished; + _client.SemanticTokensRefreshRequested += HandleSemanticTokensRefreshRequested; + } + } + + private static ILuaLanguageServerClient? CreateClient(string workspaceRootDirectoryPath, string? serverExecutablePath) + { + if (string.IsNullOrWhiteSpace(serverExecutablePath)) + return null; + + string normalizedRoot = LuaLanguageServerPathHelper.NormalizeLocalPath(workspaceRootDirectoryPath); + return new LuaLanguageServerClient(normalizedRoot, serverExecutablePath, + () => LuaLanguageServerSettingsFactory.Create(normalizedRoot)); + } + + /// + /// Gets the latest diagnostics cached for the specified document. + /// + /// The local file path. + /// The cached diagnostics, or an empty list when none are available. + public IReadOnlyList GetDiagnostics(string filePath) + { + if (_isDisposed) + return []; + + if (!LuaLanguageServerPathHelper.TryNormalizeLocalPath(filePath, out string normalizedFilePath)) + return []; + + return _documents.GetDiagnostics(normalizedFilePath); + } + + /// + /// Gets the latest semantic tokens cached for the specified document. + /// + /// The local file path. + /// The cached semantic tokens, or an empty list when none are available. + public IReadOnlyList GetSemanticTokens(string filePath) + { + if (_isDisposed) + return []; + + if (!LuaLanguageServerPathHelper.TryNormalizeLocalPath(filePath, out string normalizedFilePath)) + return []; + + return _documents.GetSemanticTokens(normalizedFilePath); + } + + /// + /// Opens a document in the provider and synchronizes its current content with LuaLS. + /// + /// The local file path. + /// The current document content. + public void OpenDocument(string filePath, string content) + { + if (!LuaLanguageServerPathHelper.TryNormalizeLocalPath(filePath, out string normalizedFilePath)) + return; + + ObserveBackgroundTask(SynchronizeDocumentAsync(normalizedFilePath, content, acquireOpenReference: true, + refreshSemanticTokens: true, CancellationToken.None), "Document open"); + } + + /// + /// Pushes updated content for a document that is already tracked by the provider. + /// + /// The local file path. + /// The updated document content. + public void UpdateDocument(string filePath, string content) + { + if (!LuaLanguageServerPathHelper.TryNormalizeLocalPath(filePath, out string normalizedFilePath)) + return; + + ObserveBackgroundTask(SynchronizeDocumentAsync(normalizedFilePath, content, acquireOpenReference: false, + refreshSemanticTokens: true, CancellationToken.None), "Document change"); + } + + /// + /// Closes a tracked document and releases its server-side state when the last open reference disappears. + /// + /// The local file path. + public void CloseDocument(string filePath) + { + if (_isDisposed || _client is null || !LuaLanguageServerPathHelper.TryNormalizeLocalPath(filePath, out string normalizedFilePath)) + return; + + ObserveBackgroundTask(CloseDocumentAsync(normalizedFilePath, CancellationToken.None), "Document close"); + } + + private async Task EnsureStartedAsync(CancellationToken cancellationToken) + { + if (_client is null || _consecutiveStartupFailures >= HardStartupFailureThreshold) + return false; + + bool shieldCancellationForRestart = _startupSucceeded && !_client.IsReady; + CancellationToken startupCancellationToken = shieldCancellationForRestart + ? CancellationToken.None + : cancellationToken; + + // Fast path: once the client is healthy, keep the workspace watcher alive and avoid taking the startup lock. + if (_startupSucceeded && _client.IsReady) + { + EnsureWorkspaceFileWatcherStarted(); + return true; + } + + await _startLock.WaitAsync(startupCancellationToken).ConfigureAwait(false); + + try + { + IReadOnlyList documentsToReopen = []; + + // Re-check state after taking the lock so concurrent callers share the same restart/startup work. + if (_consecutiveStartupFailures >= HardStartupFailureThreshold) + return false; + + if (_startupSucceeded && _client.IsReady) + { + EnsureWorkspaceFileWatcherStarted(); + return true; + } + + if (!_client.IsReady) + { + if (_startupSucceeded) + Log.Info("Lua language server connection dropped, restarting and reopening tracked documents."); + + documentsToReopen = _documents.PrepareForRestart(); + } + + // Start the transport, then replay tracked documents when this is a restart rather than a cold start. + _startupSucceeded = await _client.StartAsync(startupCancellationToken).ConfigureAwait(false); + + if (_startupSucceeded && documentsToReopen.Count > 0) + { + _startupSucceeded = await ReopenTrackedDocumentsAsync(documentsToReopen, startupCancellationToken).ConfigureAwait(false); + + if (!_startupSucceeded) + Log.Warn("Failed to replay tracked documents after Lua language server restart."); + } + + if (_startupSucceeded) + { + _consecutiveStartupFailures = 0; + + ResetRequestTimeoutTracking(_client.TransportGeneration); + + _transientStartupFailureReported = false; + _permanentStartupFailureReported = false; + + EnsureWorkspaceFileWatcherStarted(); + } + else + { + // Record repeated failures so IntelliSense eventually stops advertising availability until restart. + _consecutiveStartupFailures++; + + bool isPermanentFailure = _consecutiveStartupFailures >= HardStartupFailureThreshold; + + if (isPermanentFailure) + { + Log.Error("Lua language server failed to start {Count} times consecutively for workspace '{Workspace}'; IntelliSense is now disabled until the editor is restarted.", + _consecutiveStartupFailures, _workspaceRootDirectoryPath); + } + else + { + Log.Warn("Failed to start the Lua language server for workspace '{Workspace}' (attempt {Attempt}/{Threshold}).", + _workspaceRootDirectoryPath, _consecutiveStartupFailures, HardStartupFailureThreshold); + } + + ReportStartupFailure(isPermanentFailure); + } + + return _startupSucceeded; + } + finally + { + _startLock.Release(); + } + } + + private void EnsureWorkspaceFileWatcherStarted() + { + if (_workspaceFileWatcher is not null || _client is null || string.IsNullOrEmpty(_workspaceRootDirectoryPath)) + return; + + var watcher = new LuaWorkspaceFileWatcher(_workspaceRootDirectoryPath, DispatchWorkspaceFileChangesAsync, HandleWorkspaceWatcherFailed); + + if (!watcher.Start()) + return; + + _workspaceFileWatcher = watcher; + } + + private async Task DispatchWorkspaceFileChangesAsync(FileChangeBatch batch, CancellationToken cancellationToken) + { + if (_client is null || _isDisposed || batch.Count == 0) + return; + + // Normalize every path once, drop invalid entries, and track whether any change affects LuaLS configuration. + var changes = new List(batch.Count); + bool shouldRefreshConfiguration = false; + + foreach ((string path, FileChangeKind kind) in batch.Entries) + { + if (!LuaLanguageServerPathHelper.TryNormalizeLocalPath(path, out string normalizedPath)) + continue; + + shouldRefreshConfiguration |= IsWorkspaceConfigurationPath(normalizedPath); + + changes.Add(new LuaFileEventPayload(LuaLanguageServerPathHelper.CreateFileUri(normalizedPath), (int)kind)); + } + + if (changes.Count == 0) + return; + + try + { + // Configuration-affecting files must refresh settings before the watched-files notification lands. + if (shouldRefreshConfiguration) + { + await _client.SendNotificationAsync("workspace/didChangeConfiguration", + new LuaDidChangeConfigurationParams(LuaLanguageServerSettingsFactory.Create(_workspaceRootDirectoryPath)), + cancellationToken).ConfigureAwait(false); + } + + await _client.SendNotificationAsync("workspace/didChangeWatchedFiles", + new LuaDidChangeWatchedFilesParams([.. changes]), cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { } + catch (Exception exception) + { + Log.Debug(exception, "Failed to forward workspace file changes to the Lua language server."); + } + } + + private bool IsWorkspaceConfigurationPath(string normalizedPath) + { + if (string.Equals(normalizedPath, _workspaceApiDirectoryPath, StringComparison.OrdinalIgnoreCase)) + return true; + + string apiDirectoryPrefix = _workspaceApiDirectoryPath + Path.DirectorySeparatorChar; + + if (normalizedPath.StartsWith(apiDirectoryPrefix, StringComparison.OrdinalIgnoreCase)) + return true; + + string fileName = Path.GetFileName(normalizedPath); + + return string.Equals(fileName, ".luarc.json", StringComparison.OrdinalIgnoreCase) + || string.Equals(fileName, ".luarc.jsonc", StringComparison.OrdinalIgnoreCase); + } + + private void ReportStartupFailure(bool isPermanentFailure) + { + if (isPermanentFailure) + { + if (_permanentStartupFailureReported) + return; + + _permanentStartupFailureReported = true; + } + else + { + if (_transientStartupFailureReported) + return; + + _transientStartupFailureReported = true; + } + + LuaLanguageServerStartupFailure failure = isPermanentFailure + ? new LuaLanguageServerStartupFailure( + "The bundled Lua language server failed to start repeatedly and Lua IntelliSense is now disabled until the application is restarted. See the log for technical details.", + true) + : new LuaLanguageServerStartupFailure( + "The bundled Lua language server failed to start. Lua IntelliSense will remain unavailable until the application can start the server successfully. The application will retry automatically when Lua IntelliSense is requested again.", + false); + + RaiseStartupFailed(failure); + } + + private void HandleWorkspaceWatcherFailed(Exception? exception) + { + if (_isDisposed || Interlocked.Exchange(ref _workspaceWatcherFailureReported, 1) != 0) + return; + + Log.Warn(exception, + "Lua workspace watching is disabled for '{Workspace}'. Open editors will keep working, but external Lua workspace changes will not be forwarded until the application is restarted.", + _workspaceRootDirectoryPath); + + RaiseWorkspaceWatcherFailed(new LuaWorkspaceWatcherFailure( + "The Lua workspace file watcher encountered an internal error and has been disabled for this session.\n\n" + + "Lua IntelliSense will continue to work for files edited in the editor, but external workspace changes - such as Git pull updates, generated .API files, or .luarc changes - will no longer be forwarded until the application is restarted.")); + } + + private void RaiseDiagnosticsUpdated(string filePath, IReadOnlyList diagnostics) + => InvokeSubscribersSafely( + DiagnosticsUpdated, + handler => ((Action>)handler)(filePath, diagnostics), + "Lua diagnostics subscriber"); + + private void RaiseSemanticTokensUpdated(string filePath, IReadOnlyList semanticTokens) + => InvokeSubscribersSafely( + SemanticTokensUpdated, + handler => ((Action>)handler)(filePath, semanticTokens), + "Lua semantic-token subscriber"); + + private void RaiseStartupFailed(LuaLanguageServerStartupFailure failure) + => InvokeSubscribersSafely( + StartupFailed, + handler => ((Action)handler)(failure), + "Lua IntelliSense startup-failure subscriber"); + + private void RaiseWorkspaceWatcherFailed(LuaWorkspaceWatcherFailure failure) + => InvokeSubscribersSafely( + WorkspaceWatcherFailed, + handler => ((Action)handler)(failure), + "Lua workspace-watcher subscriber"); + + private static void InvokeSubscribersSafely(Delegate? handlers, Action invoke, string subscriberDescription) + { + if (handlers is null) + return; + + foreach (Delegate handler in handlers.GetInvocationList()) + { + try + { + invoke(handler); + } + catch (Exception exception) + { + Log.Warn(exception, "{SubscriberDescription} threw; later subscribers will still be notified.", subscriberDescription); + } + } + } + + /// + /// Releases the language-server client, workspace watcher, and any in-flight semantic-token requests. + /// + public void Dispose() + { + if (_isDisposed) + return; + + _isDisposed = true; + + if (_client is not null) + { + _client.DiagnosticsPublished -= HandleDiagnosticsPublished; + _client.SemanticTokensRefreshRequested -= HandleSemanticTokensRefreshRequested; + } + + if (_workspaceFileWatcher is not null) + { + try + { + _workspaceFileWatcher.Dispose(); + } + catch (Exception exception) + { + Log.Debug(exception, "Failed to dispose the Lua workspace file watcher."); + } + } + + CancelAllSemanticTokenRequests(); + + _startLock.Dispose(); + _client?.Dispose(); + } +} diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Provider/LuaLanguageServerNotificationPayloads.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Provider/LuaLanguageServerNotificationPayloads.cs new file mode 100644 index 0000000000..1da4b62582 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Provider/LuaLanguageServerNotificationPayloads.cs @@ -0,0 +1,42 @@ +using System.Text.Json.Serialization; + +namespace TombLib.Scripting.Lua.LanguageServer; + +/// +/// Represents the typed payload for a textDocument/didOpen notification. +/// +public readonly record struct LuaDidOpenTextDocumentParams( + [property: JsonPropertyName("textDocument")] LuaDidOpenTextDocumentPayload TextDocument); + +public readonly record struct LuaDidOpenTextDocumentPayload( + [property: JsonPropertyName("uri")] string Uri, + [property: JsonPropertyName("languageId")] string LanguageId, + [property: JsonPropertyName("version")] int Version, + [property: JsonPropertyName("text")] string Text); + +public readonly record struct LuaVersionedTextDocumentIdentifierPayload( + [property: JsonPropertyName("uri")] string Uri, + [property: JsonPropertyName("version")] int Version); + +public readonly record struct LuaDidChangeTextDocumentParams( + [property: JsonPropertyName("textDocument")] LuaVersionedTextDocumentIdentifierPayload TextDocument, + [property: JsonPropertyName("contentChanges")] LuaTextDocumentContentChangePayload[] ContentChanges); + +public readonly record struct LuaTextDocumentContentChangePayload( + [property: JsonPropertyName("text")] string Text, + [property: JsonPropertyName("range")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + LuaProtocolRangePayload? Range = null); + +public readonly record struct LuaDidCloseTextDocumentParams( + [property: JsonPropertyName("textDocument")] LuaTextDocumentIdentifier TextDocument); + +public readonly record struct LuaDidChangeConfigurationParams( + [property: JsonPropertyName("settings")] object Settings); + +public readonly record struct LuaDidChangeWatchedFilesParams( + [property: JsonPropertyName("changes")] LuaFileEventPayload[] Changes); + +public readonly record struct LuaFileEventPayload( + [property: JsonPropertyName("uri")] string Uri, + [property: JsonPropertyName("type")] int Type); diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Provider/LuaLanguageServerRequestPayloads.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Provider/LuaLanguageServerRequestPayloads.cs new file mode 100644 index 0000000000..aaa5a6445f --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Provider/LuaLanguageServerRequestPayloads.cs @@ -0,0 +1,55 @@ +using System.Text.Json.Serialization; + +namespace TombLib.Scripting.Lua.LanguageServer; + +public readonly record struct LuaTextDocumentIdentifier( + [property: JsonPropertyName("uri")] string Uri); + +public readonly record struct LuaProtocolPosition( + [property: JsonPropertyName("line")] int Line, + [property: JsonPropertyName("character")] int Character); + +/// +/// Represents a text-document request payload that targets a specific position. +/// +public readonly record struct LuaTextDocumentPositionParams( + [property: JsonPropertyName("textDocument")] LuaTextDocumentIdentifier TextDocument, + [property: JsonPropertyName("position")] LuaProtocolPosition Position); + +public readonly record struct LuaCompletionContextPayload( + [property: JsonPropertyName("triggerKind")] int TriggerKind, + [property: JsonPropertyName("triggerCharacter")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + string? TriggerCharacter = null); + +public readonly record struct LuaCompletionParams( + [property: JsonPropertyName("textDocument")] LuaTextDocumentIdentifier TextDocument, + [property: JsonPropertyName("position")] LuaProtocolPosition Position, + [property: JsonPropertyName("context")] LuaCompletionContextPayload Context); + +public readonly record struct LuaReferenceContextPayload( + [property: JsonPropertyName("includeDeclaration")] bool IncludeDeclaration); + +public readonly record struct LuaReferenceParams( + [property: JsonPropertyName("textDocument")] LuaTextDocumentIdentifier TextDocument, + [property: JsonPropertyName("position")] LuaProtocolPosition Position, + [property: JsonPropertyName("context")] LuaReferenceContextPayload Context); + +public readonly record struct LuaRenameParams( + [property: JsonPropertyName("textDocument")] LuaTextDocumentIdentifier TextDocument, + [property: JsonPropertyName("position")] LuaProtocolPosition Position, + [property: JsonPropertyName("newName")] string NewName); + +public readonly record struct LuaFormattingOptionsPayload( + [property: JsonPropertyName("tabSize")] int TabSize, + [property: JsonPropertyName("insertSpaces")] bool InsertSpaces); + +public readonly record struct LuaDocumentFormattingParams( + [property: JsonPropertyName("textDocument")] LuaTextDocumentIdentifier TextDocument, + [property: JsonPropertyName("options")] LuaFormattingOptionsPayload Options); + +public readonly record struct LuaSemanticTokensParams( + [property: JsonPropertyName("textDocument")] LuaTextDocumentIdentifier TextDocument, + [property: JsonPropertyName("previousResultId")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + string? PreviousResultId = null); diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/References/LuaLanguageServerResponseParser.References.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/References/LuaLanguageServerResponseParser.References.cs new file mode 100644 index 0000000000..853582dd3e --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/References/LuaLanguageServerResponseParser.References.cs @@ -0,0 +1,36 @@ +using TombLib.Scripting.Lua.Objects; + +namespace TombLib.Scripting.Lua.LanguageServer; + +public static partial class LuaLanguageServerResponseParser +{ + /// + /// Parses reference locations from a LuaLS references response. + /// + public static IReadOnlyList ParseReferenceLocations(IReadOnlyList? response) + { + if (response is not { Count: > 0 }) + return []; + + var locations = new List(); + + for (int i = 0; i < response.Count; i++) + { + LuaReferenceResponse referenceElement = response[i]; + + if (string.IsNullOrWhiteSpace(referenceElement.Uri)) + continue; + + if (!Uri.TryCreate(referenceElement.Uri, UriKind.Absolute, out Uri? parsedUri) + || parsedUri?.IsFile != true + || !TryParseDocumentRange(referenceElement.Range, out LuaDocumentRange? range)) + { + continue; + } + + locations.Add(new LuaReferenceLocation(LuaLanguageServerPathHelper.NormalizeLocalPath(parsedUri), range)); + } + + return locations; + } +} diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/References/LuaReferencePayloads.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/References/LuaReferencePayloads.cs new file mode 100644 index 0000000000..c138b48ac6 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/References/LuaReferencePayloads.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace TombLib.Scripting.Lua.LanguageServer; + +/// +/// Represents a single typed reference location returned by LuaLS. +/// +public sealed record LuaReferenceResponse +{ + [JsonPropertyName("uri")] + public string? Uri { get; init; } + + [JsonPropertyName("range")] + public LuaProtocolRangePayload? Range { get; init; } +} diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/Rename/LuaLanguageServerResponseParser.Rename.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/Rename/LuaLanguageServerResponseParser.Rename.cs new file mode 100644 index 0000000000..66713bccb3 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/Rename/LuaLanguageServerResponseParser.Rename.cs @@ -0,0 +1,106 @@ +using System.Diagnostics.CodeAnalysis; +using TombLib.Scripting.Lua.Objects; + +namespace TombLib.Scripting.Lua.LanguageServer; + +public static partial class LuaLanguageServerResponseParser +{ + /// + /// Parses a workspace edit from a LuaLS rename response. + /// + public static LuaWorkspaceEdit? ParseWorkspaceEdit(LuaWorkspaceEditResponse? response) + { + if (response is null) + return null; + + var editsByFile = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + ParseChangeMap(response.Value.Changes, editsByFile); + ParseDocumentChanges(response.Value.DocumentChanges, editsByFile); + + if (editsByFile.Count == 0) + return null; + + var documentEdits = new List(editsByFile.Count); + + foreach ((string filePath, List textEdits) in editsByFile) + { + if (textEdits.Count == 0) + continue; + + documentEdits.Add(new LuaDocumentEdit(filePath, textEdits)); + } + + return documentEdits.Count == 0 + ? null + : new LuaWorkspaceEdit(documentEdits); + } + + private static void ParseChangeMap(IReadOnlyDictionary? changes, + Dictionary> editsByFile) + { + if (changes is null) + return; + + foreach ((string uri, LuaTextEditPayload[]? edits) in changes) + { + if (!LuaLanguageServerPathHelper.TryGetFilePath(uri, out string filePath)) + continue; + + List textEdits = GetOrCreateTextEditBucket(editsByFile, filePath); + AppendTextEdits(edits, textEdits); + } + } + + private static void ParseDocumentChanges(IReadOnlyList? documentChanges, + Dictionary> editsByFile) + { + if (documentChanges is null) + return; + + for (int i = 0; i < documentChanges.Count; i++) + { + LuaWorkspaceDocumentChangePayload documentChange = documentChanges[i]; + + if (!LuaLanguageServerPathHelper.TryGetFilePath(documentChange.TextDocument?.Uri, out string filePath)) + continue; + + List textEdits = GetOrCreateTextEditBucket(editsByFile, filePath); + AppendTextEdits(documentChange.Edits, textEdits); + } + } + + private static void AppendTextEdits(IReadOnlyList? edits, List textEdits) + { + if (edits is null) + return; + + for (int i = 0; i < edits.Count; i++) + { + if (TryParseTextEdit(edits[i], out LuaTextEdit? textEdit)) + textEdits.Add(textEdit); + } + } + + private static bool TryParseTextEdit(LuaTextEditPayload edit, [NotNullWhen(true)] out LuaTextEdit? textEdit) + { + textEdit = null; + + if (!TryParseDocumentRange(edit.Range, out LuaDocumentRange? range)) + return false; + + textEdit = new LuaTextEdit(range, edit.NewText ?? string.Empty); + return true; + } + + private static List GetOrCreateTextEditBucket(Dictionary> editsByFile, string filePath) + { + if (!editsByFile.TryGetValue(filePath, out List? textEdits)) + { + textEdits = []; + editsByFile[filePath] = textEdits; + } + + return textEdits; + } +} diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/SemanticTokens/LuaLanguageServerSemanticTokensDeltaParser.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/SemanticTokens/LuaLanguageServerSemanticTokensDeltaParser.cs new file mode 100644 index 0000000000..691ed206d2 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/SemanticTokens/LuaLanguageServerSemanticTokensDeltaParser.cs @@ -0,0 +1,207 @@ +using TombLib.Scripting.Lua.Objects; + +namespace TombLib.Scripting.Lua.LanguageServer; + +/// +/// Result of a `textDocument/semanticTokens/full/delta` response: either the server returned a full +/// `data` array (preferred when it is cheaper than a delta), or it returned a list of `edits` that +/// should be applied to the client-side cached integer stream. +/// +/// The result id that can seed the next delta request. +/// The full semantic-token integer stream, when provided. +/// The incremental edits, when provided instead of . +public readonly record struct LuaSemanticTokensDeltaResponse( + string? ResultId, + int[]? Data, + IReadOnlyList? Edits); + +/// +/// Represents a single edit against a cached semantic-token integer stream. +/// +/// The zero-based start offset within the integer stream. +/// The number of integers to remove. +/// The replacement integers to insert. +public readonly record struct LuaSemanticTokensEdit(int Start, int DeleteCount, int[] Data); + +/// +/// Parses LuaLS semantic-token delta responses and applies them to the cached token stream. +/// +public static class LuaLanguageServerSemanticTokensDeltaParser +{ + /// + /// Parses a semantic-token response that may contain either a full token stream or incremental edits. + /// + /// The typed response payload. + /// The parsed semantic-token delta response. + public static LuaSemanticTokensDeltaResponse Parse(LuaSemanticTokensWireResponse? response) + { + if (response is not { } payload) + return new LuaSemanticTokensDeltaResponse(ResultId: null, Data: null, Edits: null); + + if (payload.Data is { } data) + return new LuaSemanticTokensDeltaResponse(payload.ResultId, data, Edits: null); + + if (payload.Edits is { } editsPayload) + { + var edits = new List(editsPayload.Length); + + for (int i = 0; i < editsPayload.Length; i++) + { + LuaSemanticTokensEditPayload edit = editsPayload[i]; + edits.Add(new LuaSemanticTokensEdit(edit.Start ?? 0, edit.DeleteCount ?? 0, edit.Data ?? [])); + } + + return new LuaSemanticTokensDeltaResponse(payload.ResultId, Data: null, edits); + } + + return new LuaSemanticTokensDeltaResponse(payload.ResultId, Data: null, Edits: null); + } + + /// + /// Applies a list of LSP semantic-token edits (as returned by `semanticTokens/full/delta`) to a + /// previously cached integer stream. Returns the new stream, or if any + /// edit is out of range. + /// + public static int[]? ApplyEdits(int[] previousData, IReadOnlyList edits) + { + // LSP requires edits to be sorted by ascending start; we re-sort defensively in case the server + // or our own caching layer reorders them. Edits are then applied left-to-right with a running + // source/destination cursor so the resulting integer stream stays consistent regardless of + // individual edit sizes. + var ordered = new List(edits); + ordered.Sort(static (a, b) => a.Start.CompareTo(b.Start)); + + int newLength = previousData.Length; + + foreach (LuaSemanticTokensEdit edit in ordered) + { + if (edit.Start < 0 || edit.DeleteCount < 0 || edit.Start + edit.DeleteCount > previousData.Length) + return null; + + newLength += edit.Data.Length - edit.DeleteCount; + } + + if (newLength < 0) + return null; + + int[] result = new int[newLength]; + int sourceIndex = 0; + int destinationIndex = 0; + + foreach (LuaSemanticTokensEdit edit in ordered) + { + int copyLength = edit.Start - sourceIndex; + + if (copyLength > 0) + { + Array.Copy(previousData, sourceIndex, result, destinationIndex, copyLength); + destinationIndex += copyLength; + } + + if (edit.Data.Length > 0) + { + Array.Copy(edit.Data, 0, result, destinationIndex, edit.Data.Length); + destinationIndex += edit.Data.Length; + } + + sourceIndex = edit.Start + edit.DeleteCount; + } + + int tailLength = previousData.Length - sourceIndex; + + if (tailLength > 0) + { + Array.Copy(previousData, sourceIndex, result, destinationIndex, tailLength); + destinationIndex += tailLength; + } + + return destinationIndex == newLength ? result : null; + } +} + +/// +/// Decodes a raw LuaLS semantic-token integer stream (already cached on the client) into the typed +/// list expected by the editor's colorizer. +/// +public static class LuaLanguageServerSemanticTokensDecoder +{ + private static readonly IReadOnlyList EmptyModifiers = []; + + /// + /// Decodes a raw semantic-token integer stream into the typed token objects expected by the editor. + /// + /// The raw LSP semantic-token integer stream. + /// The document snapshot associated with the token stream. + /// The semantic token types advertised by the server. + /// The semantic token modifiers advertised by the server. + /// The decoded semantic tokens. + public static IReadOnlyList Decode(int[] data, LuaDocumentSnapshot? document, + IReadOnlyList? tokenTypes, IReadOnlyList? tokenModifiers) + { + if (data.Length == 0 || document is null || tokenTypes is null || tokenTypes.Count == 0) + return []; + + LuaDocumentLineOffsets lineOffsets = LuaDocumentLineOffsets.Build(document.Content); + var semanticTokens = new List(data.Length / 5); + Dictionary>? modifierCache = null; + + int line = 0; + int character = 0; + + for (int tupleStart = 0; tupleStart + 4 < data.Length; tupleStart += 5) + { + int deltaLine = data[tupleStart]; + int deltaCharacter = data[tupleStart + 1]; + int length = data[tupleStart + 2]; + int tokenTypeIndex = data[tupleStart + 3]; + int modifierMask = data[tupleStart + 4]; + + line += deltaLine; + character = deltaLine == 0 ? character + deltaCharacter : deltaCharacter; + + if (line < 0 || line >= lineOffsets.LineCount || tokenTypeIndex < 0 || tokenTypeIndex >= tokenTypes.Count) + continue; + + int lineLength = lineOffsets.GetLineLength(line); + int safeCharacter = Math.Max(0, Math.Min(character, lineLength)); + int safeLength = Math.Max(0, Math.Min(length, lineLength - safeCharacter)); + + if (safeLength == 0) + continue; + + semanticTokens.Add(new LuaSemanticToken( + line, + safeCharacter, + safeLength, + tokenTypes[tokenTypeIndex], + GetOrAddModifiers(ref modifierCache, modifierMask, tokenModifiers))); + } + + return semanticTokens; + } + + private static IReadOnlyList GetOrAddModifiers( + ref Dictionary>? cache, + int modifierMask, + IReadOnlyList? tokenModifiers) + { + if (modifierMask == 0 || tokenModifiers is null || tokenModifiers.Count == 0) + return EmptyModifiers; + + cache ??= []; + + if (cache.TryGetValue(modifierMask, out IReadOnlyList? cached)) + return cached; + + var modifiers = new List(); + + for (int bitIndex = 0; bitIndex < tokenModifiers.Count; bitIndex++) + { + if ((modifierMask & (1 << bitIndex)) != 0) + modifiers.Add(tokenModifiers[bitIndex]); + } + + cache[modifierMask] = modifiers; + return modifiers; + } +} diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/SemanticTokens/LuaSemanticTokensDecodeResult.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/SemanticTokens/LuaSemanticTokensDecodeResult.cs new file mode 100644 index 0000000000..0b78983f26 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/SemanticTokens/LuaSemanticTokensDecodeResult.cs @@ -0,0 +1,12 @@ +using TombLib.Scripting.Lua.Objects; + +namespace TombLib.Scripting.Lua.LanguageServer; + +/// +/// Represents the decoded semantic tokens payload and whether the caller should retry with a full refresh. +/// +public readonly record struct LuaSemanticTokensDecodeResult( + IReadOnlyList Tokens, + int[]? Data, + string? ResultId, + bool RetryWithFullRefresh); diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/SemanticTokens/LuaSemanticTokensDeltaState.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/SemanticTokens/LuaSemanticTokensDeltaState.cs new file mode 100644 index 0000000000..2d55b396c8 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/SemanticTokens/LuaSemanticTokensDeltaState.cs @@ -0,0 +1,6 @@ +namespace TombLib.Scripting.Lua.LanguageServer; + +/// +/// Represents the cached semantic-tokens delta state used for incremental refresh requests. +/// +public readonly record struct LuaSemanticTokensDeltaState(string? PreviousResultId, int[]? PreviousData); diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/SemanticTokens/LuaSemanticTokensPayloads.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/SemanticTokens/LuaSemanticTokensPayloads.cs new file mode 100644 index 0000000000..2848dc0175 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/SemanticTokens/LuaSemanticTokensPayloads.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace TombLib.Scripting.Lua.LanguageServer; + +/// +/// Represents the typed top-level semantic-token response for both full and delta results. +/// +public readonly record struct LuaSemanticTokensWireResponse( + [property: JsonPropertyName("resultId")] string? ResultId, + [property: JsonPropertyName("data")] int[]? Data, + [property: JsonPropertyName("edits")] LuaSemanticTokensEditPayload[]? Edits); + +public readonly record struct LuaSemanticTokensEditPayload( + [property: JsonPropertyName("start")] int? Start, + [property: JsonPropertyName("deleteCount")] int? DeleteCount, + [property: JsonPropertyName("data")] int[]? Data); diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/SignatureHelp/LuaLanguageServerResponseParser.SignatureHelp.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/SignatureHelp/LuaLanguageServerResponseParser.SignatureHelp.cs new file mode 100644 index 0000000000..7a16d979cd --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/SignatureHelp/LuaLanguageServerResponseParser.SignatureHelp.cs @@ -0,0 +1,95 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using TombLib.Scripting.Lua.Objects; + +namespace TombLib.Scripting.Lua.LanguageServer; + +public static partial class LuaLanguageServerResponseParser +{ + /// + /// Parses signature help metadata from a LuaLS signature-help response. + /// + public static LuaSignatureInfo? ParseSignatureHelp(LuaSignatureHelpResponse? response) + { + if (response?.Signatures is not { Length: > 0 } signatures) + return null; + + int activeSignature = response.ActiveSignature is int parsedActiveSignature + ? Math.Clamp(parsedActiveSignature, 0, signatures.Length - 1) + : 0; + + LuaSignatureHelpSignaturePayload signatureElement = signatures[activeSignature]; + + if (string.IsNullOrWhiteSpace(signatureElement.Label)) + return null; + + string label = signatureElement.Label; + + string? documentation = signatureElement.Documentation.ValueKind != JsonValueKind.Undefined + ? ExtractMarkupText(signatureElement.Documentation) + : null; + + int activeParameter = ResolveActiveParameter(response, signatureElement); + + var parameters = new List(); + + if (signatureElement.Parameters is { Length: > 0 } parametersElement) + { + for (int i = 0; i < parametersElement.Length; i++) + { + LuaSignatureHelpParameterPayload paramElement = parametersElement[i]; + + string? parameterLabel = paramElement.Label.ValueKind == JsonValueKind.String + ? paramElement.Label.GetString() + : TryExtractParameterLabel(label, paramElement.Label, out string? extractedLabel) + ? extractedLabel + : null; + + string? parameterDocumentation = paramElement.Documentation.ValueKind != JsonValueKind.Undefined + ? ExtractMarkupText(paramElement.Documentation) + : null; + + parameters.Add(new LuaParameterInfo(parameterLabel ?? string.Empty, parameterDocumentation)); + } + } + + return new LuaSignatureInfo(label, documentation, parameters, activeParameter); + } + + private static int ResolveActiveParameter(LuaSignatureHelpResponse response, LuaSignatureHelpSignaturePayload signatureElement) + { + if (response.ActiveParameter is int responseActiveParameter) + return Math.Max(0, responseActiveParameter); + + if (signatureElement.ActiveParameter is int signatureActiveParameter) + return Math.Max(0, signatureActiveParameter); + + return 0; + } + + private static bool TryExtractParameterLabel(string signatureLabel, JsonElement parameterLabelElement, + [NotNullWhen(true)] out string? parameterLabel) + { + parameterLabel = null; + + if (string.IsNullOrEmpty(signatureLabel) || parameterLabelElement.ValueKind != JsonValueKind.Array) + return false; + + JsonElement.ArrayEnumerator labelParts = parameterLabelElement.EnumerateArray(); + + if (!labelParts.MoveNext() || !labelParts.Current.TryGetInt32(out int startIndex)) + return false; + + if (!labelParts.MoveNext() || !labelParts.Current.TryGetInt32(out int endIndex)) + return false; + + startIndex = Math.Max(0, Math.Min(startIndex, signatureLabel.Length)); + endIndex = Math.Max(startIndex, Math.Min(endIndex, signatureLabel.Length)); + + if (endIndex <= startIndex) + return false; + + parameterLabel = signatureLabel[startIndex..endIndex]; + return true; + } +} diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/SignatureHelp/LuaSignatureHelpPayloads.cs b/TombLib/TombLib.Scripting.Lua.LanguageServer/SignatureHelp/LuaSignatureHelpPayloads.cs new file mode 100644 index 0000000000..2037085e11 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/SignatureHelp/LuaSignatureHelpPayloads.cs @@ -0,0 +1,43 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace TombLib.Scripting.Lua.LanguageServer; + +/// +/// Represents the typed top-level signature-help payload returned by LuaLS. +/// +public sealed record LuaSignatureHelpResponse +{ + [JsonPropertyName("activeSignature")] + public int? ActiveSignature { get; init; } + + [JsonPropertyName("activeParameter")] + public int? ActiveParameter { get; init; } + + [JsonPropertyName("signatures")] + public LuaSignatureHelpSignaturePayload[]? Signatures { get; init; } +} + +public sealed record LuaSignatureHelpSignaturePayload +{ + [JsonPropertyName("label")] + public string? Label { get; init; } + + [JsonPropertyName("documentation")] + public JsonElement Documentation { get; init; } + + [JsonPropertyName("activeParameter")] + public int? ActiveParameter { get; init; } + + [JsonPropertyName("parameters")] + public LuaSignatureHelpParameterPayload[]? Parameters { get; init; } +} + +public sealed record LuaSignatureHelpParameterPayload +{ + [JsonPropertyName("label")] + public JsonElement Label { get; init; } + + [JsonPropertyName("documentation")] + public JsonElement Documentation { get; init; } +} diff --git a/TombLib/TombLib.Scripting.Lua.LanguageServer/TombLib.Scripting.Lua.LanguageServer.csproj b/TombLib/TombLib.Scripting.Lua.LanguageServer/TombLib.Scripting.Lua.LanguageServer.csproj new file mode 100644 index 0000000000..1411600dcd --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua.LanguageServer/TombLib.Scripting.Lua.LanguageServer.csproj @@ -0,0 +1,19 @@ + + + + false + enable + enable + + + + + + + + + + + + + diff --git a/TombLib/TombLib.Scripting.Lua/Configs/TextEditors/ColorSchemes/Lua/Default.xml b/TombLib/TombLib.Scripting.Lua/Configs/TextEditors/ColorSchemes/Lua/Default.xml index ffdc258cfd..5a635c67ab 100644 --- a/TombLib/TombLib.Scripting.Lua/Configs/TextEditors/ColorSchemes/Lua/Default.xml +++ b/TombLib/TombLib.Scripting.Lua/Configs/TextEditors/ColorSchemes/Lua/Default.xml @@ -1,171 +1,42 @@ - - - - - - - - - - - - - - - - - - TODO - FIXME - - - HACK - UNDONE - - - - - - - - --- - - - - - - - - --\[[=]*\[ - \][=]*] - - - - -- - - - - " - " - - - - - - - - ' - ' - - - - - - - - \[[=]*\[ - \][=]*] - - - - true - false - - - - and - break - do - else - elseif - end - false - for - function - if - in - local - - not - or - repeat - return - then - true - until - while - using - continue - - - - break - return - - - - local - - - - nil - - - - TEN - Logic - Objects - Strings - Inventory - Misc - Effects - - - - - \b - [\d\w_]+ # an identifier - (?=\s*\() # followed by ( - - - \b - [\d\w_]+ # an identifier - (?=\s*\") # followed by " - - - \b - [\d\w_]+ # an identifier - (?=\s*\') # followed by ' - - - \b - [\d\w_]+ # an identifier - (?=\s*\{) # followed by { - - - \b - [\d\w_]+ # an identifier - (?=\s*\[) # followed by [ - - - - - \b0[xX][0-9a-fA-F]+ # hex number - | - ( \b\d+(\.[0-9]+)? #number with optional floating point - | \.[0-9]+ #or just starting with floating point - ) - ([eE][+-]?[0-9]+)? # optional exponent - - - - \b - [\d\w_]+ # an identifier - (?=\s*\.) # followed by . - - - - [?,.;()\[\]{}+\-/%*<>^+~!|&]+ - - + + + + + + + + + + + + + + + + and + break + do + else + elseif + end + false + for + function + goto + if + in + local + nil + not + or + repeat + return + then + true + until + while + + + \b(?:0[xX][0-9A-Fa-f]+|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\b + diff --git a/TombLib/TombLib.Scripting.Lua/Configs/TextEditors/Grammars/Lua/lua.tmLanguage.json b/TombLib/TombLib.Scripting.Lua/Configs/TextEditors/Grammars/Lua/lua.tmLanguage.json new file mode 100644 index 0000000000..0d30a41f39 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/Configs/TextEditors/Grammars/Lua/lua.tmLanguage.json @@ -0,0 +1,978 @@ +{ + "name": "Lua", + "scopeName": "source.lua", + "patterns": [ + { + "begin": "\\b(?:(local)\\s+)?(function)\\b(?![,:])", + "beginCaptures": { + "1": { + "name": "keyword.local.lua" + }, + "2": { + "name": "keyword.control.lua" + } + }, + "end": "(?<=[\\)\\-{}\\[\\]\"'])", + "name": "meta.function.lua", + "patterns": [ + { + "include": "#comment" + }, + { + "begin": "(\\()", + "beginCaptures": { + "1": { + "name": "punctuation.definition.parameters.begin.lua" + } + }, + "end": "(\\))|(?=[\\-{}\\[\\]\"'])|(?", + "captures": { + "0": { + "name": "storage.type.attribute.lua" + } + } + }, + { + "match": "\\<[a-zA-Z_\\*][a-zA-Z0-9_\\.\\*\\-]*\\>", + "name": "storage.type.generic.lua" + }, + { + "match": "\\b(break|do|else|for|if|elseif|goto|return|then|repeat|while|until|end|in)\\b", + "name": "keyword.control.lua" + }, + { + "match": "\\b(local)\\b", + "name": "keyword.local.lua" + }, + { + "match": "^\\s*(global)\\b(?!\\s*=)", + "captures": { + "1": { + "name": "keyword.global.lua" + } + } + }, + { + "match": "\\b(function)\\b(?![,:])", + "name": "keyword.control.lua" + }, + { + "match": "(?=?|(?|\\<", + "name": "keyword.operator.lua" + } + ] + }, + { + "begin": "(?<=---)[ \\t]*@see", + "beginCaptures": { + "0": { + "name": "storage.type.annotation.lua" + } + }, + "end": "(?=[\\n@#])", + "patterns": [ + { + "match": "\\b([a-zA-Z_\\*][a-zA-Z0-9_\\.\\*\\-]*)", + "name": "support.class.lua" + }, + { + "match": "#", + "name": "keyword.operator.lua" + } + ] + }, + { + "begin": "(?<=---)[ \\t]*@diagnostic", + "beginCaptures": { + "0": { + "name": "storage.type.annotation.lua" + } + }, + "end": "(?=[\\n@#])", + "patterns": [ + { + "begin": "([a-zA-Z_\\-0-9]+)[ \\t]*(:)?", + "beginCaptures": { + "1": { + "name": "keyword.other.unit" + }, + "2": { + "name": "keyword.operator.unit" + } + }, + "end": "(?=\\n)", + "patterns": [ + { + "match": "\\b([a-zA-Z_\\*][a-zA-Z0-9_\\-]*)", + "name": "support.class.lua" + }, + { + "match": ",", + "name": "keyword.operator.lua" + } + ] + } + ] + }, + { + "begin": "(?<=---)[ \\t]*@module", + "beginCaptures": { + "0": { + "name": "storage.type.annotation.lua" + } + }, + "end": "(?=[\\n@#])", + "patterns": [ + { + "include": "#string" + } + ] + }, + { + "match": "(?<=---)[ \\t]*@(async|nodiscard)", + "name": "storage.type.annotation.lua" + }, + { + "begin": "(?<=---)\\|\\s*[\\>\\+]?", + "beginCaptures": { + "0": { + "name": "storage.type.annotation.lua" + } + }, + "end": "(?=[\\n@#])", + "patterns": [ + { + "include": "#string" + } + ] + } + ] + }, + "emmydoc.type": { + "patterns": [ + { + "begin": "\\bfun\\b", + "beginCaptures": { + "0": { + "name": "keyword.control.lua" + } + }, + "end": "(?=[\\s#])", + "patterns": [ + { + "match": "[\\(\\),\\:\\?\\[\\]\\<\\>][ \\t]*", + "name": "keyword.operator.lua" + }, + { + "match": "([a-zA-Z_][a-zA-Z0-9_\\.\\*\\-]*)(?", + "name": "storage.type.generic.lua" + }, + { + "match": "\\basync\\b", + "name": "entity.name.tag.lua" + }, + { + "match": "[\\{\\}\\:\\,\\?\\|\\`][ \\t]*", + "name": "keyword.operator.lua" + }, + { + "begin": "(?=[a-zA-Z_\\.\\*\"'\\[])", + "end": "(?=[\\s\\)\\,\\?\\:\\}\\|#])", + "patterns": [ + { + "match": "([a-zA-Z0-9_\\.\\*\\[\\]\\<\\>\\,\\-]+)(? _completionController.CloseWindow(); + + private void CloseSharedCompletionWindow() + => base.CloseCompletionWindowCore(); + + private void ScheduleCompletionRequest() + => _completionController.ScheduleRequest(); + + private void CancelPendingCompletionRequest() + => _completionController.CancelPendingRequest(); + + private Task RequestCompletionAsync(int offset, char? triggerCharacter) + => _completionController.RequestAsync(offset, triggerCharacter); + + private bool CanApplyCompletionItem(LuaCompletionItem item) + => IsCompletionItemCurrent(item.RequestDocumentVersion, _editorDocumentVersion, + item.RequestGeneration, _editorRequestGeneration, IsLoaded, IsIntellisenseAvailable()); + + private void RebaseOpenCompletionItems() + => _completionController.RebaseOpenCompletionItems(); + + private static bool IsCompletionItemCurrent(int? requestDocumentVersion, + int currentDocumentVersion, + int? requestGeneration, + int currentGeneration, + bool isEditorLoaded, + bool isIntellisenseAvailable) + { + if (!isEditorLoaded || !isIntellisenseAvailable) + return false; + + if (!requestDocumentVersion.HasValue && !requestGeneration.HasValue) + return true; + + if (!requestDocumentVersion.HasValue || !requestGeneration.HasValue) + return false; + + return requestDocumentVersion.Value == currentDocumentVersion + && requestGeneration.Value == currentGeneration; + } + + private void ScheduleCloseIfEmpty() + => _completionController.ScheduleCloseIfEmpty(); + + /// + /// Owns Lua completion scheduling, popup lifecycle, tooltip resolution, and AvalonEdit-specific completion-window behavior. + /// + private sealed class LuaCompletionController + { + private const double CompletionRequestDebounceDelayInMilliseconds = 120.0; + private const int CompletionWindowMinWidth = 420; + private const int CompletionWindowMaxWidth = 920; + private const int CompletionWindowHeight = 320; + private const int CompletionWidthMeasurementSampleCount = 80; + private const double CompletionToolTipHorizontalOffset = 10.0; + private const double CompletionToolTipResolveDelayInMilliseconds = 120.0; + private const double CompletionWindowHorizontalChrome = 52.0; + private const double CompletionItemIconWidth = 24.0; + private const double CompletionItemDetailSpacing = 12.0; + + private static readonly Lazy CompletionToolTipFieldAccessor = new(() => + { + FieldInfo? field = typeof(CompletionWindow).GetField("toolTip", BindingFlags.NonPublic | BindingFlags.Instance); + + if (field is null) + Log?.Debug("AvalonEdit completion tooltip styling is unavailable because the internal tooltip field could not be found."); + + return field; + }); + + private static FieldInfo? CompletionToolTipField => CompletionToolTipFieldAccessor.Value; + + private readonly LuaEditor _editor; + private readonly DispatcherTimer _completionRequestTimer = new(); + private readonly DispatcherTimer _completionToolTipUpdateTimer = new(); + private int _completionRequestToken; + private int _completionToolTipUpdateToken; + private ToolTip? _pendingCompletionToolTip; + + internal LuaCompletionController(LuaEditor editor) + { + _editor = editor; + } + + internal void InitializeScheduling() + { + _completionRequestTimer.Interval = TimeSpan.FromMilliseconds(CompletionRequestDebounceDelayInMilliseconds); + _completionRequestTimer.Tick -= CompletionRequestTimer_Tick; + _completionRequestTimer.Tick += CompletionRequestTimer_Tick; + + _completionToolTipUpdateTimer.Interval = TimeSpan.FromMilliseconds(CompletionToolTipResolveDelayInMilliseconds); + _completionToolTipUpdateTimer.Tick -= CompletionToolTipUpdateTimer_Tick; + _completionToolTipUpdateTimer.Tick += CompletionToolTipUpdateTimer_Tick; + } + + internal void CloseWindow() + { + InvalidateRequests(); + CloseWindowCore(); + } + + private void CloseWindowForRefresh() + => CloseWindowCore(); + + internal void ScheduleRequest() + { + _completionRequestTimer.Stop(); + _completionRequestTimer.Start(); + } + + internal void CancelPendingRequest() + => _completionRequestTimer.Stop(); + + internal async Task RequestAsync(int offset, char? triggerCharacter) + { + CancellationToken cancellationToken = CancellationToken.None; + int requestToken = ++_completionRequestToken; + int requestDocumentVersion = _editor._editorDocumentVersion; + int requestGeneration = _editor._editorRequestGeneration; + + try + { + if (!_editor.IsIntellisenseAvailable()) + return; + + var intellisenseProvider = _editor.IntellisenseProvider; + + if (intellisenseProvider is null) + return; + + _editor.DismissSignatureHelp(); + _editor.CloseDefinitionToolTip(true); + + (int line, int column) = _editor.GetPositionFromOffset(offset); + + IReadOnlyList items = await intellisenseProvider + .GetCompletionItemsAsync(_editor.FilePath, _editor.Text, line, column, triggerCharacter, cancellationToken) + .ConfigureAwait(true); + + if (!_editor.IsAsyncEditorResultCurrent(cancellationToken, requestToken, _completionRequestToken, + requestDocumentVersion, requestGeneration)) + { + return; + } + + if (items.Count == 0) + { + CloseWindow(); + return; + } + + var completionDataItems = new LuaCompletionData[items.Count]; + LuaThemeBrushSet brushSet = _editor.GetThemeBrushSet(); + + for (int i = 0; i < items.Count; i++) + { + LuaCompletionItem completionItem = items[i].WithRequestContext(requestDocumentVersion, requestGeneration); + completionDataItems[i] = new LuaCompletionData(completionItem, brushSet, _editor.CanApplyCompletionItem); + } + + if (!_editor.IsAsyncEditorResultCurrent(cancellationToken, requestToken, _completionRequestToken, + requestDocumentVersion, requestGeneration)) + { + return; + } + + CloseWindowForRefresh(); + InitializeWindow(); + + if (_editor._completionWindow is null) + return; + + ResizeWindow(completionDataItems); + SetWindowOffsets(offset); + + foreach (LuaCompletionData completionDataItem in completionDataItems) + _editor._completionWindow.CompletionList.CompletionData.Add(completionDataItem); + + if (_editor._completionWindow.CompletionList.CompletionData.Count > 0) + { + _editor.ShowCompletionWindow(); + ScheduleInitialSelection(); + } + } + catch (OperationCanceledException) + { + } + catch (Exception exception) + { + CloseWindow(); + LogEditorFailure("Completion request", exception); + } + } + + internal void RebaseOpenCompletionItems() + { + if (_editor._completionWindow?.CompletionList?.CompletionData is null) + return; + + for (int i = 0; i < _editor._completionWindow.CompletionList.CompletionData.Count; i++) + { + if (_editor._completionWindow.CompletionList.CompletionData[i] is LuaCompletionData completionData) + completionData.RebaseForCurrentDocument(_editor._editorDocumentVersion, _editor._editorRequestGeneration); + } + } + + private Task UpdateTooltipAsync(ToolTip tooltip, int updateToken) + => UpdateTooltipCoreAsync(tooltip, updateToken); + + internal void ScheduleCloseIfEmpty() + => _editor.Dispatcher.BeginInvoke(new Action(() => CloseWindowIfEmpty()), DispatcherPriority.Background); + + internal void InvalidateRequests() + => _completionRequestToken++; + + private void InitializeWindow() + { + _editor.InitializeCompletionWindow(CompletionWindowMinWidth, CompletionWindowHeight); + + if (_editor._completionWindow is null) + return; + + LuaCompletionWindowStyle.Apply(_editor._completionWindow, _editor.GetThemeBrushSet()); + StyleTooltip(); + MakeWindowNonActivatable(); + } + + private void CloseWindowCore() + { + CancelPendingRequest(); + CancelTooltipUpdate(); + + if (_editor._completionWindow is null) + return; + + if (CompletionToolTipField?.GetValue(_editor._completionWindow) is ToolTip tooltip) + tooltip.IsOpen = false; + + _editor.CloseSharedCompletionWindow(); + } + + private async void CompletionRequestTimer_Tick(object? sender, EventArgs e) + { + _completionRequestTimer.Stop(); + + if (!_editor.AutocompleteEnabled || !_editor.IsIntellisenseAvailable()) + return; + + if (!LuaEditorInteractionRules.IsValidAutocompleteContext(_editor.Document, _editor.CaretOffset, triggerCharacter: null)) + return; + + await RequestAsync(_editor.CaretOffset, null).ConfigureAwait(true); + } + + private void StyleTooltip() + { + if (_editor._completionWindow?.CompletionList.ListBox is not ListBox listBox) + return; + + if (CompletionToolTipField?.GetValue(_editor._completionWindow) is not ToolTip tooltip) + return; + + tooltip.Background = DefaultToolTipBackground; + tooltip.BorderBrush = DefaultToolTipBorder; + tooltip.BorderThickness = new Thickness(0.0); + tooltip.Padding = new Thickness(0.0); + tooltip.PlacementTarget = listBox; + tooltip.Placement = PlacementMode.Right; + tooltip.HorizontalOffset = CompletionToolTipHorizontalOffset; + tooltip.StaysOpen = true; + + listBox.SelectionChanged += (s, e) => ScheduleTooltipUpdate(tooltip); + listBox.PreviewMouseLeftButtonUp += (s, e) => HandleCompletionListClick(listBox, tooltip, e); + } + + private void HandleCompletionListClick(ListBox listBox, ToolTip tooltip, MouseButtonEventArgs e) + { + ListBoxItem? listBoxItem = (e.OriginalSource as DependencyObject)?.FindVisualAncestorOrSelf(); + + if (listBoxItem is null) + return; + + if (!ReferenceEquals(listBox.SelectedItem, listBoxItem.DataContext)) + listBox.SelectedItem = listBoxItem.DataContext; + + listBox.ScrollIntoView(listBoxItem.DataContext); + ScheduleTooltipUpdate(tooltip); + } + + private void ScheduleTooltipUpdate(ToolTip tooltip) + { + _pendingCompletionToolTip = tooltip; + _completionToolTipUpdateToken++; + _completionToolTipUpdateTimer.Stop(); + _completionToolTipUpdateTimer.Start(); + } + + private async void CompletionToolTipUpdateTimer_Tick(object? sender, EventArgs e) + { + _completionToolTipUpdateTimer.Stop(); + + if (_pendingCompletionToolTip is not ToolTip tooltip) + return; + + await UpdateTooltipCoreAsync(tooltip, _completionToolTipUpdateToken).ConfigureAwait(true); + } + + private async Task UpdateTooltipCoreAsync(ToolTip tooltip, int updateToken) + { + if (_editor._completionWindow?.CompletionList.ListBox is not ListBox listBox) + return; + + if (listBox.SelectedItem is not ICompletionData item) + { + tooltip.IsOpen = false; + return; + } + + try + { + object? description = item.Description; + + if (description is not null) + ApplyTooltipContent(tooltip, description); + else + tooltip.IsOpen = false; + + if (item is LuaCompletionData luaCompletionData && luaCompletionData.CanResolve) + { + if (updateToken != _completionToolTipUpdateToken) + return; + + object? resolvedDescription = await luaCompletionData.GetDescriptionAsync().ConfigureAwait(true); + + if (updateToken != _completionToolTipUpdateToken) + return; + + if (_editor._completionWindow?.CompletionList.ListBox is not ListBox currentListBox + || !ReferenceEquals(currentListBox, listBox) + || !ReferenceEquals(currentListBox.SelectedItem, item)) + { + return; + } + + if (resolvedDescription is not null) + ApplyTooltipContent(tooltip, resolvedDescription); + else + tooltip.IsOpen = false; + } + } + catch (Exception exception) + { + tooltip.IsOpen = false; + LogEditorFailure("Completion tooltip update", exception); + } + } + + private void ResizeWindow(LuaCompletionData[] completionDataItems) + { + if (_editor._completionWindow is null || completionDataItems.Length == 0) + return; + + double requiredWidth = CompletionWindowMinWidth; + int measurementCount = Math.Min(completionDataItems.Length, CompletionWidthMeasurementSampleCount); + var textWidthCache = new Dictionary(StringComparer.Ordinal); + + for (int i = 0; i < measurementCount; i++) + requiredWidth = Math.Max(requiredWidth, MeasureItemWidth(completionDataItems[i], textWidthCache)); + + _editor._completionWindow.Width = Math.Max( + CompletionWindowMinWidth, + Math.Min(CompletionWindowMaxWidth, requiredWidth + CompletionWindowHorizontalChrome)); + } + + private double MeasureItemWidth(LuaCompletionData completionData, IDictionary textWidthCache) + { + double width = CompletionItemIconWidth + MeasureTextWidth(completionData.DisplayText, textWidthCache); + + if (!string.IsNullOrWhiteSpace(completionData.DisplayDetail)) + width += CompletionItemDetailSpacing + MeasureTextWidth(completionData.DisplayDetail, textWidthCache); + + return width; + } + + private double MeasureTextWidth(string text, IDictionary textWidthCache) + { + if (string.IsNullOrWhiteSpace(text)) + return 0.0; + + if (textWidthCache.TryGetValue(text, out double cachedWidth)) + return cachedWidth; + + double pixelsPerDip = VisualTreeHelper.GetDpi(_editor).PixelsPerDip; + + var formattedText = new FormattedText( + text, + CultureInfo.CurrentUICulture, + FlowDirection.LeftToRight, + new Typeface(_editor.FontFamily, FontStyles.Normal, FontWeights.Normal, FontStretches.Normal), + _editor.FontSize, + _editor.Foreground, + pixelsPerDip); + + double width = formattedText.WidthIncludingTrailingWhitespace; + textWidthCache[text] = width; + return width; + } + + private static void ApplyTooltipContent(ToolTip tooltip, object content) + { + tooltip.Content = content; + + if (!tooltip.IsOpen) + { + tooltip.IsOpen = true; + } + else + { + tooltip.InvalidateMeasure(); + tooltip.InvalidateVisual(); + } + } + + internal void CancelTooltipUpdate() + { + _completionToolTipUpdateToken++; + _pendingCompletionToolTip = null; + _completionToolTipUpdateTimer.Stop(); + } + + private void ScheduleInitialSelection() + => _editor.Dispatcher.BeginInvoke(new Action(SelectInitialItem), DispatcherPriority.ContextIdle); + + private void SelectInitialItem() + { + if (_editor._completionWindow is null) + return; + + _editor._completionWindow.CompletionList.SelectItem(GetCompletionWindowQuery()); + CloseWindowIfEmpty(); + } + + private void MakeWindowNonActivatable() + { + if (_editor._completionWindow is null) + return; + + _editor._completionWindow.SourceInitialized += (s, e) => + { + if (s is Window window && PresentationSource.FromVisual(window) is HwndSource source) + source.AddHook(CompletionWindowWndProc); + }; + } + + private static IntPtr CompletionWindowWndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) + { + const int WM_MOUSEACTIVATE = 0x0021; + const int MA_NOACTIVATE = 3; + + if (msg == WM_MOUSEACTIVATE) + { + handled = true; + return new IntPtr(MA_NOACTIVATE); + } + + return IntPtr.Zero; + } + + private bool CloseWindowIfEmpty() + { + if (_editor._completionWindow is null) + return false; + + ListBox listBox = _editor._completionWindow.CompletionList.ListBox; + + if (listBox?.HasItems != false) + return false; + + CloseWindow(); + return true; + } + + private string GetCompletionWindowQuery() + { + if (_editor._completionWindow is null || _editor.Document is null) + return string.Empty; + + int startOffset = Math.Max(0, Math.Min(_editor._completionWindow.StartOffset, _editor.Document.TextLength)); + int endOffset = Math.Max(startOffset, Math.Min(_editor._completionWindow.EndOffset, _editor.Document.TextLength)); + + return endOffset > startOffset + ? _editor.Document.GetText(startOffset, endOffset - startOffset) + : string.Empty; + } + + private void SetWindowOffsets(int offset) + { + if (_editor._completionWindow is null) + return; + + int startOffset = Math.Max(0, Math.Min(offset, _editor.Document.TextLength)); + + while (startOffset > 0) + { + char currentChar = _editor.Document.GetCharAt(startOffset - 1); + + if (LuaLineParser.IsIdentifierCharacter(currentChar)) + startOffset--; + else + break; + } + + _editor._completionWindow.StartOffset = startOffset; + _editor._completionWindow.EndOffset = Math.Max(0, Math.Min(offset, _editor.Document.TextLength)); + } + } +} \ No newline at end of file diff --git a/TombLib/TombLib.Scripting.Lua/Editor/Hover/LuaHoverController.cs b/TombLib/TombLib.Scripting.Lua/Editor/Hover/LuaHoverController.cs new file mode 100644 index 0000000000..21058b4f18 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/Editor/Hover/LuaHoverController.cs @@ -0,0 +1,181 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using TombLib.Scripting.Lua.Objects; +using TombLib.Scripting.Rendering; +using TombLib.Scripting.Lua.Utils; +using TombLib.Scripting.Objects; + +namespace TombLib.Scripting.Lua; + +public sealed partial class LuaEditor +{ + protected override async void HandleMouseHover(MouseEventArgs e) + => await _hoverController.HandleMouseHoverAsync(e).ConfigureAwait(true); + + private static FrameworkElement CreateHoverToolTipContent(LuaHoverInfo hoverInfo) => hoverInfo.IsMarkdown + ? MarkdownToolTipRenderer.CreateContent(hoverInfo.Content, ToolTipForeground, DefaultToolTipBackground) + : MarkdownToolTipRenderer.CreatePlainTextContent(hoverInfo.Content, ToolTipForeground); + + /// + /// Owns Lua hover request state, request eligibility checks, and hover-versus-diagnostic tooltip presentation. + /// + private sealed class LuaHoverController + { + private readonly LuaEditor _editor; + private CancellationTokenSource? _hoverCancellationTokenSource; + private int _hoverRequestToken; + + internal LuaHoverController(LuaEditor editor) + { + _editor = editor; + } + + internal async Task HandleMouseHoverAsync(MouseEventArgs e) + { + int hoveredOffset = _editor.GetOffsetFromPoint(e.GetPosition(_editor)); + + if (hoveredOffset == -1) + return; + + bool hasDiagnostic = _editor.TryGetDiagnosticInfo(hoveredOffset, out string? diagnosticMessage, out TextEditorDiagnosticSeverity diagnosticSeverity, allowLineFallback: false); + bool canShowDiagnosticFallback = _editor._completionWindow is null && !_editor._signatureHelpController.IsVisible; + + if (!TryGetRequestOffset(hoveredOffset, out int hoverOffset) || !_editor.IsIntellisenseAvailable()) + { + ShowDiagnosticToolTipIfAvailable(canShowDiagnosticFallback, hasDiagnostic, diagnosticMessage, diagnosticSeverity); + return; + } + + CancellationToken cancellationToken = ResetCancellationTokenSource(ref _hoverCancellationTokenSource); + int hoverRequestToken = ++_hoverRequestToken; + + try + { + LuaHoverInfo? hoverInfo = await RequestAsync(hoverOffset, cancellationToken).ConfigureAwait(true); + + if (cancellationToken.IsCancellationRequested || hoverRequestToken != _hoverRequestToken) + return; + + int currentHoveredOffset = _editor.GetOffsetFromPoint(Mouse.GetPosition(_editor)); + + if (!LuaEditorInteractionRules.TryGetHoverOffset(_editor.Document, currentHoveredOffset, out int currentHoverOffset) + || currentHoverOffset != hoverOffset) + { + return; + } + + hasDiagnostic = _editor.TryGetDiagnosticInfo(currentHoveredOffset, out diagnosticMessage, out diagnosticSeverity, allowLineFallback: false); + ShowBestToolTip(hoverInfo, hasDiagnostic, diagnosticMessage, diagnosticSeverity); + } + catch (OperationCanceledException) + { + } + catch (Exception exception) + { + LogEditorFailure("Hover request", exception); + ShowDiagnosticToolTipIfAvailable(canShowDiagnosticFallback, hasDiagnostic, diagnosticMessage, diagnosticSeverity); + } + } + + internal void CancelPendingRequest() + => CancelAndDispose(ref _hoverCancellationTokenSource); + + internal void InvalidateRequests() + => _hoverRequestToken++; + + private bool TryGetRequestOffset(int hoveredOffset, out int hoverOffset) + { + hoverOffset = 0; + + if (!LuaEditorInteractionRules.CanRequestHover(_editor._completionWindow is not null, _editor._signatureHelpController.IsVisible)) + return false; + + if (!LuaEditorInteractionRules.TryGetHoverOffset(_editor.Document, hoveredOffset, out hoverOffset)) + return false; + + return !string.IsNullOrWhiteSpace(_editor.GetWordFromOffset(hoverOffset)); + } + + private void ShowDiagnosticToolTipIfAvailable(bool canShowDiagnosticFallback, bool hasDiagnostic, string? diagnosticMessage, TextEditorDiagnosticSeverity diagnosticSeverity) + { + if (canShowDiagnosticFallback && hasDiagnostic && !string.IsNullOrWhiteSpace(diagnosticMessage)) + _editor.ShowDiagnosticToolTip(diagnosticMessage, diagnosticSeverity); + } + + private void ShowBestToolTip(LuaHoverInfo? hoverInfo, bool hasDiagnostic, string? diagnosticMessage, TextEditorDiagnosticSeverity diagnosticSeverity) + { + bool canShowToolTip = _editor._completionWindow is null && !_editor._signatureHelpController.IsVisible; + bool hasDisplayableDiagnostic = hasDiagnostic && !string.IsNullOrWhiteSpace(diagnosticMessage); + string diagnosticText = diagnosticMessage ?? string.Empty; + + if (!canShowToolTip) + return; + + LuaHoverInfo? displayableHoverInfo = hoverInfo is not null && !string.IsNullOrWhiteSpace(hoverInfo.Content) + ? hoverInfo + : null; + + if (displayableHoverInfo is not null && hasDisplayableDiagnostic) + ShowCombinedToolTip(displayableHoverInfo, diagnosticText, diagnosticSeverity); + else if (displayableHoverInfo is not null) + ShowHoverToolTip(displayableHoverInfo); + else if (hasDisplayableDiagnostic) + _editor.ShowDiagnosticToolTip(diagnosticText, diagnosticSeverity); + } + + private void ShowCombinedToolTip(LuaHoverInfo hoverInfo, string diagnosticMessage, TextEditorDiagnosticSeverity severity) + { + GetDiagnosticToolTipColors(severity, out SolidColorBrush diagnosticBorder, out SolidColorBrush diagnosticBackground); + + var panel = new StackPanel { MaxWidth = ToolTipTextMaxWidth }; + + panel.Children.Add(CreateHoverToolTipContent(hoverInfo)); + + panel.Children.Add(new Border + { + Background = diagnosticBackground, + BorderBrush = diagnosticBorder, + BorderThickness = new Thickness(1.0), + CornerRadius = new CornerRadius(3.0), + Padding = new Thickness(8.0, 4.0, 8.0, 4.0), + Margin = new Thickness(0.0, 6.0, 0.0, 0.0), + Child = new TextBlock + { + Text = diagnosticMessage, + Foreground = ToolTipForeground, + FontFamily = SystemFonts.MessageFontFamily, + FontSize = ToolTipTextFontSize, + TextWrapping = TextWrapping.Wrap, + MaxWidth = ToolTipTextMaxWidth + } + }); + + _editor.ShowToolTip(panel, DefaultToolTipBorder, DefaultToolTipBackground); + } + + private void ShowHoverToolTip(LuaHoverInfo hoverInfo) + => _editor.ShowToolTip(CreateHoverToolTipContent(hoverInfo), DefaultToolTipBorder, DefaultToolTipBackground); + + private async Task RequestAsync(int offset, CancellationToken cancellationToken) + { + if (!_editor.IsIntellisenseAvailable()) + return null; + + var intellisenseProvider = _editor.IntellisenseProvider; + + if (intellisenseProvider is null) + return null; + + (int line, int column) = _editor.GetPositionFromOffset(offset); + + return await intellisenseProvider + .GetHoverAsync(_editor.FilePath, _editor.Text, line, column, cancellationToken) + .ConfigureAwait(true); + } + } +} \ No newline at end of file diff --git a/TombLib/TombLib.Scripting.Lua/Editor/Navigation/LuaDefinitionNavigationController.cs b/TombLib/TombLib.Scripting.Lua/Editor/Navigation/LuaDefinitionNavigationController.cs new file mode 100644 index 0000000000..12c1aef7e7 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/Editor/Navigation/LuaDefinitionNavigationController.cs @@ -0,0 +1,85 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using TombLib.Scripting.Lua.Objects; +using TombLib.Scripting.Lua.Utils; + +namespace TombLib.Scripting.Lua; + +public sealed partial class LuaEditor +{ + /// + /// Owns Lua definition-navigation request state and the editor-side flow for F12 and Ctrl+Click navigation. + /// + private sealed class LuaDefinitionNavigationController + { + private readonly LuaEditor _editor; + private CancellationTokenSource? _definitionCancellationTokenSource; + private int _definitionRequestToken; + + internal LuaDefinitionNavigationController(LuaEditor editor) + { + _editor = editor; + } + + internal void CancelPendingRequest() + => CancelAndDispose(ref _definitionCancellationTokenSource); + + internal void InvalidateRequests() + => _definitionRequestToken++; + + internal async Task TryNavigateAsync(int offset, CancellationToken cancellationToken) + { + if (!_editor.IsIntellisenseAvailable()) + return false; + + var intellisenseProvider = _editor.IntellisenseProvider; + + if (intellisenseProvider is null) + return false; + + try + { + if (!LuaEditorInteractionRules.TryGetDefinitionStartOffset(_editor.Document, offset, out int definitionOffset)) + return false; + + CancellationToken definitionCancellationToken = ResetCancellationTokenSource(ref _definitionCancellationTokenSource); + using CancellationTokenSource? linkedCancellationTokenSource = cancellationToken.CanBeCanceled + ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, definitionCancellationToken) + : null; + + CancellationToken effectiveCancellationToken = linkedCancellationTokenSource?.Token ?? definitionCancellationToken; + int requestToken = ++_definitionRequestToken; + int requestDocumentVersion = _editor._editorDocumentVersion; + int requestGeneration = _editor._editorRequestGeneration; + + (int line, int column) = _editor.GetPositionFromOffset(definitionOffset); + + LuaDefinitionLocation? definitionLocation = await intellisenseProvider + .GetDefinitionAsync(_editor.FilePath, _editor.Text, line, column, effectiveCancellationToken) + .ConfigureAwait(true); + + if (!_editor.IsAsyncEditorResultCurrent(effectiveCancellationToken, requestToken, _definitionRequestToken, + requestDocumentVersion, requestGeneration)) + { + return false; + } + + if (definitionLocation is null) + return false; + + _editor.DefinitionNavigationRequested?.Invoke(definitionLocation); + return true; + } + catch (OperationCanceledException) + { + return false; + } + catch (Exception exception) + { + LogEditorFailure("Go to definition", exception); + return false; + } + } + } +} \ No newline at end of file diff --git a/TombLib/TombLib.Scripting.Lua/Editor/SignatureHelp/LuaSignatureHelpController.cs b/TombLib/TombLib.Scripting.Lua/Editor/SignatureHelp/LuaSignatureHelpController.cs new file mode 100644 index 0000000000..9138617788 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/Editor/SignatureHelp/LuaSignatureHelpController.cs @@ -0,0 +1,357 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Documents; +using System.Windows.Media; +using System.Windows.Threading; +using TombLib.Scripting.Lua.Objects; +using TombLib.Scripting.Lua.Resources; + +namespace TombLib.Scripting.Lua; + +public sealed partial class LuaEditor +{ + private const double SignaturePopupFontSize = 14.0; + + private void DismissSignatureHelp() + => _signatureHelpController.Dismiss(); + + private Task RequestSignatureHelpAsync(int offset) + => _signatureHelpController.RequestAsync(offset); + + private void ScheduleSignatureHelpRefresh() + => _signatureHelpController.ScheduleRefresh(); + + private static TextBlock CreateSignatureDocumentationBlock(string text, LuaThemeBrushSet brushSet) => new() + { + Text = text, + Foreground = brushSet.SignatureParamDocForeground, + FontFamily = SystemFonts.MessageFontFamily, + FontSize = Math.Max(SystemFonts.MessageFontSize + 1.0, SignaturePopupFontSize), + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(0.0, 4.0, 0.0, 0.0) + }; + + private static TextBlock BuildSignatureBlock(LuaSignatureInfo signatureInfo, LuaThemeBrushSet brushSet) + { + var textBlock = new TextBlock + { + FontFamily = new FontFamily("Consolas"), + FontSize = Math.Max(SystemFonts.MessageFontSize + 1.0, SignaturePopupFontSize), + TextWrapping = TextWrapping.Wrap, + Foreground = brushSet.SignatureForeground + }; + + string label = signatureInfo.Label; + + if (signatureInfo.Parameters.Count == 0 + || !TryGetActiveParameterRange(label, signatureInfo, out int activeStart, out int activeEnd)) + { + textBlock.Text = label; + return textBlock; + } + + if (activeStart > 0) + textBlock.Inlines.Add(new Run(label[..activeStart])); + + textBlock.Inlines.Add(new Run(label[activeStart..activeEnd]) + { + FontWeight = FontWeights.Bold, + Foreground = brushSet.SignatureActiveParamForeground + }); + + if (activeEnd < label.Length) + textBlock.Inlines.Add(new Run(label[activeEnd..])); + + return textBlock; + } + + private static bool TryGetActiveParameterRange(string label, LuaSignatureInfo signatureInfo, out int activeStart, out int activeEnd) + { + activeStart = 0; + activeEnd = 0; + + int activeIndex = Math.Min(signatureInfo.ActiveParameter, signatureInfo.Parameters.Count - 1); + + if (activeIndex < 0) + return false; + + string activeLabel = signatureInfo.Parameters[activeIndex].Label; + + if (string.IsNullOrEmpty(activeLabel)) + return false; + + int searchStart = label.IndexOf('('); + searchStart = searchStart < 0 ? 0 : searchStart + 1; + + int matchIndex = label.IndexOf(activeLabel, searchStart, StringComparison.Ordinal); + + if (matchIndex < 0) + return false; + + activeStart = matchIndex; + activeEnd = matchIndex + activeLabel.Length; + return true; + } + + /// + /// Owns signature-help popup state, refresh scheduling, and provider request flow for Lua call-site assistance. + /// + private sealed class LuaSignatureHelpController + { + private const double SignatureHelpRefreshDebounceDelayInMilliseconds = 50.0; + + private readonly LuaEditor _editor; + private int _signatureRequestToken; + private int _pendingSignatureHelpOffset = -1; + private bool _signatureRefreshPending; + private bool _signatureRequestInFlight; + + private readonly Popup _signaturePopup = new(); + private readonly Border _signaturePopupBorder = new(); + private readonly ContentPresenter _signaturePopupPresenter = new(); + private readonly DispatcherTimer _signatureRefreshTimer = new(); + + internal LuaSignatureHelpController(LuaEditor editor) + { + _editor = editor; + } + + internal bool IsVisible => _signaturePopup.IsOpen; + + internal bool IsActiveOrPending => _signaturePopup.IsOpen || _signatureRequestInFlight || _signatureRefreshPending; + + internal void InitializePopup() + { + _signaturePopup.AllowsTransparency = true; + _signaturePopup.PopupAnimation = PopupAnimation.None; + _signaturePopup.StaysOpen = true; + _signaturePopup.Placement = PlacementMode.RelativePoint; + + _signaturePopupBorder.SnapsToDevicePixels = true; + _signaturePopupBorder.CornerRadius = new CornerRadius(3.0); + _signaturePopupBorder.BorderThickness = new Thickness(1.0); + _signaturePopupBorder.Padding = new Thickness(8.0, 6.0, 8.0, 6.0); + _signaturePopupBorder.BorderBrush = DefaultToolTipBorder; + _signaturePopupBorder.Background = DefaultToolTipBackground; + _signaturePopupBorder.Child = _signaturePopupPresenter; + + _signaturePopup.Child = _signaturePopupBorder; + + _signatureRefreshTimer.Interval = TimeSpan.FromMilliseconds(SignatureHelpRefreshDebounceDelayInMilliseconds); + _signatureRefreshTimer.Tick -= SignatureRefreshTimer_Tick; + _signatureRefreshTimer.Tick += SignatureRefreshTimer_Tick; + } + + internal void Dismiss() + { + CancelPendingRefresh(); + InvalidateRequests(); + + if (_signaturePopup.IsOpen) + _signaturePopup.IsOpen = false; + + _signaturePopupPresenter.Content = null; + } + + internal Task RequestAsync(int offset) + => RequestAsyncCore(offset); + + internal void ScheduleRefresh() + { + _pendingSignatureHelpOffset = _editor.CaretOffset; + _signatureRefreshPending = true; + _signatureRefreshTimer.Stop(); + _signatureRefreshTimer.Start(); + } + + internal void CancelPendingRefresh() + { + _signatureRefreshTimer.Stop(); + _signatureRefreshPending = false; + _pendingSignatureHelpOffset = -1; + } + + internal void InvalidateRequests() + { + _signatureRequestToken++; + _signatureRequestInFlight = false; + } + + private void HandleRefreshTimerTick(object? sender, EventArgs e) + => SignatureRefreshTimer_Tick(sender, e); + + private void ShowToolTip(LuaSignatureInfo signatureInfo) + { + double availablePopupWidth = Math.Max(0.0, Math.Min(500.0, _editor.ActualWidth - 16.0)); + + double popupHorizontalPadding = _signaturePopupBorder.Padding.Left + + _signaturePopupBorder.Padding.Right + + _signaturePopupBorder.BorderThickness.Left + + _signaturePopupBorder.BorderThickness.Right; + + double popupVerticalPadding = _signaturePopupBorder.Padding.Top + + _signaturePopupBorder.Padding.Bottom + + _signaturePopupBorder.BorderThickness.Top + + _signaturePopupBorder.BorderThickness.Bottom; + + double contentMaxWidth = Math.Max(0.0, availablePopupWidth - popupHorizontalPadding); + + StackPanel panel = CreatePanel(signatureInfo, contentMaxWidth); + + panel.Measure(new Size(contentMaxWidth, double.PositiveInfinity)); + + Size popupSize = new( + Math.Min(availablePopupWidth, panel.DesiredSize.Width + popupHorizontalPadding), + panel.DesiredSize.Height + popupVerticalPadding); + + _signaturePopupPresenter.Content = panel; + _signaturePopupPresenter.InvalidateMeasure(); + _signaturePopupBorder.InvalidateMeasure(); + PositionPopup(popupSize); + + if (!_signaturePopup.IsOpen) + _signaturePopup.IsOpen = true; + } + + private StackPanel CreatePanel(LuaSignatureInfo signatureInfo, double contentMaxWidth) + { + LuaThemeBrushSet brushSet = _editor.GetThemeBrushSet(); + var panel = new StackPanel { MaxWidth = contentMaxWidth }; + panel.Children.Add(BuildSignatureBlock(signatureInfo, brushSet)); + + if (!string.IsNullOrWhiteSpace(signatureInfo.Documentation)) + panel.Children.Add(CreateSignatureDocumentationBlock(signatureInfo.Documentation, brushSet)); + + if (signatureInfo.ActiveParameter < signatureInfo.Parameters.Count) + { + LuaParameterInfo activeParameter = signatureInfo.Parameters[signatureInfo.ActiveParameter]; + + if (!string.IsNullOrWhiteSpace(activeParameter.Documentation)) + panel.Children.Add(CreateSignatureDocumentationBlock(activeParameter.Label + ": " + activeParameter.Documentation, brushSet)); + } + + return panel; + } + + private void PositionPopup(Size popupSize) + { + _editor.AttachHostWindowHandlers(); + _signaturePopup.PlacementTarget = _editor; + _editor.TextArea.TextView.EnsureVisualLines(); + + Rect caretRectangle = _editor.TextArea.Caret.CalculateCaretRectangle(); + Vector scrollOffset = _editor.TextArea.TextView.ScrollOffset; + + Point caretViewportPoint = new( + caretRectangle.X - scrollOffset.X, + caretRectangle.Y - scrollOffset.Y); + + Point editorPoint = _editor.TextArea.TextView.TranslatePoint(caretViewportPoint, _editor); + double lineHeight = Math.Max(_editor.TextArea.TextView.DefaultLineHeight, caretRectangle.Height); + double lineSlack = Math.Max(0.0, lineHeight - caretRectangle.Height); + double horizontalOffset = Math.Max(0.0, editorPoint.X + 2.0); + double maxHorizontalOffset = Math.Max(0.0, _editor.ActualWidth - popupSize.Width - 8.0); + horizontalOffset = Math.Min(horizontalOffset, maxHorizontalOffset); + + double verticalOffset = editorPoint.Y - popupSize.Height - lineSlack - 8.0; + + if (verticalOffset < 0.0) + verticalOffset = Math.Min(Math.Max(0.0, _editor.ActualHeight - popupSize.Height), editorPoint.Y + lineHeight + 4.0); + + _signaturePopup.HorizontalOffset = horizontalOffset; + _signaturePopup.VerticalOffset = verticalOffset; + } + + private async Task RequestAsyncCore(int offset) + { + if (_signatureRequestInFlight) + { + _pendingSignatureHelpOffset = offset; + _signatureRefreshPending = true; + return; + } + + _signatureRequestInFlight = true; + + if (!_editor.IsIntellisenseAvailable()) + { + _signatureRequestInFlight = false; + Dismiss(); + return; + } + + var intellisenseProvider = _editor.IntellisenseProvider; + + if (intellisenseProvider is null) + { + _signatureRequestInFlight = false; + Dismiss(); + return; + } + + CancellationToken cancellationToken = CancellationToken.None; + int requestToken = ++_signatureRequestToken; + int requestDocumentVersion = _editor._editorDocumentVersion; + int requestGeneration = _editor._editorRequestGeneration; + + try + { + (int line, int column) = _editor.GetPositionFromOffset(offset); + + LuaSignatureInfo? signatureInfo = await intellisenseProvider + .GetSignatureHelpAsync(_editor.FilePath, _editor.Text, line, column, cancellationToken) + .ConfigureAwait(true); + + if (!_editor.IsAsyncEditorResultCurrent(cancellationToken, requestToken, _signatureRequestToken, + requestDocumentVersion, requestGeneration)) + { + return; + } + + if (signatureInfo is null) + { + Dismiss(); + return; + } + + ShowToolTip(signatureInfo); + } + catch (OperationCanceledException) + { + } + catch (Exception exception) + { + LogEditorFailure("Signature help", exception); + } + finally + { + _signatureRequestInFlight = false; + + if (_signatureRefreshPending && _pendingSignatureHelpOffset >= 0) + { + _signatureRefreshTimer.Stop(); + _signatureRefreshTimer.Start(); + } + } + } + + private async void SignatureRefreshTimer_Tick(object? sender, EventArgs e) + { + _signatureRefreshTimer.Stop(); + + if (!_signatureRefreshPending || _pendingSignatureHelpOffset < 0) + return; + + if (_signatureRequestInFlight) + return; + + int offset = _pendingSignatureHelpOffset; + _signatureRefreshPending = false; + await RequestAsyncCore(offset).ConfigureAwait(true); + } + } +} \ No newline at end of file diff --git a/TombLib/TombLib.Scripting.Lua/Highlighting/LuaSemanticTokensColorizer.cs b/TombLib/TombLib.Scripting.Lua/Highlighting/LuaSemanticTokensColorizer.cs new file mode 100644 index 0000000000..0cfb613e7c --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/Highlighting/LuaSemanticTokensColorizer.cs @@ -0,0 +1,224 @@ +using ICSharpCode.AvalonEdit.Document; +using ICSharpCode.AvalonEdit.Rendering; +using System; +using System.Collections.Generic; +using System.Windows; +using System.Windows.Media; +using TombLib.Scripting.Lua.Objects; +using TombLib.Scripting.Lua.Resources; + +namespace TombLib.Scripting.Lua.Highlighting; + +/// +/// Applies Lua semantic-token styling on top of the editor's baseline syntax highlighting. +/// +internal sealed class LuaSemanticTokensColorizer : DocumentColorizingTransformer +{ + private static readonly TextDecorationCollection DeprecatedDecorations = CreateTextDecorations(TextDecorations.Strikethrough); + + private static readonly IReadOnlyDictionary> EmptyTokensByLine = + new Dictionary>(); + + private LuaThemeBrushSet _themeBrushSet; + private readonly TextView _textView; + + // Tokens are pre-styled at SetTokens time so that ColorizeLine, which runs on every redraw and + // for every visible line, can avoid re-resolving brushes and modifier flags per token. + private IReadOnlyList _rawTokens = []; + + private IReadOnlyDictionary> _tokensByLine = EmptyTokensByLine; + + /// + /// Initializes a new instance of the class. + /// + /// The text view that will be redrawn when semantic styles change. + /// The active Lua theme brush set. + public LuaSemanticTokensColorizer(TextView textView, LuaThemeBrushSet themeBrushSet) + { + _textView = textView; + _themeBrushSet = themeBrushSet; + } + + /// + /// Rebuilds the styled semantic-token cache for a new theme and redraws the text view. + /// + /// The new active theme brush set. + public void UpdateTheme(LuaThemeBrushSet themeBrushSet) + { + _themeBrushSet = themeBrushSet; + _tokensByLine = BuildStyledMap(_rawTokens, _themeBrushSet); + _textView.Redraw(); + } + + /// + /// Replaces the semantic tokens currently applied to the text view. + /// + /// The semantic tokens to render. + public void SetTokens(IReadOnlyList tokens) + { + if (tokens is null || tokens.Count == 0) + { + ClearTokens(); + return; + } + + _rawTokens = tokens; + _tokensByLine = BuildStyledMap(tokens, _themeBrushSet); + _textView.Redraw(); + } + + /// + /// Removes all semantic-token styling from the text view. + /// + public void ClearTokens() + { + if (_tokensByLine.Count == 0 && _rawTokens.Count == 0) + return; + + _rawTokens = []; + _tokensByLine = EmptyTokensByLine; + _textView.Redraw(); + } + + protected override void ColorizeLine(DocumentLine line) + { + if (!_tokensByLine.TryGetValue(line.LineNumber - 1, out IReadOnlyList? tokens)) + return; + + int lineLength = line.Length; + + for (int i = 0; i < tokens.Count; i++) + { + StyledSemanticToken styled = tokens[i]; + int startIndex = Math.Max(0, Math.Min(styled.Character, lineLength)); + int endIndex = Math.Max(startIndex, Math.Min(styled.Character + styled.Length, lineLength)); + + if (endIndex <= startIndex) + continue; + + LuaSemanticTokenStyle style = styled.Style; + ChangeLinePart(line.Offset + startIndex, line.Offset + endIndex, element => ApplyStyle(element, style)); + } + } + + private static IReadOnlyDictionary> BuildStyledMap( + IReadOnlyList tokens, LuaThemeBrushSet themeBrushSet) + { + if (tokens.Count == 0) + return EmptyTokensByLine; + + var grouped = new Dictionary>(); + + for (int i = 0; i < tokens.Count; i++) + { + LuaSemanticToken token = tokens[i]; + LuaSemanticTokenStyle style = ResolveStyle(token, themeBrushSet); + + if (!style.HasFormatting) + continue; + + if (!grouped.TryGetValue(token.Line, out List? lineTokens)) + { + lineTokens = []; + grouped[token.Line] = lineTokens; + } + + lineTokens.Add(new StyledSemanticToken(token.Character, token.Length, style)); + } + + var frozen = new Dictionary>(grouped.Count); + + foreach (KeyValuePair> pair in grouped) + { + pair.Value.Sort(static (left, right) => + { + int characterComparison = left.Character.CompareTo(right.Character); + return characterComparison != 0 ? characterComparison : left.Length.CompareTo(right.Length); + }); + + frozen[pair.Key] = pair.Value; + } + + return frozen; + } + + private static LuaSemanticTokenStyle ResolveStyle(LuaSemanticToken token, LuaThemeBrushSet brushSet) + { + Brush? foreground = token.Type switch + { + "namespace" => brushSet.TypeBrush, + "type" => brushSet.TypeBrush, + "class" => brushSet.TypeBrush, + "enum" => brushSet.TypeBrush, + "interface" => brushSet.TypeBrush, + "struct" => brushSet.TypeBrush, + "typeParameter" => brushSet.TypeBrush, + "function" => token.HasModifier("defaultLibrary") ? brushSet.TypeBrush : brushSet.MethodBrush, + "method" => token.HasModifier("defaultLibrary") ? brushSet.TypeBrush : brushSet.MethodBrush, + "parameter" => brushSet.VariableBrush, + "property" => brushSet.PropertyBrush, + "event" => brushSet.VariableBrush, + "enumMember" => brushSet.ConstantBrush, + "decorator" => brushSet.KeywordBrush, + "macro" => brushSet.KeywordBrush, + "variable" => ResolveVariableBrush(token, brushSet), + _ => null + }; + + return new LuaSemanticTokenStyle( + foreground, + token.HasModifier("declaration") && (token.Type == "function" || token.Type == "method"), + token.HasModifier("deprecated") ? DeprecatedDecorations : null); + } + + private static SolidColorBrush? ResolveVariableBrush(LuaSemanticToken token, LuaThemeBrushSet brushSet) + { + if (token.HasModifier("defaultLibrary")) + return brushSet.TypeBrush; + + if (token.HasModifier("global")) + return brushSet.PropertyBrush; + + return brushSet.VariableBrush; + } + + private static void ApplyStyle(VisualLineElement element, LuaSemanticTokenStyle style) + { + VisualLineElementTextRunProperties properties = element.TextRunProperties; + + if (style.Foreground is not null) + properties.SetForegroundBrush(style.Foreground); + + if (style.IsBold) + { + Typeface typeface = properties.Typeface; + properties.SetTypeface(new Typeface(typeface.FontFamily, typeface.Style, FontWeights.Bold, typeface.Stretch)); + } + + if (style.TextDecorations is not null) + properties.SetTextDecorations(style.TextDecorations); + } + + private static TextDecorationCollection CreateTextDecorations(TextDecorationCollection source) + { + var clone = source.Clone(); + clone.Freeze(); + return clone; + } + + private readonly struct StyledSemanticToken(int character, int length, LuaSemanticTokenStyle style) + { + public int Character { get; } = character; + public int Length { get; } = length; + public LuaSemanticTokenStyle Style { get; } = style; + } + + private readonly struct LuaSemanticTokenStyle(Brush? foreground, bool isBold, TextDecorationCollection? textDecorations) + { + public Brush? Foreground { get; } = foreground; + public bool IsBold { get; } = isBold; + public TextDecorationCollection? TextDecorations { get; } = textDecorations; + + public bool HasFormatting => Foreground is not null || IsBold || TextDecorations is not null; + } +} diff --git a/TombLib/TombLib.Scripting.Lua/LuaEditor.Intellisense.cs b/TombLib/TombLib.Scripting.Lua/LuaEditor.Intellisense.cs new file mode 100644 index 0000000000..cc9c3ef3fa --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/LuaEditor.Intellisense.cs @@ -0,0 +1,298 @@ +using ICSharpCode.AvalonEdit.Document; +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Windows; +using System.Windows.Input; +using TombLib.Scripting.Lua.Utils; + +namespace TombLib.Scripting.Lua; + +public sealed partial class LuaEditor +{ + private Window? _hostWindow; + + private void BindLuaIntellisenseEvents() + { + _completionController.InitializeScheduling(); + + Document.Changed += LuaEditor_DocumentChanged; + IsKeyboardFocusWithinChanged += LuaEditor_IsKeyboardFocusWithinChanged; + Loaded += LuaEditor_Loaded; + TextChanged += LuaEditor_TextChanged; + TextArea.TextEntering += TextArea_TextEntering; + TextArea.TextEntered += TextArea_TextEntered; + AddHandler(PreviewKeyDownEvent, new KeyEventHandler(TextEditor_KeyDown), true); + AddHandler(PreviewMouseDownEvent, new MouseButtonEventHandler(TextEditor_PreviewMouseDown), true); + AddHandler(PreviewMouseLeftButtonDownEvent, new MouseButtonEventHandler(TextEditor_PreviewMouseLeftButtonDown), true); + Unloaded += LuaEditor_Unloaded; + } + + private void LuaEditor_Loaded(object? sender, RoutedEventArgs e) + => AttachHostWindowHandlers(); + + private void LuaEditor_DocumentChanged(object? sender, DocumentChangeEventArgs e) + => ClearDiagnostics(); + + private void LuaEditor_TextChanged(object? sender, EventArgs e) + { + _editorDocumentVersion++; + RebaseOpenCompletionItems(); + } + + private void LuaEditor_IsKeyboardFocusWithinChanged(object? sender, DependencyPropertyChangedEventArgs e) + { + if (e.NewValue is bool hasKeyboardFocus && !hasKeyboardFocus) + { + CloseCompletionWindow(); + DismissTransientToolTips(); + } + } + + private void AttachHostWindowHandlers() + { + Window? window = Window.GetWindow(this); + + if (window == _hostWindow) + return; + + if (_hostWindow is not null) + _hostWindow.Deactivated -= HostWindow_Deactivated; + + _hostWindow = window; + + if (_hostWindow is not null) + _hostWindow.Deactivated += HostWindow_Deactivated; + } + + private void HostWindow_Deactivated(object? sender, EventArgs e) + { + CloseCompletionWindow(); + DismissTransientToolTips(); + } + + private void LuaEditor_Unloaded(object? sender, RoutedEventArgs e) + { + _textMateHighlighting?.Dispose(); + _textMateHighlighting = null; + InvalidateAsyncEditorRequests(); + + CancelPendingCompletionRequest(); + _hoverController.CancelPendingRequest(); + + _completionController.CancelTooltipUpdate(); + + _definitionNavigationController.CancelPendingRequest(); + CloseCompletionWindow(); + + if (_hostWindow is not null) + _hostWindow.Deactivated -= HostWindow_Deactivated; + + _hostWindow = null; + + DismissTransientToolTips(); + ClearDiagnostics(); + ClearSemanticTokens(); + + IntellisenseProvider?.CloseDocument(FilePath); + } + + private async void TextArea_TextEntering(object? sender, TextCompositionEventArgs e) + { + if (!AutocompleteEnabled || !IsIntellisenseAvailable()) + return; + + if (e.Text == " " && Keyboard.Modifiers.HasFlag(ModifierKeys.Control)) + { + e.Handled = true; + CancelPendingCompletionRequest(); + + if (LuaEditorInteractionRules.IsValidManualCompletionContext(Document, CaretOffset)) + await RequestCompletionAsync(CaretOffset, null).ConfigureAwait(true); + } + } + + private async void TextArea_TextEntered(object? sender, TextCompositionEventArgs e) + { + if (!IsIntellisenseAvailable()) + return; + + if (e.Text == "(" || e.Text == ",") + { + CancelPendingCompletionRequest(); + CloseCompletionWindow(); + _signatureHelpController.CancelPendingRefresh(); + await RequestSignatureHelpAsync(CaretOffset).ConfigureAwait(true); + return; + } + + if (e.Text == ")") + { + CancelPendingCompletionRequest(); + CloseCompletionWindow(); + DismissSignatureHelp(); + return; + } + + if (_completionWindow is not null) + { + CancelPendingCompletionRequest(); + + if (!ShouldKeepCompletionWindowOpen(e.Text)) + { + CloseCompletionWindow(); + } + else + { + ScheduleCloseIfEmpty(); + return; + } + } + + if (ShouldRefreshSignatureHelpAfterTextInput(e.Text)) + ScheduleSignatureHelpRefresh(); + + if (!AutocompleteEnabled) + return; + + if (TryGetCompletionTrigger(e.Text, out char? triggerCharacter) + && LuaEditorInteractionRules.IsValidAutocompleteContext(Document, CaretOffset, triggerCharacter)) + { + if (triggerCharacter is null) + { + ScheduleCompletionRequest(); + } + else + { + CancelPendingCompletionRequest(); + await RequestCompletionAsync(CaretOffset, triggerCharacter).ConfigureAwait(true); + } + } + } + + [MemberNotNullWhen(true, nameof(IntellisenseProvider))] + private bool IsIntellisenseAvailable() + => IntellisenseProvider?.IsAvailable == true && !string.IsNullOrWhiteSpace(FilePath); + + private static bool TryGetCompletionTrigger(string? inputText, out char? triggerCharacter) + { + triggerCharacter = null; + + if (string.IsNullOrEmpty(inputText) || inputText.Length != 1) + return false; + + char typedChar = inputText[0]; + + if (typedChar == '.' || typedChar == ':') + { + triggerCharacter = typedChar; + return true; + } + + return LuaLineParser.IsIdentifierTriggerCharacter(typedChar); + } + + private static bool ShouldKeepCompletionWindowOpen(string? inputText) + => inputText?.Length == 1 && LuaLineParser.IsIdentifierCharacter(inputText[0]); + + private void DismissTransientToolTips() + { + CancelPendingCompletionRequest(); + _hoverController.CancelPendingRequest(); + _hoverController.InvalidateRequests(); + DismissSignatureHelp(); + CloseDefinitionToolTip(true); + } + + private (int Line, int Column) GetPositionFromOffset(int offset) + { + int safeOffset = Math.Max(0, Math.Min(offset, Document.TextLength)); + TextLocation location = Document.GetLocation(safeOffset); + + return (location.Line - 1, location.Column - 1); + } + + private static CancellationToken ResetCancellationTokenSource(ref CancellationTokenSource? cancellationTokenSource) + { + CancelAndDispose(ref cancellationTokenSource); + cancellationTokenSource = new CancellationTokenSource(); + return cancellationTokenSource.Token; + } + + private static void CancelAndDispose(ref CancellationTokenSource? cancellationTokenSource) + { + if (cancellationTokenSource is null) + return; + + cancellationTokenSource.Cancel(); + cancellationTokenSource.Dispose(); + cancellationTokenSource = null; + } + + private static void LogEditorFailure(string area, Exception exception) + => Log.Warn(exception, "Lua editor operation '{Area}' failed.", area); + + protected override void OnAutoClosingElementSkipped(string element) + { + if (!ShouldDismissSignatureHelpOnAutoClosingSkip(element, ParenthesesClosingString)) + return; + + CancelPendingCompletionRequest(); + CloseCompletionWindow(); + DismissSignatureHelp(); + } + + private void InvalidateAsyncEditorRequests() + { + _editorRequestGeneration++; + _completionController.InvalidateRequests(); + _hoverController.InvalidateRequests(); + _signatureHelpController.InvalidateRequests(); + _definitionNavigationController.InvalidateRequests(); + } + + private bool IsAsyncEditorResultCurrent(CancellationToken cancellationToken, + int requestToken, + int currentRequestToken, + int requestDocumentVersion, + int requestGeneration) + { + return IsAsyncEditorResultCurrent( + cancellationToken.IsCancellationRequested, + requestToken, + currentRequestToken, + requestDocumentVersion, + _editorDocumentVersion, + requestGeneration, + _editorRequestGeneration, + IsLoaded, + IsIntellisenseAvailable()); + } + + private static bool IsAsyncEditorResultCurrent(bool isCancellationRequested, + int requestToken, + int currentRequestToken, + int requestDocumentVersion, + int currentDocumentVersion, + int requestGeneration, + int currentGeneration, + bool isEditorLoaded, + bool isIntellisenseAvailable) + { + return !isCancellationRequested + && requestToken == currentRequestToken + && requestDocumentVersion == currentDocumentVersion + && requestGeneration == currentGeneration + && isEditorLoaded + && isIntellisenseAvailable; + } + + private bool ShouldRefreshSignatureHelpAfterTextInput(string? inputText) + => ShouldRefreshSignatureHelpAfterTextInput(inputText, _signatureHelpController.IsActiveOrPending); + + private static bool ShouldRefreshSignatureHelpAfterTextInput(string? inputText, bool isSignatureHelpActiveOrPending) + => isSignatureHelpActiveOrPending && inputText?.Length == 1; + + private static bool ShouldDismissSignatureHelpOnAutoClosingSkip(string element, string parenthesesClosingString) + => string.Equals(element, parenthesesClosingString, StringComparison.Ordinal); +} diff --git a/TombLib/TombLib.Scripting.Lua/LuaEditor.Navigation.cs b/TombLib/TombLib.Scripting.Lua/LuaEditor.Navigation.cs new file mode 100644 index 0000000000..d88a517b20 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/LuaEditor.Navigation.cs @@ -0,0 +1,58 @@ +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Input; + +namespace TombLib.Scripting.Lua; + +public sealed partial class LuaEditor +{ + private async void TextEditor_KeyDown(object? sender, KeyEventArgs e) + { + if (e.Key == Key.Escape && (_completionWindow is not null || _signatureHelpController.IsVisible || _specialToolTip.IsOpen)) + { + CloseCompletionWindow(); + DismissTransientToolTips(); + e.Handled = true; + return; + } + + if (_signatureHelpController.IsVisible && (e.Key == Key.Back || e.Key == Key.Delete)) + ScheduleSignatureHelpRefresh(); + + if (e.Key == Key.F12) + { + if (await _definitionNavigationController.TryNavigateAsync(CaretOffset, CancellationToken.None).ConfigureAwait(true)) + e.Handled = true; + } + } + + private void TextEditor_PreviewMouseDown(object? sender, MouseButtonEventArgs e) + { + if (e.ChangedButton == MouseButton.Left || e.ChangedButton == MouseButton.Right) + { + CloseCompletionWindow(); + DismissTransientToolTips(); + } + } + + private async void TextEditor_PreviewMouseLeftButtonDown(object? sender, MouseButtonEventArgs e) + { + if (!Keyboard.Modifiers.HasFlag(ModifierKeys.Control) || e.ChangedButton != MouseButton.Left) + return; + + int hoveredOffset = GetOffsetFromPoint(e.GetPosition(this)); + + if (hoveredOffset == -1) + return; + + if (await _definitionNavigationController.TryNavigateAsync(hoveredOffset, CancellationToken.None).ConfigureAwait(true)) + e.Handled = true; + } + + /// + /// Attempts to resolve and navigate to the symbol definition at the current caret position. + /// + /// A task that completes once the navigation attempt finishes. + public Task NavigateToDefinitionAtCaretAsync() + => _definitionNavigationController.TryNavigateAsync(CaretOffset, CancellationToken.None); +} diff --git a/TombLib/TombLib.Scripting.Lua/LuaEditor.SemanticHighlighting.cs b/TombLib/TombLib.Scripting.Lua/LuaEditor.SemanticHighlighting.cs new file mode 100644 index 0000000000..28b49693bb --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/LuaEditor.SemanticHighlighting.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using TombLib.Scripting.Lua.Highlighting; +using TombLib.Scripting.Lua.Objects; + +namespace TombLib.Scripting.Lua; + +public sealed partial class LuaEditor +{ + private LuaSemanticTokensColorizer? _semanticTokensColorizer; + + /// + /// Replaces the current semantic token set used to colorize the document. + /// + /// The semantic tokens to apply to the editor. + public void SetSemanticTokens(IReadOnlyList tokens) + { + EnsureSemanticTokensColorizerAttached(); + _semanticTokensColorizer.SetTokens(tokens); + } + + /// + /// Removes all semantic token formatting from the current document. + /// + public void ClearSemanticTokens() + => _semanticTokensColorizer?.ClearTokens(); + + [MemberNotNull(nameof(_semanticTokensColorizer))] + private void EnsureSemanticTokensColorizerAttached() + { + if (_semanticTokensColorizer is null) + _semanticTokensColorizer = new LuaSemanticTokensColorizer(TextArea.TextView, GetThemeBrushSet()); + else + _semanticTokensColorizer.UpdateTheme(GetThemeBrushSet()); + + if (!TextArea.TextView.LineTransformers.Contains(_semanticTokensColorizer)) + TextArea.TextView.LineTransformers.Add(_semanticTokensColorizer); + } +} diff --git a/TombLib/TombLib.Scripting.Lua/LuaEditor.cs b/TombLib/TombLib.Scripting.Lua/LuaEditor.cs index ea3831ec74..c6f71a5391 100644 --- a/TombLib/TombLib.Scripting.Lua/LuaEditor.cs +++ b/TombLib/TombLib.Scripting.Lua/LuaEditor.cs @@ -1,36 +1,90 @@ -using ICSharpCode.AvalonEdit.Highlighting; -using ICSharpCode.AvalonEdit.Highlighting.Xshd; +using NLog; using System; -using System.IO; -using System.Windows.Media; -using System.Xml; using TombLib.Scripting.Bases; +using TombLib.Scripting.Highlighting; +using TombLib.Scripting.Lua.Objects; +using TombLib.Scripting.Lua.Resources; +using TombLib.Scripting.Lua.Services; +using TombLib.Scripting.Lua.Utils; -namespace TombLib.Scripting.Lua +namespace TombLib.Scripting.Lua; + +/// +/// Provides a Lua-specific text editor with syntax highlighting, semantic coloring, and language-service integration. +/// +public sealed partial class LuaEditor : TextEditorBase { - public sealed class LuaEditor : TextEditorBase + private static readonly Logger Log = LogManager.GetCurrentClassLogger(); + private readonly LuaCompletionController _completionController; + private readonly LuaDefinitionNavigationController _definitionNavigationController; + private readonly LuaHoverController _hoverController; + private readonly LuaSignatureHelpController _signatureHelpController; + + /// + /// Gets the default file extension associated with Lua documents. + /// + public override string DefaultFileExtension => ".lua"; + + private LuaTextMateInstallation? _textMateHighlighting; + private LuaThemeBrushSet? _themeBrushSet; + private int _editorDocumentVersion; + private int _editorRequestGeneration; + + /// + /// Gets or sets the IntelliSense provider used to supply completions, hover text, diagnostics, and navigation results. + /// + public ILuaIntellisenseProvider? IntellisenseProvider { get; set; } + + /// + /// Occurs when the editor resolves a definition location that should be opened by the host application. + /// + public event Action? DefinitionNavigationRequested; + + /// + /// Initializes a new instance of the class for the specified engine version. + /// + /// The engine version used to configure editor behavior. + public LuaEditor(Version engineVersion) : base(engineVersion) { - public override string DefaultFileExtension => ".lua"; + CommentPrefix = "--"; + TextArea.IndentationStrategy = new LuaAutoIndentationStrategy(Options); + _completionController = new LuaCompletionController(this); + _definitionNavigationController = new LuaDefinitionNavigationController(this); + _hoverController = new LuaHoverController(this); + _signatureHelpController = new LuaSignatureHelpController(this); + _signatureHelpController.InitializePopup(); + BindLuaIntellisenseEvents(); + } - public LuaEditor(Version engineVersion) : base(engineVersion) - { - CommentPrefix = "--"; - } + /// + /// Applies the active Lua theme, refreshes syntax highlighting, and updates shared editor settings. + /// + /// The editor configuration to apply. + public override void UpdateSettings(Bases.ConfigurationBase configuration) + { + var config = configuration as LuaEditorConfiguration; + var theme = config?.Theme ?? LuaThemeRepository.GetTheme(ConfigurationDefaults.SelectedThemeName); + _themeBrushSet = LuaEditorColorPalette.Create(theme); + _textMateHighlighting?.Dispose(); + _textMateHighlighting = null; - public override void UpdateSettings(Bases.ConfigurationBase configuration) - { - var config = configuration as LuaEditorConfiguration; + LuaTextMateSyntaxHighlighting.TryInstall(this, theme.TextMateTheme, out _textMateHighlighting); + SyntaxHighlighting = _textMateHighlighting is null + ? LuaTextMateSyntaxHighlighting.LoadFallbackHighlighting() + : null; - string xmlFile = Path.Combine(DefaultPaths.LuaColorConfigsDirectory, "Default.xml"); + EnsureSemanticTokensColorizerAttached(); - using (var stream = new FileStream(xmlFile, FileMode.Open, FileAccess.Read)) - using (var reader = new XmlTextReader(stream)) - SyntaxHighlighting = HighlightingLoader.Load(reader, HighlightingManager.Instance); + Background = _themeBrushSet.EditorBackground; + Foreground = _themeBrushSet.EditorForeground; - Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#202020")); - Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("White")); + base.UpdateSettings(configuration); + LiveErrorUnderlining = true; // TEMP - Add as a setting later + } - base.UpdateSettings(configuration); - } + private LuaThemeBrushSet GetThemeBrushSet() + { + _themeBrushSet ??= LuaEditorColorPalette.Create(LuaThemeRepository.GetTheme(ConfigurationDefaults.SelectedThemeName)); + return _themeBrushSet; } } diff --git a/TombLib/TombLib.Scripting.Lua/LuaEditorConfiguration.cs b/TombLib/TombLib.Scripting.Lua/LuaEditorConfiguration.cs index f67d633e17..328a6046e9 100644 --- a/TombLib/TombLib.Scripting.Lua/LuaEditorConfiguration.cs +++ b/TombLib/TombLib.Scripting.Lua/LuaEditorConfiguration.cs @@ -1,47 +1,57 @@ using System.IO; +using System.Xml.Serialization; using TombLib.Scripting.Bases; -using TombLib.Scripting.Lua.Objects; using TombLib.Scripting.Lua.Resources; -using TombLib.Utils; -namespace TombLib.Scripting.Lua +namespace TombLib.Scripting.Lua; + +/// +/// Stores user-configurable settings for the Lua editor. +/// +public sealed class LuaEditorConfiguration : TextEditorConfigBase { - public sealed class LuaEditorConfiguration : TextEditorConfigBase - { - public override string DefaultPath { get; } + /// + /// Gets the default file path used to persist this configuration. + /// + public override string DefaultPath { get; } - #region Color scheme + private string _selectedThemeName = ConfigurationDefaults.SelectedThemeName; - private string _selectedColorSchemeName; - public string SelectedColorSchemeName + /// + /// Gets or sets the selected Lua theme name. + /// + public string SelectedThemeName + { + get => _selectedThemeName; + set { - get => _selectedColorSchemeName; - set - { - _selectedColorSchemeName = value; - - string schemeFilePath = - Path.Combine(DefaultPaths.LuaColorConfigsDirectory, value + ConfigurationDefaults.ColorSchemeFileExtension); - - if (!File.Exists(schemeFilePath)) - ColorScheme = new ColorScheme(); - else - ColorScheme = XmlUtils.ReadXmlFile(schemeFilePath); - } - } - - public ColorScheme ColorScheme; + _selectedThemeName = LuaThemeRepository.ResolveThemeName(value); - #endregion Color scheme + Theme = LuaThemeRepository.GetTheme(_selectedThemeName); + } + } - #region Construction + /// + /// Gets the resolved theme object for the current selection. + /// + [XmlIgnore] + public Objects.LuaTheme Theme { get; private set; } = LuaThemeRepository.GetTheme(ConfigurationDefaults.SelectedThemeName); - public LuaEditorConfiguration() - { - DefaultPath = Path.Combine(DefaultPaths.TextEditorConfigsDirectory, ConfigurationDefaults.ConfigurationFileName); - SelectedColorSchemeName = ConfigurationDefaults.SelectedColorSchemeName; - } + /// + /// Gets or sets the legacy color-scheme alias used by existing serialized configuration data. + /// + public string SelectedColorSchemeName + { + get => SelectedThemeName; + set => SelectedThemeName = value; + } - #endregion Construction + /// + /// Initializes a new instance of the class. + /// + public LuaEditorConfiguration() + { + DefaultPath = Path.Combine(DefaultPaths.TextEditorConfigsDirectory, ConfigurationDefaults.ConfigurationFileName); + SelectedThemeName = ConfigurationDefaults.SelectedThemeName; } } diff --git a/TombLib/TombLib.Scripting.Lua/LuaTextBox.Designer.cs b/TombLib/TombLib.Scripting.Lua/LuaTextBox.Designer.cs deleted file mode 100644 index 942509664e..0000000000 --- a/TombLib/TombLib.Scripting.Lua/LuaTextBox.Designer.cs +++ /dev/null @@ -1,45 +0,0 @@ -namespace TombLib.Scripting.Lua -{ - partial class LuaTextBox - { - /// - /// Required designer variable. - /// - private System.ComponentModel.IContainer components = null; - - #region Component Designer generated code - - /// - /// Required method for Designer support - do not modify - /// the contents of this method with the code editor. - /// - private void InitializeComponent() - { - this.ehTextEditor = new System.Windows.Forms.Integration.ElementHost(); - this.SuspendLayout(); - // - // ehTextEditor - // - this.ehTextEditor.Dock = System.Windows.Forms.DockStyle.Fill; - this.ehTextEditor.Location = new System.Drawing.Point(0, 0); - this.ehTextEditor.Name = "ehTextEditor"; - this.ehTextEditor.Size = new System.Drawing.Size(150, 150); - this.ehTextEditor.TabIndex = 0; - this.ehTextEditor.Text = "elementHost"; - this.ehTextEditor.Child = null; - // - // LuaTextEditor - // - this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); - this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; - this.Controls.Add(this.ehTextEditor); - this.Name = "LuaTextEditor"; - this.ResumeLayout(false); - - } - - #endregion - - private System.Windows.Forms.Integration.ElementHost ehTextEditor; - } -} diff --git a/TombLib/TombLib.Scripting.Lua/LuaTextBox.cs b/TombLib/TombLib.Scripting.Lua/LuaTextBox.cs deleted file mode 100644 index 357e0ff7f8..0000000000 --- a/TombLib/TombLib.Scripting.Lua/LuaTextBox.cs +++ /dev/null @@ -1,74 +0,0 @@ -using DarkUI.Config; -using DarkUI.Forms; -using System; -using System.ComponentModel; -using System.Windows.Forms; - -namespace TombLib.Scripting.Lua -{ - public partial class LuaTextBox : UserControl - { - private DarkTranslucentForm _overlayForm; - - public LuaEditor TextEditor { get; private set; } - - public LuaTextBox() - { - InitializeComponent(); - InitializeTextEditor(); - BackColor = Colors.GreyBackground; - } - - private void InitializeTextEditor() - { - if (LicenseManager.UsageMode == LicenseUsageMode.Runtime) - { - TextEditor = new LuaEditor(new Version(0, 0)); - TextEditor.AllowDrop = true; - TextEditor.WordWrap = true; - TextEditor.DragEnter += textEditor_DragEnter; - ehTextEditor.Child = TextEditor; - - _overlayForm = new DarkTranslucentForm(Colors.GreyBackground, 0.01); // 0 won't show form! - _overlayForm.AllowDrop = true; - _overlayForm.DragEnter += overlayForm_DragEnter; - _overlayForm.DragDrop += overlayForm_DragDrop; - _overlayForm.DragLeave += overlayForm_DragLeave; - } - } - - public void Paste(string text) - { - TextEditor.TextArea.PerformTextInput(text); - TextEditor.Focus(); - } - - protected override void OnGotFocus(EventArgs e) - { - base.OnGotFocus(e); - TextEditor.Focus(); - } - - private void textEditor_DragEnter(object sender, System.Windows.DragEventArgs e) - { - _overlayForm.Show(); - _overlayForm.Location = PointToScreen(new System.Drawing.Point(0)); - _overlayForm.Size = ClientSize; - } - - private void overlayForm_DragEnter(object sender, DragEventArgs e) => - OnDragEnter(e); - - private void overlayForm_DragDrop(object sender, DragEventArgs e) - { - _overlayForm.Hide(); - OnDragDrop(e); - } - - private void overlayForm_DragLeave(object sender, EventArgs e) - { - _overlayForm.Hide(); - OnDragLeave(e); - } - } -} diff --git a/TombLib/TombLib.Scripting.Lua/LuaTextBox.resx b/TombLib/TombLib.Scripting.Lua/LuaTextBox.resx deleted file mode 100644 index 1af7de150c..0000000000 --- a/TombLib/TombLib.Scripting.Lua/LuaTextBox.resx +++ /dev/null @@ -1,120 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - \ No newline at end of file diff --git a/TombLib/TombLib.Scripting.Lua/Objects/ColorScheme.cs b/TombLib/TombLib.Scripting.Lua/Objects/ColorScheme.cs deleted file mode 100644 index 0b6fa96ac8..0000000000 --- a/TombLib/TombLib.Scripting.Lua/Objects/ColorScheme.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System; -using TombLib.Scripting.Bases; -using TombLib.Scripting.Objects; - -namespace TombLib.Scripting.Lua.Objects -{ - public sealed class ColorScheme : ColorSchemeBase - { - public HighlightingObject Comments { get; set; } = new HighlightingObject(); - public HighlightingObject Values { get; set; } = new HighlightingObject(); - public HighlightingObject Statements { get; set; } = new HighlightingObject(); - public HighlightingObject Operators { get; set; } = new HighlightingObject(); - public HighlightingObject SpecialOperators { get; set; } = new HighlightingObject(); - - #region Operators - - public static bool operator ==(ColorScheme a, ColorScheme b) => a.Equals(b); - public static bool operator !=(ColorScheme a, ColorScheme b) => !a.Equals(b); - - public override bool Equals(object obj) - { - if (obj == null || !(obj is ColorScheme)) - return false; - else - { - var objectToCompare = obj as ColorScheme; - - return Comments == objectToCompare.Comments - && Values == objectToCompare.Values - && Statements == objectToCompare.Statements - && Operators == objectToCompare.Operators - && SpecialOperators == objectToCompare.SpecialOperators - && Background.Equals(objectToCompare.Background, StringComparison.OrdinalIgnoreCase) - && Foreground.Equals(objectToCompare.Foreground, StringComparison.OrdinalIgnoreCase); - } - } - - public override int GetHashCode() => ToString().GetHashCode(); - - #endregion Operators - } -} diff --git a/TombLib/TombLib.Scripting.Lua/Objects/LuaCompletionData.cs b/TombLib/TombLib.Scripting.Lua/Objects/LuaCompletionData.cs new file mode 100644 index 0000000000..689f478f56 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/Objects/LuaCompletionData.cs @@ -0,0 +1,372 @@ +using ICSharpCode.AvalonEdit.CodeCompletion; +using ICSharpCode.AvalonEdit.Document; +using ICSharpCode.AvalonEdit.Editing; +using System; +using System.ComponentModel; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using TombLib.Scripting.Lua.Resources; +using TombLib.Scripting.Lua.Utils; +using TombLib.Scripting.Rendering; +using TombLib.Scripting.Resources; + +namespace TombLib.Scripting.Lua.Objects; + +/// +/// Adapts a to AvalonEdit's completion-item UI contract. +/// +internal sealed class LuaCompletionData : ICompletionData, INotifyPropertyChanged +{ + private const double DescriptionMaxWidth = 540.0; + private const double DescriptionTextMaxWidth = 500.0; + + private static readonly SolidColorBrush DescriptionBorderBrush = TextEditorColorPalette.ToolTipBorder; + private static readonly SolidColorBrush DescriptionBackgroundBrush = TextEditorColorPalette.ToolTipBackground; + private static readonly SolidColorBrush DescriptionForegroundBrush = TextEditorColorPalette.ToolTipForeground; + + private static readonly object NoDescriptionSentinel = new(); + private static readonly PropertyInfo? OverstrikeModeProperty = typeof(TextArea).GetProperty("OverstrikeMode"); + + private readonly object _resolveSync = new(); + + private readonly LuaThemeBrushSet _brushSet; + private readonly Func? _canApplyItem; + private LuaCompletionItem _item; + private string? _displayDetail; + private object? _cachedDescription; + private Task? _resolveTask; + + /// + /// Initializes a new instance of the class. + /// + /// The completion item being adapted. + /// The theme brushes used to render the item. + /// Validates whether the completion item may still be applied. + public LuaCompletionData(LuaCompletionItem item, LuaThemeBrushSet brushSet, Func? canApplyItem = null) + { + _item = item ?? throw new ArgumentNullException(nameof(item)); + _brushSet = brushSet ?? throw new ArgumentNullException(nameof(brushSet)); + _canApplyItem = canApplyItem; + _displayDetail = FlattenSingleLineText(_item.Detail); + } + + /// + /// Occurs when a bindable completion-property value changes. + /// + public event PropertyChangedEventHandler? PropertyChanged; + + /// + /// Gets the icon image shown for the completion item. + /// + public ImageSource Image => LuaCompletionIconFactory.GetIcon(_item.IconKind, _brushSet); + + /// + /// Gets the text used for filtering and matching. + /// + public string Text => _item.FilterText; + + /// + /// Gets the primary label shown in the completion list. + /// + public string DisplayText => _item.Label; + + /// + /// Gets the optional secondary detail shown inline in the completion list. + /// + public string? DisplayDetail => _displayDetail; + + /// + /// Gets the visibility for the inline detail label. + /// + public Visibility DetailVisibility => string.IsNullOrEmpty(_displayDetail) ? Visibility.Collapsed : Visibility.Visible; + + /// + /// Gets the content object used by AvalonEdit for the completion row. + /// + public object Content => DisplayText; + + /// + /// Gets the tooltip content for the completion item. + /// + public object? Description + { + get + { + // Cache a sentinel for a missing description so subsequent accesses do not keep + // rebuilding the same null result on every selection change. + _cachedDescription ??= BuildDescriptionContent() ?? NoDescriptionSentinel; + return ReferenceEquals(_cachedDescription, NoDescriptionSentinel) ? null : _cachedDescription; + } + } + + /// + /// Gets the sort priority used by the completion list. + /// + public double Priority => _item.Priority; + + /// + /// Gets a value indicating whether this item supports lazy resolve for richer detail. + /// + public bool CanResolve => _item.CanResolve; + + /// + /// Inserts the completion text into the editor. + /// + /// The target text area. + /// The segment to replace. + /// The insertion request context. + public void Complete(TextArea textArea, ISegment completionSegment, EventArgs insertionRequestEventArgs) + { + ArgumentNullException.ThrowIfNull(textArea); + ArgumentNullException.ThrowIfNull(completionSegment); + ArgumentNullException.ThrowIfNull(insertionRequestEventArgs); + + if (!CanApplyCompletionItem(_item, _canApplyItem)) + return; + + TextDocument document = textArea.Document; + string insertText = _item.InsertText; + int? insertCaretOffset = _item.InsertCaretOffset; + (int replacementOffset, int replacementLength) = ResolveCompletionSegment( + document, + completionSegment.Offset, + completionSegment.Length, + _item.TextEdit, + ShouldUseReplaceRange(textArea)); + + if (ContainsLineBreak(insertText)) + { + LuaCompletionNormalizationResult normalizedInsertion = LuaIndentationStrategy.NormalizeCompletionInsertion( + insertText, + insertCaretOffset, + GetCurrentLineIndentation(document, replacementOffset), + LuaIndentationStrategy.CreateIndentationUnit( + textArea.Options.ConvertTabsToSpaces, + textArea.Options.IndentationSize, + 4)); + + insertText = normalizedInsertion.Text; + insertCaretOffset = normalizedInsertion.CaretOffset; + } + + document.Replace(replacementOffset, replacementLength, insertText); + textArea.Caret.Offset = replacementOffset + (insertCaretOffset ?? insertText.Length); + } + + /// + /// Resolves and returns the tooltip content for the completion item. + /// + /// A token that can cancel the lazy resolve request. + /// The tooltip content, or when no detail is available. + public async Task GetDescriptionAsync(CancellationToken cancellationToken = default) + { + if (!CanResolve) + return Description; + + Task resolveTask; + + lock (_resolveSync) + { + if (_resolveTask is null || _resolveTask.IsFaulted || _resolveTask.IsCanceled) + { + // Use the caller's token so a stale, abandoned resolve also cancels the underlying LSP request + // instead of staying in flight against the language server. + _resolveTask = _item.ResolveAsync(cancellationToken); + } + + resolveTask = _resolveTask; + } + + try + { + LuaCompletionItem resolvedItem = cancellationToken.CanBeCanceled + ? await resolveTask.WaitAsync(cancellationToken).ConfigureAwait(true) + : await resolveTask.ConfigureAwait(true); + + ApplyResolvedItem(resolvedItem); + return Description; + } + catch (OperationCanceledException) + { + // Allow a future request to retry the resolve when the caller cancels mid-flight. + lock (_resolveSync) + { + if (ReferenceEquals(_resolveTask, resolveTask)) + _resolveTask = null; + } + + throw; + } + catch + { + lock (_resolveSync) + { + if (ReferenceEquals(_resolveTask, resolveTask)) + _resolveTask = null; + } + + throw; + } + } + + internal void RebaseForCurrentDocument(int requestDocumentVersion, int requestGeneration) + { + _item = _item.WithFilteredCommitContext(requestDocumentVersion, requestGeneration); + + lock (_resolveSync) + _resolveTask = null; + } + + private static string? FlattenSingleLineText(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return null; + + string[] lines = text.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + return lines.Length == 0 ? null : string.Join(" ", lines).Trim(); + } + + private static bool CanApplyCompletionItem(LuaCompletionItem item, Func? canApplyItem) + => canApplyItem?.Invoke(item) ?? true; + + private static bool ContainsLineBreak(string text) + => text.IndexOfAny(['\r', '\n']) >= 0; + + private static string GetCurrentLineIndentation(TextDocument document, int offset) + { + ArgumentNullException.ThrowIfNull(document); + + DocumentLine line = document.GetLineByOffset(Math.Clamp(offset, 0, document.TextLength)); + return LuaIndentationStrategy.GetLeadingWhitespace(document.GetText(line)); + } + + private static (int Offset, int Length) ResolveCompletionSegment(TextDocument document, + int fallbackOffset, + int fallbackLength, + LuaCompletionTextEdit? textEdit, + bool useReplaceRange) + { + ArgumentNullException.ThrowIfNull(document); + + var fallbackSegment = (fallbackOffset, fallbackLength); + + if (textEdit is not LuaCompletionTextEdit completionTextEdit) + return fallbackSegment; + + LuaCompletionRange range = useReplaceRange + ? completionTextEdit.ReplacementRange + : completionTextEdit.InsertRange; + + return TryCreateCompletionSegment(document, range, out (int Offset, int Length) replacementSegment) + ? replacementSegment + : fallbackSegment; + } + + private static bool TryCreateCompletionSegment(TextDocument document, + LuaCompletionRange range, + out (int Offset, int Length) segment) + { + segment = default; + + if (!TryGetCompletionOffset(document, range.Start, out int startOffset) + || !TryGetCompletionOffset(document, range.End, out int endOffset) + || endOffset < startOffset) + { + return false; + } + + segment = (startOffset, endOffset - startOffset); + return true; + } + + private static bool TryGetCompletionOffset(TextDocument document, LuaCompletionPosition position, out int offset) + { + offset = 0; + int lineNumber = position.Line + 1; + + if (lineNumber < 1 || lineNumber > document.LineCount) + return false; + + DocumentLine line = document.GetLineByNumber(lineNumber); + + if (position.Character < 0 || position.Character > line.Length) + return false; + + offset = line.Offset + position.Character; + return true; + } + + private static bool ShouldUseReplaceRange(TextArea textArea) + => OverstrikeModeProperty?.GetValue(textArea) is true; + + private Border? BuildDescriptionContent() + { + bool hasDetail = !string.IsNullOrWhiteSpace(_item.Detail); + bool hasDescription = !string.IsNullOrWhiteSpace(_item.Description); + + if (!hasDetail && !hasDescription) + return null; + + var panel = new StackPanel + { + MaxWidth = DescriptionMaxWidth + }; + + if (hasDetail) + { + panel.Children.Add(new TextBlock + { + Text = _item.Detail, + Foreground = DescriptionForegroundBrush, + TextWrapping = TextWrapping.Wrap, + MaxWidth = DescriptionTextMaxWidth, + FontWeight = FontWeights.SemiBold, + Margin = hasDescription + ? new Thickness(0.0, 0.0, 0.0, 8.0) + : new Thickness(0.0) + }); + } + + if (hasDescription) + { + string descriptionText = _item.Description!; + + panel.Children.Add(_item.IsDescriptionMarkdown + ? MarkdownToolTipRenderer.CreateContent(descriptionText, DescriptionForegroundBrush, DescriptionBackgroundBrush, false) + : MarkdownToolTipRenderer.CreatePlainTextContent(descriptionText, DescriptionForegroundBrush, false)); + } + + return new Border + { + Background = DescriptionBackgroundBrush, + BorderBrush = DescriptionBorderBrush, + BorderThickness = new Thickness(1.0), + Padding = new Thickness(8.0, 6.0, 8.0, 6.0), + Child = panel, + MaxWidth = DescriptionMaxWidth + }; + } + + private void ApplyResolvedItem(LuaCompletionItem resolvedItem) + { + if (resolvedItem is null || ReferenceEquals(resolvedItem, _item)) + return; + + _item = resolvedItem; + _displayDetail = FlattenSingleLineText(_item.Detail); + _cachedDescription = null; + + OnPropertyChanged(nameof(Image)); + OnPropertyChanged(nameof(DisplayDetail)); + OnPropertyChanged(nameof(DetailVisibility)); + OnPropertyChanged(nameof(Description)); + } + + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); +} diff --git a/TombLib/TombLib.Scripting.Lua/Objects/LuaCompletionIconFactory.cs b/TombLib/TombLib.Scripting.Lua/Objects/LuaCompletionIconFactory.cs new file mode 100644 index 0000000000..f8a9a023a1 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/Objects/LuaCompletionIconFactory.cs @@ -0,0 +1,109 @@ +using System.Collections.Concurrent; +using System.Windows.Media; +using TombLib.Scripting.Lua.Resources; + +namespace TombLib.Scripting.Lua.Objects; + +// Geometry paths below are vendored from microsoft/vscode-codicons under the MIT license. +/// +/// Creates themed completion icons from the vendored Codicon geometry set. +/// +internal static class LuaCompletionIconFactory +{ + private static readonly ConcurrentDictionary Cache = new(); + + /// + /// Gets the themed icon image for the supplied completion kind. + /// + /// The completion icon kind. + /// The brush set used to color the icon. + /// A cached frozen image for the requested icon. + public static ImageSource GetIcon(LuaCompletionIconKind kind, LuaThemeBrushSet brushSet) + => Cache.GetOrAdd(brushSet.ThemeName + ":" + kind, _ => CreateIcon(kind, brushSet)); + + private static DrawingImage CreateIcon(LuaCompletionIconKind kind, LuaThemeBrushSet brushSet) + { + var drawingGroup = new DrawingGroup(); + + foreach (string pathData in GetPathData(kind)) + drawingGroup.Children.Add(CreatePathDrawing(brushSet.GetCompletionItemBrush(kind), pathData)); + + drawingGroup.Freeze(); + + var image = new DrawingImage(drawingGroup); + image.Freeze(); + return image; + } + + private static GeometryDrawing CreatePathDrawing(Brush brush, string pathData) + { + Geometry geometry = Geometry.Parse(pathData); + geometry.Freeze(); + return new GeometryDrawing(brush, null, geometry); + } + + private static string[] GetPathData(LuaCompletionIconKind kind) + { + return kind switch + { + LuaCompletionIconKind.Variable => + [ + "M11.279 5.78975L8.799 5.06575C8.59 5.00575 8.372 5.01475 8.168 5.08975L4.648 6.40975C4.26 6.55575 4 6.93175 4 7.34675V9.13975C4 9.57075 4.274 9.95175 4.684 10.0877L7.165 10.9147C7.268 10.9497 7.376 10.9667 7.483 10.9667C7.611 10.9667 7.739 10.9427 7.859 10.8937L11.376 9.46475C11.755 9.31175 12 8.94775 12 8.53875V6.74975C12 6.30775 11.703 5.91275 11.279 5.78975ZM11 8.53875L7.483 9.96775L5 9.13975V7.34675L8.521 6.02675L11 6.75075V8.53975V8.53875ZM7.48 7.46675L8.807 6.91375C9.06 6.80875 9.355 6.92775 9.461 7.18275C9.566 7.43775 9.446 7.73075 9.191 7.83675L7.999 8.33375V8.62575C7.999 8.90175 7.775 9.12575 7.499 9.12575C7.223 9.12575 6.999 8.90175 6.999 8.62575V8.36075L6.591 8.22475C6.329 8.13775 6.188 7.85475 6.275 7.59275C6.364 7.33075 6.647 7.19175 6.908 7.27675L7.48 7.46675Z", + "M12.5 14H11.5C11.224 14 11 13.776 11 13.5C11 13.224 11.224 13 11.5 13H12.5C12.776 13 13 12.775 13 12.5V3.5C13 3.225 12.776 3 12.5 3H11.5C11.224 3 11 2.776 11 2.5C11 2.224 11.224 2 11.5 2H12.5C13.327 2 14 2.673 14 3.5V12.5C14 13.327 13.327 14 12.5 14ZM5 13.5C5 13.224 4.776 13 4.5 13H3.5C3.224 13 3 12.775 3 12.5V3.5C3 3.225 3.224 3 3.5 3H4.5C4.776 3 5 2.776 5 2.5C5 2.224 4.776 2 4.5 2H3.5C2.673 2 2 2.673 2 3.5V12.5C2 13.327 2.673 14 3.5 14H4.5C4.776 14 5 13.776 5 13.5Z" + ], + LuaCompletionIconKind.Field => + [ + "M11.967 6.08899C11.9907 6.15031 12.0021 6.2157 12.0005 6.28143C11.9989 6.34715 11.9843 6.41191 11.9577 6.47201C11.931 6.5321 11.8928 6.58635 11.8451 6.63165C11.7975 6.67695 11.7414 6.7124 11.68 6.73599L7.5 8.34399V10.02C7.5 10.1526 7.44732 10.2798 7.35355 10.3735C7.25979 10.4673 7.13261 10.52 7 10.52C6.86739 10.52 6.74021 10.4673 6.64645 10.3735C6.55268 10.2798 6.5 10.1526 6.5 10.02V8.34299L4.32 7.50499C4.25874 7.48135 4.20273 7.44588 4.15518 7.40059C4.10763 7.35531 4.06946 7.30111 4.04286 7.24107C4.01625 7.18104 4.00173 7.11635 4.00013 7.05071C3.99852 6.98507 4.00986 6.91975 4.0335 6.85849C4.05714 6.79722 4.09261 6.74122 4.13789 6.69367C4.18318 6.64611 4.23738 6.60795 4.29741 6.58134C4.35745 6.55474 4.42213 6.54022 4.48778 6.53861C4.55342 6.53701 4.61874 6.54835 4.68 6.57199L7 7.46399L11.32 5.79999C11.3814 5.77634 11.447 5.76505 11.5128 5.76678C11.5786 5.76852 11.6434 5.78323 11.7035 5.81008C11.7636 5.83694 11.8179 5.8754 11.8631 5.92326C11.9083 5.97112 11.9436 6.02744 11.967 6.08899ZM15 5.79999V9.42899C14.9986 9.73191 14.9061 10.0274 14.7345 10.2771C14.563 10.5268 14.3203 10.7191 14.038 10.829L7.538 13.329C7.19108 13.4626 6.80692 13.4626 6.46 13.329L1.961 11.6C1.67891 11.4899 1.43643 11.2975 1.26506 11.0479C1.09369 10.7982 1.00134 10.5028 1 10.2V6.57099C1.00155 6.26809 1.0941 5.97265 1.26565 5.72301C1.43719 5.47336 1.6798 5.28104 1.962 5.17099L8.462 2.67099C8.80902 2.53798 9.19298 2.53798 9.54 2.67099L14.04 4.40199C14.3215 4.51223 14.5635 4.70438 14.7346 4.95361C14.9058 5.20283 14.9982 5.49766 15 5.79999ZM14 5.79999C14 5.69881 13.9694 5.6 13.912 5.51662C13.8547 5.43324 13.7735 5.36921 13.679 5.33299L9.179 3.60299C9.06398 3.55763 8.93602 3.55763 8.821 3.60299L2.321 6.10299C2.22637 6.13927 2.145 6.20345 2.08767 6.28703C2.03034 6.37061 1.99977 6.46964 2 6.57099V10.2C2.0001 10.3009 2.03071 10.3994 2.08782 10.4825C2.14494 10.5657 2.22587 10.6297 2.32 10.666L6.82 12.398C6.93524 12.4422 7.06276 12.4422 7.178 12.398L13.678 9.89799C13.773 9.8618 13.8547 9.79754 13.9122 9.71375C13.9697 9.62996 14.0004 9.53062 14 9.42899V5.79999Z" + ], + LuaCompletionIconKind.Method => + [ + "M4.69684 5.04043C4.44303 4.93166 4.14909 5.04923 4.04031 5.30305C3.93153 5.55686 4.04911 5.8508 4.30292 5.95958L7.49988 7.3297V10.5C7.49988 10.7761 7.72374 11 7.99988 11C8.27603 11 8.49988 10.7761 8.49988 10.5V7.3297L11.6968 5.95958C11.9507 5.8508 12.0682 5.55686 11.9595 5.30305C11.8507 5.04923 11.5567 4.93166 11.3029 5.04043L7.99988 6.45602L4.69684 5.04043ZM9.07694 1.37855C8.38373 1.11193 7.61627 1.11193 6.92306 1.37855L1.96153 3.28683C1.38224 3.50964 1 4.06619 1 4.68685V11.3133C1 11.9339 1.38224 12.4905 1.96153 12.7133L6.92306 14.6216C7.61627 14.8882 8.38373 14.8882 9.07694 14.6216L14.0385 12.7133C14.6178 12.4905 15 11.9339 15 11.3133V4.68685C15 4.06619 14.6178 3.50964 14.0385 3.28683L9.07694 1.37855ZM7.28204 2.3119C7.74418 2.13415 8.25582 2.13415 8.71796 2.3119L13.6795 4.22018C13.8726 4.29445 14 4.47997 14 4.68685V11.3133C14 11.5201 13.8726 11.7057 13.6795 11.7799L8.71796 13.6882C8.25582 13.866 7.74418 13.866 7.28204 13.6882L2.32051 11.7799C2.12741 11.7057 2 11.5201 2 11.3133V4.68685C2 4.47997 2.12741 4.29445 2.32051 4.22018L7.28204 2.3119Z" + ], + LuaCompletionIconKind.Property => + [ + "M6.99989 5C6.99989 2.79086 8.79075 1 10.9999 1C11.5087 1 11.9964 1.09524 12.4454 1.26931C12.603 1.3304 12.719 1.46698 12.7539 1.63235C12.7888 1.79773 12.7377 1.96953 12.6182 2.08904L10.7072 4.00012L12.0001 5.29302L13.911 3.38207C14.0305 3.26254 14.2023 3.2115 14.3677 3.24637C14.5331 3.28125 14.6697 3.39732 14.7307 3.55493C14.9047 4.0038 14.9999 4.49138 14.9999 5C14.9999 7.20914 13.209 9 10.9999 9C10.6198 9 10.2514 8.94684 9.90215 8.84736L4.89566 13.9192C4.18171 14.6425 3.03692 14.7101 2.24289 14.0757C1.32876 13.3455 1.24088 11.9872 2.05327 11.1453L7.10411 5.91061C7.03588 5.61771 6.99989 5.31279 6.99989 5ZM10.9999 2C9.34303 2 7.99989 3.34315 7.99989 5C7.99989 5.31548 8.04841 5.61868 8.13805 5.90305C8.19313 6.07781 8.14821 6.26869 8.02099 6.40054L2.7729 11.8396C2.3696 12.2576 2.41323 12.9319 2.86703 13.2944C3.26123 13.6093 3.82955 13.5758 4.18398 13.2167L9.40817 7.9243C9.54702 7.78364 9.75569 7.73797 9.9406 7.80777C10.2693 7.93186 10.6261 8 10.9999 8C12.6567 8 13.9999 6.65685 13.9999 5C13.9999 4.9056 13.9955 4.81228 13.987 4.72023L12.3537 6.35368C12.2599 6.44745 12.1327 6.50013 12.0001 6.50013C11.8675 6.50013 11.7403 6.44745 11.6466 6.35368L9.64655 4.35368C9.45129 4.15842 9.45129 3.84185 9.64655 3.64658L11.2802 2.01289C11.188 2.00436 11.0945 2 10.9999 2Z" + ], + LuaCompletionIconKind.Class => + [ + "M13.2069 10.4999C13.0194 10.3125 12.7651 10.2072 12.4999 10.2072C12.2348 10.2072 11.9805 10.3125 11.7929 10.4999L11.2929 10.9999H8.99994V6.99994H10.3629C10.2479 7.1876 10.1989 7.40832 10.2238 7.62701C10.2486 7.84571 10.3458 8.04983 10.4999 8.20694L11.2929 8.99994C11.4805 9.18741 11.7348 9.29273 11.9999 9.29273C12.2651 9.29273 12.5194 9.18741 12.7069 8.99994L13.9999 7.70694C14.1874 7.51941 14.2927 7.2651 14.2927 6.99994C14.2927 6.73478 14.1874 6.48047 13.9999 6.29294L13.2069 5.49994C13.0194 5.31247 12.7651 5.20715 12.4999 5.20715C12.2348 5.20715 11.9805 5.31247 11.7929 5.49994L11.2929 5.99994H6.70694L7.49994 5.20694C7.68741 5.01941 7.79273 4.7651 7.79273 4.49994C7.79273 4.23478 7.68741 3.98047 7.49994 3.79294L6.20694 2.49994C6.01941 2.31247 5.7651 2.20715 5.49994 2.20715C5.23478 2.20715 4.98047 2.31247 4.79294 2.49994L1.49994 5.79294C1.31247 5.98047 1.20715 6.23478 1.20715 6.49994C1.20715 6.7651 1.31247 7.01941 1.49994 7.20694L2.79294 8.49994C2.98047 8.68741 3.23478 8.79273 3.49994 8.79273C3.7651 8.79273 4.01941 8.68741 4.20694 8.49994L5.70694 6.99994H7.99994V11.4999C7.99994 11.6325 8.05262 11.7597 8.14639 11.8535C8.24015 11.9473 8.36733 11.9999 8.49994 11.9999H10.3629C10.2479 12.1876 10.1989 12.4083 10.2238 12.627C10.2486 12.8457 10.3458 13.0498 10.4999 13.2069L11.2929 13.9999C11.4805 14.1874 11.7348 14.2927 11.9999 14.2927C12.2651 14.2927 12.5194 14.1874 12.7069 13.9999L13.9999 12.7069C14.1874 12.5194 14.2927 12.2651 14.2927 11.9999C14.2927 11.7348 14.1874 11.4805 13.9999 11.2929L13.2069 10.4999ZM3.49994 7.79294L2.20694 6.49994L5.49994 3.20694L6.79294 4.49994L3.49994 7.79294ZM13.2929 6.99994L11.9999 8.29294L11.2069 7.49994L12.4999 6.20694L13.2929 6.99994ZM11.9999 13.2929L11.2069 12.4999L12.4999 11.2069L13.2929 11.9999L11.9999 13.2929Z" + ], + LuaCompletionIconKind.Keyword => + [ + "M9.5 14C9.77614 14 10 14.2239 10 14.5C10 14.7761 9.77614 15 9.5 15H2.5C2.22386 15 2 14.7761 2 14.5C2 14.2239 2.22386 14 2.5 14H9.5Z", + "M6.5 11C6.77614 11 7 11.2239 7 11.5C7 11.7761 6.77614 12 6.5 12H2.5C2.22386 12 2 11.7761 2 11.5C2 11.2239 2.22386 11 2.5 11H6.5Z", + "M13.5 11C13.7761 11 14 11.2239 14 11.5C14 11.7761 13.7761 12 13.5 12H8.5C8.22386 12 8 11.7761 8 11.5C8 11.2239 8.22386 11 8.5 11H13.5Z", + "M8.5 8C8.77614 8 9 8.22386 9 8.5C9 8.77614 8.77614 9 8.5 9H2.5C2.22386 9 2 8.77614 2 8.5C2 8.22386 2.22386 8 2.5 8H8.5Z", + "M13.5 8C13.7761 8 14 8.22386 14 8.5C14 8.77614 13.7761 9 13.5 9H10.5C10.2239 9 10 8.77614 10 8.5C10 8.22386 10.2239 8 10.5 8H13.5Z", + "M9 2C9.55228 2 10 2.44772 10 3V5C10 5.55228 9.55228 6 9 6H3C2.44772 6 2 5.55228 2 5V3C2 2.44772 2.44772 2 3 2H9ZM3 5H9V3H3V5Z", + "M13.5 4C13.7761 4 14 4.22386 14 4.5C14 4.77614 13.7761 5 13.5 5H11.5C11.2239 5 11 4.77614 11 4.5C11 4.22386 11.2239 4 11.5 4H13.5Z" + ], + LuaCompletionIconKind.Constant => + [ + "M4.5 2C3.83696 2 3.20107 2.26339 2.73223 2.73223C2.26339 3.20107 2 3.83696 2 4.5V11.5C2 12.163 2.26339 12.7989 2.73223 13.2678C3.20107 13.7366 3.83696 14 4.5 14H11.5C12.163 14 12.7989 13.7366 13.2678 13.2678C13.7366 12.7989 14 12.163 14 11.5V4.5C14 3.83696 13.7366 3.20107 13.2678 2.73223C12.7989 2.26339 12.163 2 11.5 2H4.5ZM3 4.5C3 4.10218 3.15804 3.72064 3.43934 3.43934C3.72064 3.15804 4.10218 3 4.5 3H11.5C11.8978 3 12.2794 3.15804 12.5607 3.43934C12.842 3.72064 13 4.10218 13 4.5V11.5C13 11.8978 12.842 12.2794 12.5607 12.5607C12.2794 12.842 11.8978 13 11.5 13H4.5C4.10218 13 3.72064 12.842 3.43934 12.5607C3.15804 12.2794 3 11.8978 3 11.5V4.5Z", + "M5 6.5C5 6.36739 5.05268 6.24021 5.14645 6.14645C5.24021 6.05268 5.36739 6 5.5 6H10.5C10.6326 6 10.7598 6.05268 10.8536 6.14645C10.9473 6.24021 11 6.36739 11 6.5C11 6.63261 10.9473 6.75979 10.8536 6.85355C10.7598 6.94732 10.6326 7 10.5 7H5.5C5.36739 7 5.24021 6.94732 5.14645 6.85355C5.05268 6.75979 5 6.63261 5 6.5ZM10.5 9H5.5C5.36739 9 5.24021 9.05268 5.14645 9.14645C5.05268 9.24021 5 9.36739 5 9.5C5 9.63261 5.05268 9.75979 5.14645 9.85355C5.24021 9.94732 5.36739 10 5.5 10H10.5C10.6326 10 10.7598 9.94732 10.8536 9.85355C10.9473 9.75979 11 9.63261 11 9.5C11 9.36739 10.9473 9.24021 10.8536 9.14645C10.7598 9.05268 10.6326 9 10.5 9Z" + ], + LuaCompletionIconKind.Parameter => + [ + "M4 3.5C4 3.22386 4.22386 3 4.5 3H11.5C11.7761 3 12 3.22386 12 3.5V4.5C12 4.77614 11.7761 5 11.5 5C11.2239 5 11 4.77614 11 4.5V4H8.5V12H9C9.27614 12 9.5 12.2239 9.5 12.5C9.5 12.7761 9.27614 13 9 13H7C6.72386 13 6.5 12.7761 6.5 12.5C6.5 12.2239 6.72386 12 7 12H7.5V4H5V4.5C5 4.77614 4.77614 5 4.5 5C4.22386 5 4 4.77614 4 4.5V3.5ZM4.35355 6.64645C4.54882 6.84171 4.54882 7.15829 4.35355 7.35355L2.20711 9.5L4.35355 11.6464C4.54882 11.8417 4.54882 12.1583 4.35355 12.3536C4.15829 12.5488 3.84171 12.5488 3.64645 12.3536L1.14645 9.85355C0.951184 9.65829 0.951184 9.34171 1.14645 9.14645L3.64645 6.64645C3.84171 6.45118 4.15829 6.45118 4.35355 6.64645ZM14.8536 9.14645L12.3536 6.64645C12.1583 6.45118 11.8417 6.45118 11.6464 6.64645C11.4512 6.84171 11.4512 7.15829 11.6464 7.35355L13.7929 9.5L11.6464 11.6464C11.4512 11.8417 11.4512 12.1583 11.6464 12.3536C11.8417 12.5488 12.1583 12.5488 12.3536 12.3536L14.8536 9.85355C15.0488 9.65829 15.0488 9.34171 14.8536 9.14645Z" + ], + LuaCompletionIconKind.Namespace => + [ + "M5 2C3.89543 2 3 2.89543 3 4V6.00469C3 6.53494 2.99231 6.79889 2.91088 7.00209C2.84826 7.15835 2.71576 7.33309 2.2764 7.55276C2.10701 7.63745 2 7.81058 2 7.99997C2 8.18935 2.10699 8.36249 2.27638 8.44719C2.71569 8.66685 2.84809 8.84151 2.91076 8.99819C2.99233 9.20211 3 9.46732 3 10L3 12C3 13.1046 3.89543 14 5 14C5.27614 14 5.5 13.7761 5.5 13.5C5.5 13.2239 5.27614 13 5 13C4.44772 13 4 12.5523 4 12L4.00003 9.94145C4.00033 9.49235 4.00065 9.03033 3.83924 8.6268C3.74212 8.384 3.59654 8.17962 3.40072 8.00002C3.59646 7.82057 3.74199 7.61645 3.83912 7.37408C4.00065 6.971 4.00033 6.51001 4.00003 6.063L4 4C4 3.44772 4.44772 3 5 3C5.27614 3 5.5 2.77614 5.5 2.5C5.5 2.22386 5.27614 2 5 2ZM11 2C12.1046 2 13 2.89543 13 4V6.00469C13 6.53494 13.0077 6.79889 13.0891 7.00209C13.1517 7.15835 13.2842 7.33309 13.7236 7.55276C13.893 7.63745 14 7.81058 14 7.99997C14 8.18935 13.893 8.36249 13.7236 8.44719C13.2843 8.66685 13.1519 8.84151 13.0892 8.99819C13.0077 9.20211 13 9.46732 13 10V12C13 13.1046 12.1046 14 11 14C10.7239 14 10.5 13.7761 10.5 13.5C10.5 13.2239 10.7239 13 11 13C11.5523 13 12 12.5523 12 12L12 9.94145C11.9997 9.49235 11.9994 9.03033 12.1608 8.6268C12.2579 8.384 12.4035 8.17962 12.5993 8.00002C12.4035 7.82057 12.258 7.61645 12.1609 7.37408C11.9993 6.971 11.9997 6.51001 12 6.063L12 4C12 3.44772 11.5523 3 11 3C10.7239 3 10.5 2.77614 10.5 2.5C10.5 2.22386 10.7239 2 11 2Z" + ], + LuaCompletionIconKind.File => + [ + "M5 1C3.89543 1 3 1.89543 3 3V13C3 14.1046 3.89543 15 5 15H11C12.1046 15 13 14.1046 13 13V5.41421C13 5.01639 12.842 4.63486 12.5607 4.35355L9.64645 1.43934C9.36514 1.15804 8.98361 1 8.58579 1H5ZM4 3C4 2.44772 4.44772 2 5 2H8V4.5C8 5.32843 8.67157 6 9.5 6H12V13C12 13.5523 11.5523 14 11 14H5C4.44772 14 4 13.5523 4 13V3ZM11.7929 5H9.5C9.22386 5 9 4.77614 9 4.5V2.20711L11.7929 5Z" + ], + LuaCompletionIconKind.Folder => + [ + "M2 4.5V6H5.58579C5.71839 6 5.84557 5.94732 5.93934 5.85355L7.29289 4.5L5.93934 3.14645C5.84557 3.05268 5.71839 3 5.58579 3H3.5C2.67157 3 2 3.67157 2 4.5ZM1 4.5C1 3.11929 2.11929 2 3.5 2H5.58579C5.98361 2 6.36514 2.15804 6.64645 2.43934L8.20711 4H12.5C13.8807 4 15 5.11929 15 6.5V11.5C15 12.8807 13.8807 14 12.5 14H3.5C2.11929 14 1 12.8807 1 11.5V4.5ZM2 7V11.5C2 12.3284 2.67157 13 3.5 13H12.5C13.3284 13 14 12.3284 14 11.5V6.5C14 5.67157 13.3284 5 12.5 5H8.20711L6.64645 6.56066C6.36514 6.84197 5.98361 7 5.58579 7H2Z" + ], + _ => + [ + "M11.9999 3C10.1399 3 8.56988 4.27 8.12988 6H9.17988C9.58988 4.84 10.6999 4 11.9999 4C13.6499 4 14.9999 5.35 14.9999 7C14.9999 8.3 14.1599 9.41 12.9999 9.82V10.87C14.7299 10.43 15.9999 8.86 15.9999 7C15.9999 4.79 14.2099 3 11.9999 3Z", + "M10.5 15H5.5C4.673 15 4 14.327 4 13.5V8.5C4 7.673 4.673 7 5.5 7H10.5C11.327 7 12 7.673 12 8.5V13.5C12 14.327 11.327 15 10.5 15ZM5.5 8C5.224 8 5 8.225 5 8.5V13.5C5 13.775 5.224 14 5.5 14H10.5C10.776 14 11 13.775 11 13.5V8.5C11 8.225 10.776 8 10.5 8H5.5Z", + "M4.42973 2.25008C4.24973 1.94008 3.74973 1.94008 3.56973 2.25008L0.0997266 8.25008C0.00972656 8.40008 0.00972656 8.60008 0.0997266 8.75008C0.189727 8.90008 0.359727 9.00008 0.539727 9.00008H2.99973V8.50008C2.99973 8.33008 3.01973 8.16008 3.04973 8.00008H1.39973L3.99973 3.50008L5.44973 6.00008H6.59973L4.42973 2.25008Z" + ] + }; + } +} diff --git a/TombLib/TombLib.Scripting.Lua/Objects/LuaCompletionIconKind.cs b/TombLib/TombLib.Scripting.Lua/Objects/LuaCompletionIconKind.cs new file mode 100644 index 0000000000..3074d804f8 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/Objects/LuaCompletionIconKind.cs @@ -0,0 +1,67 @@ +namespace TombLib.Scripting.Lua.Objects; + +/// +/// Defines the icon categories used by Lua completion items. +/// +public enum LuaCompletionIconKind +{ + /// + /// A generic icon for uncategorized completion items. + /// + Misc, + + /// + /// An icon for variables. + /// + Variable, + + /// + /// An icon for fields. + /// + Field, + + /// + /// An icon for methods or functions. + /// + Method, + + /// + /// An icon for properties. + /// + Property, + + /// + /// An icon for classes or types. + /// + Class, + + /// + /// An icon for keywords. + /// + Keyword, + + /// + /// An icon for constants. + /// + Constant, + + /// + /// An icon for parameters. + /// + Parameter, + + /// + /// An icon for namespaces. + /// + Namespace, + + /// + /// An icon for files. + /// + File, + + /// + /// An icon for folders. + /// + Folder +} diff --git a/TombLib/TombLib.Scripting.Lua/Objects/LuaCompletionItem.cs b/TombLib/TombLib.Scripting.Lua/Objects/LuaCompletionItem.cs new file mode 100644 index 0000000000..95c12ccd85 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/Objects/LuaCompletionItem.cs @@ -0,0 +1,231 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace TombLib.Scripting.Lua.Objects; + +/// +/// Represents a zero-based document position used by LSP completion text edits. +/// +public readonly record struct LuaCompletionPosition(int Line, int Character); + +/// +/// Represents a zero-based document range used by LSP completion text edits. +/// +public readonly record struct LuaCompletionRange(LuaCompletionPosition Start, LuaCompletionPosition End); + +/// +/// Describes the insert/replace ranges supplied by an LSP completion item. +/// +public readonly record struct LuaCompletionTextEdit(LuaCompletionRange InsertRange, LuaCompletionRange? ReplaceRange = null) +{ + /// + /// Gets the effective replacement range, falling back to when no distinct replace range exists. + /// + public LuaCompletionRange ReplacementRange => ReplaceRange ?? InsertRange; +} + +/// +/// Represents a single completion entry returned by a Lua IntelliSense provider. +/// +public sealed class LuaCompletionItem +{ + private readonly Func>? _resolveAsync; + + /// + /// Initializes a new instance of the class. + /// + /// The label shown in the completion list. + /// The text inserted when the item is committed. + /// Optional secondary detail shown in the completion list. + /// Optional description shown in the completion tooltip. + /// Optional filter text used when matching the item. + /// The sort priority for the item. + /// The icon category shown for the item. + /// Whether should be rendered as Markdown. + /// Optional callback that lazily resolves additional item data. + /// The optional LSP insert/replace edit metadata. + /// The editor document version associated with the completion request. + /// The editor request generation associated with the completion request. + /// The optional caret offset within after plain-text insertion. + public LuaCompletionItem( + string label, + string? insertText = null, + string? detail = null, + string? description = null, + string? filterText = null, + double priority = 0.0, + LuaCompletionIconKind iconKind = LuaCompletionIconKind.Misc, + bool isDescriptionMarkdown = false, + Func>? resolveAsync = null, + LuaCompletionTextEdit? textEdit = null, + int? requestDocumentVersion = null, + int? requestGeneration = null, + int? insertCaretOffset = null) + { + Label = label; + InsertText = string.IsNullOrWhiteSpace(insertText) ? label : insertText; + Detail = string.IsNullOrWhiteSpace(detail) ? null : detail.Trim(); + Description = string.IsNullOrWhiteSpace(description) ? null : description.Trim(); + FilterText = string.IsNullOrWhiteSpace(filterText) ? label : filterText; + Priority = priority; + IconKind = iconKind; + IsDescriptionMarkdown = isDescriptionMarkdown; + TextEdit = textEdit; + RequestDocumentVersion = requestDocumentVersion; + RequestGeneration = requestGeneration; + InsertCaretOffset = insertCaretOffset; + _resolveAsync = resolveAsync; + } + + /// + /// Gets the label shown in the completion list. + /// + public string Label { get; } + + /// + /// Gets the text inserted when the item is committed. + /// + public string InsertText { get; } + + /// + /// Gets the optional secondary detail shown alongside the label. + /// + public string? Detail { get; } + + /// + /// Gets the optional completion description. + /// + public string? Description { get; } + + /// + /// Gets the text used to filter or match the item. + /// + public string FilterText { get; } + + /// + /// Gets the sort priority for the item. + /// + public double Priority { get; } + + /// + /// Gets the icon category shown for the item. + /// + public LuaCompletionIconKind IconKind { get; } + + /// + /// Gets a value indicating whether should be rendered as Markdown. + /// + public bool IsDescriptionMarkdown { get; } + + /// + /// Gets the optional LSP insert/replace edit metadata. + /// + public LuaCompletionTextEdit? TextEdit { get; } + + /// + /// Gets the editor document version associated with the originating completion request. + /// + public int? RequestDocumentVersion { get; } + + /// + /// Gets the editor request generation associated with the originating completion request. + /// + public int? RequestGeneration { get; } + + /// + /// Gets the optional caret offset within after plain-text insertion. + /// + public int? InsertCaretOffset { get; } + + /// + /// Gets a value indicating whether additional item details can be resolved lazily. + /// + public bool CanResolve => _resolveAsync is not null; + + /// + /// Resolves the completion item, returning either the current instance or a lazily populated copy. + /// + /// A token that can cancel the resolve request. + /// The resolved completion item. + public Task ResolveAsync(CancellationToken cancellationToken = default) + { + return _resolveAsync is null + ? Task.FromResult(this) + : _resolveAsync(cancellationToken); + } + + /// + /// Returns a copy of this item with the supplied resolve callback attached. + /// + /// The lazy resolve callback. + /// A new completion item with the resolve callback attached. + public LuaCompletionItem WithResolveCallback(Func> resolveAsync) + => new(Label, InsertText, Detail, Description, FilterText, Priority, IconKind, IsDescriptionMarkdown, + resolveAsync, TextEdit, RequestDocumentVersion, RequestGeneration, InsertCaretOffset); + + /// + /// Returns a copy of this item with editor request metadata attached. + /// + /// The editor document version for the completion request. + /// The editor request generation for the completion request. + /// A new completion item with request metadata attached. + public LuaCompletionItem WithRequestContext(int requestDocumentVersion, int requestGeneration) + { + if (RequestDocumentVersion == requestDocumentVersion && RequestGeneration == requestGeneration) + return this; + + Func>? resolveAsync = _resolveAsync is null + ? null + : async cancellationToken => + (await _resolveAsync(cancellationToken).ConfigureAwait(false)) + .WithRequestContext(requestDocumentVersion, requestGeneration); + + return new LuaCompletionItem(Label, InsertText, Detail, Description, FilterText, Priority, IconKind, + IsDescriptionMarkdown, resolveAsync, TextEdit, requestDocumentVersion, requestGeneration, InsertCaretOffset); + } + + /// + /// Returns a copy of this item rebound to the current filtered popup state. + /// + /// The current editor document version. + /// The current editor request generation. + /// A new completion item that commits against the current completion segment. + public LuaCompletionItem WithFilteredCommitContext(int requestDocumentVersion, int requestGeneration) + { + if (RequestDocumentVersion == requestDocumentVersion + && RequestGeneration == requestGeneration + && TextEdit is null) + { + return this; + } + + Func>? resolveAsync = _resolveAsync is null + ? null + : async cancellationToken => + (await _resolveAsync(cancellationToken).ConfigureAwait(false)) + .WithFilteredCommitContext(requestDocumentVersion, requestGeneration); + + return new LuaCompletionItem(Label, InsertText, Detail, Description, FilterText, Priority, IconKind, + IsDescriptionMarkdown, resolveAsync, textEdit: null, requestDocumentVersion, requestGeneration, InsertCaretOffset); + } + + /// + /// Returns a copy of this item with resolved detail and documentation merged onto the original insertion metadata. + /// + /// The lazily resolved item. + /// A new completion item with resolved presentation data and preserved insertion metadata. + public LuaCompletionItem WithResolvedContent(LuaCompletionItem resolvedItem) + { + ArgumentNullException.ThrowIfNull(resolvedItem); + + string? detail = string.IsNullOrWhiteSpace(resolvedItem.Detail) ? Detail : resolvedItem.Detail; + string? description = string.IsNullOrWhiteSpace(resolvedItem.Description) ? Description : resolvedItem.Description; + bool isDescriptionMarkdown = string.IsNullOrWhiteSpace(resolvedItem.Description) + ? IsDescriptionMarkdown + : resolvedItem.IsDescriptionMarkdown; + + return new LuaCompletionItem(Label, InsertText, detail, description, FilterText, Priority, IconKind, + isDescriptionMarkdown, resolveAsync: null, TextEdit, RequestDocumentVersion, RequestGeneration, InsertCaretOffset); + } +} diff --git a/TombLib/TombLib.Scripting.Lua/Objects/LuaCompletionWindowStyle.cs b/TombLib/TombLib.Scripting.Lua/Objects/LuaCompletionWindowStyle.cs new file mode 100644 index 0000000000..7cab5d7392 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/Objects/LuaCompletionWindowStyle.cs @@ -0,0 +1,86 @@ +using ICSharpCode.AvalonEdit.CodeCompletion; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Media; +using TombLib.Scripting.Lua.Resources; + +namespace TombLib.Scripting.Lua.Objects; + +/// +/// Applies the Lua-specific visual styling for AvalonEdit completion popups. +/// +internal static class LuaCompletionWindowStyle +{ + private static readonly Style ItemContainerStyle = CreateItemContainerStyle(); + + /// + /// Applies the Lua completion-list styling to the supplied completion window. + /// + /// The completion window to style. + /// The theme brushes used by the completion UI. + public static void Apply(CompletionWindow window, LuaThemeBrushSet brushSet) + { + if (window is null) + return; + + window.FontFamily = window.TextArea.FontFamily; + window.FontSize = window.TextArea.FontSize; + window.CompletionList.HorizontalContentAlignment = HorizontalAlignment.Stretch; + window.CompletionList.ListBox.HorizontalContentAlignment = HorizontalAlignment.Stretch; + window.CompletionList.ListBox.BorderThickness = new Thickness(0.0); + window.CompletionList.ListBox.Focusable = false; + window.CompletionList.ListBox.IsTabStop = false; + window.CompletionList.ListBox.FontFamily = window.TextArea.FontFamily; + window.CompletionList.ListBox.FontSize = window.TextArea.FontSize; + window.CompletionList.ListBox.ItemTemplate = CreateItemTemplate(brushSet.MutedTextBrush); + window.CompletionList.ListBox.ItemContainerStyle = ItemContainerStyle; + } + + private static DataTemplate CreateItemTemplate(Brush detailBrush) + { + var template = new DataTemplate(typeof(LuaCompletionData)); + + var panel = new FrameworkElementFactory(typeof(DockPanel)); + panel.SetValue(DockPanel.LastChildFillProperty, true); + + var icon = new FrameworkElementFactory(typeof(Image)); + icon.SetValue(DockPanel.DockProperty, Dock.Left); + icon.SetValue(FrameworkElement.WidthProperty, 16.0); + icon.SetValue(FrameworkElement.HeightProperty, 16.0); + icon.SetValue(FrameworkElement.MarginProperty, new Thickness(0.0, 0.0, 8.0, 0.0)); + icon.SetValue(FrameworkElement.VerticalAlignmentProperty, VerticalAlignment.Center); + icon.SetBinding(Image.SourceProperty, new Binding(nameof(LuaCompletionData.Image))); + + var detail = new FrameworkElementFactory(typeof(TextBlock)); + detail.SetValue(DockPanel.DockProperty, Dock.Right); + detail.SetValue(TextBlock.ForegroundProperty, detailBrush); + detail.SetValue(TextBlock.TextTrimmingProperty, TextTrimming.CharacterEllipsis); + detail.SetValue(FrameworkElement.MarginProperty, new Thickness(12.0, 0.0, 0.0, 0.0)); + detail.SetValue(FrameworkElement.VerticalAlignmentProperty, VerticalAlignment.Center); + detail.SetBinding(TextBlock.TextProperty, new Binding(nameof(LuaCompletionData.DisplayDetail))); + detail.SetBinding(UIElement.VisibilityProperty, new Binding(nameof(LuaCompletionData.DetailVisibility))); + + var label = new FrameworkElementFactory(typeof(TextBlock)); + label.SetValue(TextBlock.TextTrimmingProperty, TextTrimming.CharacterEllipsis); + label.SetValue(FrameworkElement.VerticalAlignmentProperty, VerticalAlignment.Center); + label.SetBinding(TextBlock.TextProperty, new Binding(nameof(LuaCompletionData.DisplayText))); + + panel.AppendChild(icon); + panel.AppendChild(detail); + panel.AppendChild(label); + + template.VisualTree = panel; + return template; + } + + private static Style CreateItemContainerStyle() + { + var style = new Style(typeof(ListBoxItem)); + style.Setters.Add(new Setter(UIElement.FocusableProperty, false)); + style.Setters.Add(new Setter(Control.PaddingProperty, new Thickness(8.0, 3.0, 8.0, 3.0))); + style.Setters.Add(new Setter(Control.HorizontalContentAlignmentProperty, HorizontalAlignment.Stretch)); + style.Setters.Add(new Setter(Control.VerticalContentAlignmentProperty, VerticalAlignment.Center)); + return style; + } +} diff --git a/TombLib/TombLib.Scripting.Lua/Objects/LuaDefinitionLocation.cs b/TombLib/TombLib.Scripting.Lua/Objects/LuaDefinitionLocation.cs new file mode 100644 index 0000000000..acbccef167 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/Objects/LuaDefinitionLocation.cs @@ -0,0 +1,37 @@ +using System; + +namespace TombLib.Scripting.Lua.Objects; + +/// +/// Identifies a source location for a resolved Lua symbol definition. +/// +public sealed class LuaDefinitionLocation +{ + /// + /// Initializes a new instance of the class. + /// + /// The file containing the definition. + /// The one-based line number. + /// The one-based column number. + public LuaDefinitionLocation(string filePath, int lineNumber, int columnNumber) + { + FilePath = filePath; + LineNumber = Math.Max(1, lineNumber); + ColumnNumber = Math.Max(1, columnNumber); + } + + /// + /// Gets the file containing the definition. + /// + public string FilePath { get; } + + /// + /// Gets the one-based line number of the definition. + /// + public int LineNumber { get; } + + /// + /// Gets the one-based column number of the definition. + /// + public int ColumnNumber { get; } +} diff --git a/TombLib/TombLib.Scripting.Lua/Objects/LuaDocumentEdit.cs b/TombLib/TombLib.Scripting.Lua/Objects/LuaDocumentEdit.cs new file mode 100644 index 0000000000..58c322b80f --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/Objects/LuaDocumentEdit.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; + +namespace TombLib.Scripting.Lua.Objects; + +/// +/// Represents the text edits that should be applied to a single Lua document. +/// +public sealed class LuaDocumentEdit +{ + /// + /// Initializes a new instance of the class. + /// + /// The file to update. + /// The edits to apply. + public LuaDocumentEdit(string filePath, IReadOnlyList textEdits) + { + FilePath = filePath; + TextEdits = textEdits ?? []; + } + + /// + /// Gets the file to update. + /// + public string FilePath { get; } + + /// + /// Gets the edits to apply to the file. + /// + public IReadOnlyList TextEdits { get; } +} \ No newline at end of file diff --git a/TombLib/TombLib.Scripting.Lua/Objects/LuaDocumentRange.cs b/TombLib/TombLib.Scripting.Lua/Objects/LuaDocumentRange.cs new file mode 100644 index 0000000000..153b7bf3f7 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/Objects/LuaDocumentRange.cs @@ -0,0 +1,56 @@ +using System; + +namespace TombLib.Scripting.Lua.Objects; + +/// +/// Identifies a one-based document range inside a Lua source file. +/// +public sealed class LuaDocumentRange +{ + /// + /// Initializes a new instance of the class. + /// + /// The one-based start line number. + /// The one-based start column number. + /// The one-based end line number. + /// The one-based end column number. + public LuaDocumentRange(int startLineNumber, int startColumnNumber, int endLineNumber, int endColumnNumber) + { + int safeStartLineNumber = Math.Max(1, startLineNumber); + int safeStartColumnNumber = Math.Max(1, startColumnNumber); + int safeEndLineNumber = Math.Max(1, endLineNumber); + int safeEndColumnNumber = Math.Max(1, endColumnNumber); + + if (safeEndLineNumber < safeStartLineNumber + || (safeEndLineNumber == safeStartLineNumber && safeEndColumnNumber < safeStartColumnNumber)) + { + safeEndLineNumber = safeStartLineNumber; + safeEndColumnNumber = safeStartColumnNumber; + } + + StartLineNumber = safeStartLineNumber; + StartColumnNumber = safeStartColumnNumber; + EndLineNumber = safeEndLineNumber; + EndColumnNumber = safeEndColumnNumber; + } + + /// + /// Gets the one-based start line number. + /// + public int StartLineNumber { get; } + + /// + /// Gets the one-based start column number. + /// + public int StartColumnNumber { get; } + + /// + /// Gets the one-based end line number. + /// + public int EndLineNumber { get; } + + /// + /// Gets the one-based end column number. + /// + public int EndColumnNumber { get; } +} \ No newline at end of file diff --git a/TombLib/TombLib.Scripting.Lua/Objects/LuaFormattingOptions.cs b/TombLib/TombLib.Scripting.Lua/Objects/LuaFormattingOptions.cs new file mode 100644 index 0000000000..9f1eaa8c88 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/Objects/LuaFormattingOptions.cs @@ -0,0 +1,28 @@ +namespace TombLib.Scripting.Lua.Objects; + +/// +/// Represents the editor formatting preferences used for a Lua document formatting request. +/// +public sealed class LuaFormattingOptions +{ + /// + /// Initializes a new instance of the class. + /// + /// The preferred indentation width. + /// to indent with spaces; otherwise, tabs. + public LuaFormattingOptions(int tabSize, bool insertSpaces) + { + TabSize = tabSize > 0 ? tabSize : 4; + InsertSpaces = insertSpaces; + } + + /// + /// Gets the preferred indentation width. + /// + public int TabSize { get; } + + /// + /// Gets a value indicating whether indentation should use spaces. + /// + public bool InsertSpaces { get; } +} \ No newline at end of file diff --git a/TombLib/TombLib.Scripting.Lua/Objects/LuaHoverInfo.cs b/TombLib/TombLib.Scripting.Lua/Objects/LuaHoverInfo.cs new file mode 100644 index 0000000000..1a745545f4 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/Objects/LuaHoverInfo.cs @@ -0,0 +1,28 @@ +namespace TombLib.Scripting.Lua.Objects; + +/// +/// Describes the content shown in a Lua hover tooltip. +/// +public sealed class LuaHoverInfo +{ + /// + /// Initializes a new instance of the class. + /// + /// The hover content. + /// Whether the content should be rendered as Markdown. + public LuaHoverInfo(string content, bool isMarkdown) + { + Content = content; + IsMarkdown = isMarkdown; + } + + /// + /// Gets the hover content. + /// + public string Content { get; } + + /// + /// Gets a value indicating whether is Markdown. + /// + public bool IsMarkdown { get; } +} diff --git a/TombLib/TombLib.Scripting.Lua/Objects/LuaReferenceLocation.cs b/TombLib/TombLib.Scripting.Lua/Objects/LuaReferenceLocation.cs new file mode 100644 index 0000000000..906f2110cf --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/Objects/LuaReferenceLocation.cs @@ -0,0 +1,28 @@ +namespace TombLib.Scripting.Lua.Objects; + +/// +/// Identifies a source location for a Lua symbol reference. +/// +public sealed class LuaReferenceLocation +{ + /// + /// Initializes a new instance of the class. + /// + /// The file containing the reference. + /// The referenced range within the file. + public LuaReferenceLocation(string filePath, LuaDocumentRange range) + { + FilePath = filePath; + Range = range; + } + + /// + /// Gets the file containing the reference. + /// + public string FilePath { get; } + + /// + /// Gets the referenced range within the file. + /// + public LuaDocumentRange Range { get; } +} \ No newline at end of file diff --git a/TombLib/TombLib.Scripting.Lua/Objects/LuaSemanticToken.cs b/TombLib/TombLib.Scripting.Lua/Objects/LuaSemanticToken.cs new file mode 100644 index 0000000000..59745c5b21 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/Objects/LuaSemanticToken.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; + +namespace TombLib.Scripting.Lua.Objects; + +/// +/// Represents a single semantic token produced by the Lua language service. +/// +public sealed class LuaSemanticToken +{ + /// + /// Initializes a new instance of the class. + /// + /// The zero-based line index. + /// The zero-based character index within the line. + /// The token length in characters. + /// The semantic token type. + /// The semantic token modifiers. + public LuaSemanticToken(int line, int character, int length, string type, IReadOnlyList modifiers) + { + Line = Math.Max(0, line); + Character = Math.Max(0, character); + Length = Math.Max(0, length); + Type = type ?? string.Empty; + Modifiers = modifiers ?? []; + } + + /// + /// Gets the zero-based line index containing the token. + /// + public int Line { get; } + + /// + /// Gets the zero-based character index of the token within its line. + /// + public int Character { get; } + + /// + /// Gets the token length in characters. + /// + public int Length { get; } + + /// + /// Gets the semantic token type. + /// + public string Type { get; } + + /// + /// Gets the semantic token modifiers. + /// + public IReadOnlyList Modifiers { get; } + + /// + /// Determines whether the token has the specified modifier. + /// + /// The modifier name to check. + /// if the modifier is present; otherwise, . + public bool HasModifier(string modifier) + { + if (string.IsNullOrWhiteSpace(modifier) || Modifiers.Count == 0) + return false; + + for (int i = 0; i < Modifiers.Count; i++) + { + if (string.Equals(Modifiers[i], modifier, StringComparison.Ordinal)) + return true; + } + + return false; + } +} diff --git a/TombLib/TombLib.Scripting.Lua/Objects/LuaSignatureInfo.cs b/TombLib/TombLib.Scripting.Lua/Objects/LuaSignatureInfo.cs new file mode 100644 index 0000000000..55da5caeef --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/Objects/LuaSignatureInfo.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; + +namespace TombLib.Scripting.Lua.Objects; + +/// +/// Describes signature-help data for a function call. +/// +public sealed class LuaSignatureInfo +{ + /// + /// Initializes a new instance of the class. + /// + /// The full signature label to display. + /// Optional documentation for the signature. + /// The parameters available in the signature. + /// The zero-based index of the active parameter. + public LuaSignatureInfo(string label, string? documentation, IReadOnlyList parameters, + int activeParameter) + { + Label = label; + Documentation = documentation; + Parameters = parameters ?? []; + ActiveParameter = Math.Max(0, activeParameter); + } + + /// + /// Gets the full label shown in signature help. + /// + public string Label { get; } + + /// + /// Gets the optional documentation associated with the signature. + /// + public string? Documentation { get; } + + /// + /// Gets the parameter metadata associated with the signature. + /// + public IReadOnlyList Parameters { get; } + + /// + /// Gets the zero-based index of the active parameter. + /// + public int ActiveParameter { get; } +} + +/// +/// Describes a single signature parameter. +/// +public sealed class LuaParameterInfo +{ + /// + /// Initializes a new instance of the class. + /// + /// The parameter label. + /// Optional documentation for the parameter. + public LuaParameterInfo(string label, string? documentation) + { + Label = label; + Documentation = documentation; + } + + /// + /// Gets the display label for the parameter. + /// + public string Label { get; } + + /// + /// Gets the optional documentation associated with the parameter. + /// + public string? Documentation { get; } +} diff --git a/TombLib/TombLib.Scripting.Lua/Objects/LuaTextEdit.cs b/TombLib/TombLib.Scripting.Lua/Objects/LuaTextEdit.cs new file mode 100644 index 0000000000..7363c068e9 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/Objects/LuaTextEdit.cs @@ -0,0 +1,28 @@ +namespace TombLib.Scripting.Lua.Objects; + +/// +/// Represents a single text replacement inside a Lua document. +/// +public sealed class LuaTextEdit +{ + /// + /// Initializes a new instance of the class. + /// + /// The range to replace. + /// The replacement text. + public LuaTextEdit(LuaDocumentRange range, string newText) + { + Range = range; + NewText = newText ?? string.Empty; + } + + /// + /// Gets the range to replace. + /// + public LuaDocumentRange Range { get; } + + /// + /// Gets the replacement text. + /// + public string NewText { get; } +} \ No newline at end of file diff --git a/TombLib/TombLib.Scripting.Lua/Objects/LuaTheme.cs b/TombLib/TombLib.Scripting.Lua/Objects/LuaTheme.cs new file mode 100644 index 0000000000..509729250c --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/Objects/LuaTheme.cs @@ -0,0 +1,164 @@ +using System.Collections.Generic; +using TombLib.Scripting.Highlighting; +using TombLib.Scripting.Lua.Resources; + +namespace TombLib.Scripting.Lua.Objects; + +/// +/// Describes a Lua editor theme, including editor colors, semantic colors, and TextMate token colors. +/// +public sealed class LuaTheme +{ + /// + /// Gets or sets the display name of the theme. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets additional names that can be used to resolve this theme. + /// + public List Aliases { get; set; } = []; + + /// + /// Gets or sets the editor background color. + /// + public string EditorBackground { get; set; } = LuaBuiltInThemes.DefaultEditorBackground; + + /// + /// Gets or sets the editor foreground color. + /// + public string EditorForeground { get; set; } = LuaBuiltInThemes.DefaultEditorForeground; + + /// + /// Gets or sets the TextMate token theme used for syntax highlighting. + /// + public TextMateTokenTheme TextMateTheme { get; set; } = new TextMateTokenTheme(); + + /// + /// Gets or sets the semantic color palette used for Lua editor features. + /// + public LuaThemeSemanticColors SemanticColors { get; set; } = new LuaThemeSemanticColors(); + + /// + /// Normalizes missing values so the theme can be used safely at runtime. + /// + /// The theme name to use when no explicit name was provided. + /// The current theme instance. + public LuaTheme Normalize(string fallbackName) + { + if (string.IsNullOrWhiteSpace(Name)) + Name = fallbackName; + + if (string.IsNullOrWhiteSpace(EditorBackground)) + EditorBackground = LuaBuiltInThemes.DefaultEditorBackground; + + if (string.IsNullOrWhiteSpace(EditorForeground)) + EditorForeground = LuaBuiltInThemes.DefaultEditorForeground; + + Aliases ??= []; + TextMateTheme ??= new TextMateTokenTheme(); + SemanticColors ??= new LuaThemeSemanticColors(); + SemanticColors.Normalize(); + + return this; + } +} + +/// +/// Defines the semantic color slots used by the Lua editor UI. +/// +public sealed class LuaThemeSemanticColors +{ + /// + /// Gets or sets the muted text color used for secondary completion details. + /// + public string MutedText { get; set; } = LuaBuiltInThemes.DefaultMutedText; + + /// + /// Gets or sets the fallback color used for uncategorized symbols. + /// + public string Misc { get; set; } = LuaBuiltInThemes.DefaultMisc; + + /// + /// Gets or sets the color used for methods and functions. + /// + public string Method { get; set; } = LuaBuiltInThemes.DefaultMethod; + + /// + /// Gets or sets the color used for variables and parameters. + /// + public string Variable { get; set; } = LuaBuiltInThemes.DefaultVariable; + + /// + /// Gets or sets the color used for properties and fields. + /// + public string Property { get; set; } = LuaBuiltInThemes.DefaultProperty; + + /// + /// Gets or sets the color used for types and namespaces. + /// + public string Type { get; set; } = LuaBuiltInThemes.DefaultType; + + /// + /// Gets or sets the color used for keywords. + /// + public string Keyword { get; set; } = LuaBuiltInThemes.DefaultKeyword; + + /// + /// Gets or sets the color used for language-defined constants. + /// + public string LanguageConstant { get; set; } = LuaBuiltInThemes.DefaultLanguageConstant; + + /// + /// Gets or sets the color used for user-defined constants and enum members. + /// + public string Constant { get; set; } = LuaBuiltInThemes.DefaultConstant; + + /// + /// Gets or sets the color used for file and folder completion items. + /// + public string File { get; set; } = LuaBuiltInThemes.DefaultFile; + + /// + /// Gets or sets the color used for signature-help documentation text. + /// + public string SignatureParameterDocumentation { get; set; } = LuaBuiltInThemes.DefaultSignatureParameterDocumentation; + + /// + /// Gets or sets the color used to emphasize the active signature parameter. + /// + public string SignatureActiveParameter { get; set; } = LuaBuiltInThemes.DefaultSignatureActiveParameter; + + /// + /// Gets or sets the base color used for signature labels. + /// + public string SignatureText { get; set; } = LuaBuiltInThemes.DefaultSignatureText; + + /// + /// Replaces missing or whitespace-only color values with built-in defaults. + /// + public void Normalize() + { + MutedText = NormalizeValue(MutedText, LuaBuiltInThemes.DefaultMutedText); + Misc = NormalizeValue(Misc, LuaBuiltInThemes.DefaultMisc); + Method = NormalizeValue(Method, LuaBuiltInThemes.DefaultMethod); + Variable = NormalizeValue(Variable, LuaBuiltInThemes.DefaultVariable); + Property = NormalizeValue(Property, LuaBuiltInThemes.DefaultProperty); + Type = NormalizeValue(Type, LuaBuiltInThemes.DefaultType); + Keyword = NormalizeValue(Keyword, LuaBuiltInThemes.DefaultKeyword); + LanguageConstant = NormalizeValue(LanguageConstant, LuaBuiltInThemes.DefaultLanguageConstant); + Constant = NormalizeValue(Constant, LuaBuiltInThemes.DefaultConstant); + File = NormalizeValue(File, LuaBuiltInThemes.DefaultFile); + SignatureParameterDocumentation = NormalizeValue(SignatureParameterDocumentation, LuaBuiltInThemes.DefaultSignatureParameterDocumentation); + SignatureActiveParameter = NormalizeValue(SignatureActiveParameter, LuaBuiltInThemes.DefaultSignatureActiveParameter); + SignatureText = NormalizeValue(SignatureText, LuaBuiltInThemes.DefaultSignatureText); + } + + private static string NormalizeValue(string value, string fallbackValue) + { + if (string.IsNullOrWhiteSpace(value)) + return fallbackValue; + + return value; + } +} diff --git a/TombLib/TombLib.Scripting.Lua/Objects/LuaWorkspaceEdit.cs b/TombLib/TombLib.Scripting.Lua/Objects/LuaWorkspaceEdit.cs new file mode 100644 index 0000000000..c3e98c050d --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/Objects/LuaWorkspaceEdit.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; + +namespace TombLib.Scripting.Lua.Objects; + +/// +/// Represents a workspace-wide set of Lua document edits. +/// +public sealed class LuaWorkspaceEdit +{ + /// + /// Initializes a new instance of the class. + /// + /// The per-document edits in the workspace change set. + public LuaWorkspaceEdit(IReadOnlyList documentEdits) + { + DocumentEdits = documentEdits ?? []; + } + + /// + /// Gets the per-document edits in the workspace change set. + /// + public IReadOnlyList DocumentEdits { get; } + + /// + /// Gets a value indicating whether the workspace edit contains any text edits. + /// + public bool HasEdits => DocumentEdits.Count > 0; +} \ No newline at end of file diff --git a/TombLib/TombLib.Scripting.Lua/Objects/SyntaxHighlighting.cs b/TombLib/TombLib.Scripting.Lua/Objects/SyntaxHighlighting.cs deleted file mode 100644 index 5b339ac0dc..0000000000 --- a/TombLib/TombLib.Scripting.Lua/Objects/SyntaxHighlighting.cs +++ /dev/null @@ -1,107 +0,0 @@ -using ICSharpCode.AvalonEdit.Highlighting; -using System; -using System.Collections.Generic; -using System.Text.RegularExpressions; -using System.Windows; -using System.Windows.Media; -using TombLib.Scripting.Lua.Resources; - -namespace TombLib.Scripting.Lua.Objects -{ - public sealed class SyntaxHighlighting : IHighlightingDefinition - { - private readonly ColorScheme _scheme; - - #region Construction - - public SyntaxHighlighting(ColorScheme scheme) - => _scheme = scheme; - - #endregion Construction - - #region Rules - - public HighlightingRuleSet MainRuleSet - { - get - { - var ruleSet = new HighlightingRuleSet(); - - ruleSet.Rules.Add(new HighlightingRule - { - Regex = new Regex(Patterns.Comments), - Color = new HighlightingColor - { - Foreground = new SimpleHighlightingBrush((Color)ColorConverter.ConvertFromString(_scheme.Comments.HtmlColor)), - FontWeight = _scheme.Comments.IsBold ? FontWeights.Bold : FontWeights.Normal, - FontStyle = _scheme.Comments.IsItalic ? FontStyles.Italic : FontStyles.Normal - } - }); - - ruleSet.Rules.Add(new HighlightingRule - { - Regex = new Regex(Patterns.Values, RegexOptions.IgnoreCase), - Color = new HighlightingColor - { - Foreground = new SimpleHighlightingBrush((Color)ColorConverter.ConvertFromString(_scheme.Values.HtmlColor)), - FontWeight = _scheme.Values.IsBold ? FontWeights.Bold : FontWeights.Normal, - FontStyle = _scheme.Values.IsItalic ? FontStyles.Italic : FontStyles.Normal - } - }); - - ruleSet.Rules.Add(new HighlightingRule - { - Regex = new Regex(Patterns.Statements, RegexOptions.IgnoreCase), - Color = new HighlightingColor - { - Foreground = new SimpleHighlightingBrush((Color)ColorConverter.ConvertFromString(_scheme.Statements.HtmlColor)), - FontWeight = _scheme.Statements.IsBold ? FontWeights.Bold : FontWeights.Normal, - FontStyle = _scheme.Statements.IsItalic ? FontStyles.Italic : FontStyles.Normal - } - }); - - ruleSet.Rules.Add(new HighlightingRule - { - Regex = new Regex(Patterns.Operators, RegexOptions.IgnoreCase), - Color = new HighlightingColor - { - Foreground = new SimpleHighlightingBrush((Color)ColorConverter.ConvertFromString(_scheme.Operators.HtmlColor)), - FontWeight = _scheme.Operators.IsBold ? FontWeights.Bold : FontWeights.Normal, - FontStyle = _scheme.Operators.IsItalic ? FontStyles.Italic : FontStyles.Normal - } - }); - - ruleSet.Rules.Add(new HighlightingRule - { - Regex = new Regex(Patterns.SpecialOperators, RegexOptions.IgnoreCase), - Color = new HighlightingColor - { - Foreground = new SimpleHighlightingBrush((Color)ColorConverter.ConvertFromString(_scheme.SpecialOperators.HtmlColor)), - FontWeight = _scheme.SpecialOperators.IsBold ? FontWeights.Bold : FontWeights.Normal, - FontStyle = _scheme.SpecialOperators.IsItalic ? FontStyles.Italic : FontStyles.Normal - } - }); - - ruleSet.Name = "Lua Rules"; - return ruleSet; - } - } - - #endregion Rules - - #region Other - - public string Name => "Lua Rules"; - - public IEnumerable NamedHighlightingColors => throw new NotImplementedException(); - public IDictionary Properties => throw new NotImplementedException(); - - public HighlightingColor GetNamedColor(string name) - => throw new NotImplementedException(); - - public HighlightingRuleSet GetNamedRuleSet(string name) - => throw new NotImplementedException(); - - #endregion Other - } -} diff --git a/TombLib/TombLib.Scripting.Lua/Properties/InternalsVisibleTo.cs b/TombLib/TombLib.Scripting.Lua/Properties/InternalsVisibleTo.cs new file mode 100644 index 0000000000..d34753098b --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/Properties/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("TombLib.Test")] diff --git a/TombLib/TombLib.Scripting.Lua/Resources/ConfigurationDefaults.cs b/TombLib/TombLib.Scripting.Lua/Resources/ConfigurationDefaults.cs index ac4ef80066..fee7f45f97 100644 --- a/TombLib/TombLib.Scripting.Lua/Resources/ConfigurationDefaults.cs +++ b/TombLib/TombLib.Scripting.Lua/Resources/ConfigurationDefaults.cs @@ -1,10 +1,17 @@ -namespace TombLib.Scripting.Lua.Resources +namespace TombLib.Scripting.Lua.Resources; + +/// +/// Defines default values used by Lua editor configuration objects. +/// +public static class ConfigurationDefaults { - public struct ConfigurationDefaults - { - public const string ConfigurationFileName = "LuaConfiguration.xml"; - public const string ColorSchemeFileExtension = ".luasch"; + /// + /// Gets the default file name used to persist Lua editor configuration. + /// + public const string ConfigurationFileName = "LuaConfiguration.xml"; - public const string SelectedColorSchemeName = "VS15"; - } + /// + /// Gets the default selected Lua theme name. + /// + public const string SelectedThemeName = LuaBuiltInThemes.DefaultThemeName; } diff --git a/TombLib/TombLib.Scripting.Lua/Resources/Keywords.cs b/TombLib/TombLib.Scripting.Lua/Resources/Keywords.cs deleted file mode 100644 index 9e1c132b95..0000000000 --- a/TombLib/TombLib.Scripting.Lua/Resources/Keywords.cs +++ /dev/null @@ -1,56 +0,0 @@ -namespace TombLib.Scripting.Lua.Resources -{ - public static class Keywords - { - public static readonly string[] Statements = new string[] - { - "if", - "then", - "else", - "elseif", - "function", - "end", - "for", - "while", - "in", - "repeat", - "until", - "break", - "return", - "local", - "print" - }; - - public static readonly string[] Values = new string[] - { - "true", - "false", - "nil" - }; - - public static readonly string[] Operators = new string[] - { - "%", - @"\+", - @"-", - @"\*", - "/", - @"\^", - @"\=", - @"~\=", - @">", - @"<", - @":", - @"\.", - @"\[", - @"\]" - }; - - public static readonly string[] SpecialOperators = new string[] - { - "and", - "or", - "not", - }; - } -} diff --git a/TombLib/TombLib.Scripting.Lua/Resources/LuaBuiltInThemes.cs b/TombLib/TombLib.Scripting.Lua/Resources/LuaBuiltInThemes.cs new file mode 100644 index 0000000000..67ac225779 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/Resources/LuaBuiltInThemes.cs @@ -0,0 +1,64 @@ +using TombLib.Scripting.Highlighting; +using TombLib.Scripting.Lua.Objects; + +namespace TombLib.Scripting.Lua.Resources; + +/// +/// Defines the built-in Lua theme defaults used when no external theme data is available. +/// +internal static class LuaBuiltInThemes +{ + public const string DefaultThemeName = "SharpLua Classic"; + public const string DefaultThemeAlias = "SharpLua"; + + public const string DefaultEditorBackground = LuaBuiltInTextMateThemeDefaults.DefaultEditorBackground; + public const string DefaultEditorForeground = LuaBuiltInTextMateThemeDefaults.DefaultEditorForeground; + + public const string DefaultMutedText = LuaBuiltInTextMateThemeDefaults.DefaultMutedText; + public const string DefaultMisc = LuaBuiltInTextMateThemeDefaults.DefaultMisc; + public const string DefaultMethod = LuaBuiltInTextMateThemeDefaults.DefaultMethod; + public const string DefaultVariable = LuaBuiltInTextMateThemeDefaults.DefaultVariable; + public const string DefaultProperty = LuaBuiltInTextMateThemeDefaults.DefaultProperty; + public const string DefaultType = LuaBuiltInTextMateThemeDefaults.DefaultType; + public const string DefaultKeyword = LuaBuiltInTextMateThemeDefaults.DefaultKeyword; + public const string DefaultLanguageConstant = LuaBuiltInTextMateThemeDefaults.DefaultLanguageConstant; + public const string DefaultConstant = LuaBuiltInTextMateThemeDefaults.DefaultConstant; + public const string DefaultFile = LuaBuiltInTextMateThemeDefaults.DefaultFile; + public const string DefaultSignatureParameterDocumentation = LuaBuiltInTextMateThemeDefaults.DefaultSignatureParameterDocumentation; + public const string DefaultSignatureActiveParameter = LuaBuiltInTextMateThemeDefaults.DefaultSignatureActiveParameter; + public const string DefaultSignatureText = LuaBuiltInTextMateThemeDefaults.DefaultSignatureText; + + /// + /// Creates the built-in fallback Lua theme. + /// + /// A fully populated default Lua theme instance. + public static LuaTheme CreateDefaultTheme() + { + return new LuaTheme + { + Name = DefaultThemeName, + Aliases = [DefaultThemeAlias], + EditorBackground = DefaultEditorBackground, + EditorForeground = DefaultEditorForeground, + + SemanticColors = new LuaThemeSemanticColors + { + MutedText = DefaultMutedText, + Misc = DefaultMisc, + Method = DefaultMethod, + Variable = DefaultVariable, + Property = DefaultProperty, + Type = DefaultType, + Keyword = DefaultKeyword, + LanguageConstant = DefaultLanguageConstant, + Constant = DefaultConstant, + File = DefaultFile, + SignatureParameterDocumentation = DefaultSignatureParameterDocumentation, + SignatureActiveParameter = DefaultSignatureActiveParameter, + SignatureText = DefaultSignatureText + }, + + TextMateTheme = LuaBuiltInTextMateThemeDefaults.CreateDefaultTextMateTheme() + }; + } +} diff --git a/TombLib/TombLib.Scripting.Lua/Resources/LuaEditorColorPalette.cs b/TombLib/TombLib.Scripting.Lua/Resources/LuaEditorColorPalette.cs new file mode 100644 index 0000000000..3643b6de92 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/Resources/LuaEditorColorPalette.cs @@ -0,0 +1,60 @@ +using System.Windows.Media; +using TombLib.Scripting.Lua.Objects; +using TombLib.Scripting.Resources; +using static TombLib.WPF.BrushHelpers; + +namespace TombLib.Scripting.Lua.Resources; + +/// +/// Builds the frozen brush palette used by the Lua editor from a resolved theme definition. +/// +internal static class LuaEditorColorPalette +{ + /// + /// Creates the editor brush set for the supplied Lua theme. + /// + /// The theme to materialize into brushes. + /// A frozen brush set ready for use by the editor UI. + public static LuaThemeBrushSet Create(LuaTheme theme) + { + LuaTheme effectiveTheme = (theme ?? new LuaTheme()).Normalize(ConfigurationDefaults.SelectedThemeName); + LuaThemeSemanticColors semanticColors = effectiveTheme.SemanticColors; + + return new LuaThemeBrushSet( + effectiveTheme.Name, + CreateBrush(effectiveTheme.EditorBackground, LuaBuiltInThemes.DefaultEditorBackground), + CreateBrush(effectiveTheme.EditorForeground, LuaBuiltInThemes.DefaultEditorForeground), + CreateBrush(semanticColors.MutedText, LuaBuiltInThemes.DefaultMutedText), + CreateBrush(semanticColors.Misc, LuaBuiltInThemes.DefaultMisc), + CreateBrush(semanticColors.Method, LuaBuiltInThemes.DefaultMethod), + CreateBrush(semanticColors.Variable, LuaBuiltInThemes.DefaultVariable), + CreateBrush(semanticColors.Property, LuaBuiltInThemes.DefaultProperty), + CreateBrush(semanticColors.Type, LuaBuiltInThemes.DefaultType), + CreateBrush(semanticColors.Keyword, LuaBuiltInThemes.DefaultKeyword), + CreateBrush(semanticColors.LanguageConstant, LuaBuiltInThemes.DefaultLanguageConstant), + CreateBrush(semanticColors.Constant, LuaBuiltInThemes.DefaultConstant), + CreateBrush(semanticColors.File, LuaBuiltInThemes.DefaultFile), + CreateBrush(semanticColors.SignatureParameterDocumentation, LuaBuiltInThemes.DefaultSignatureParameterDocumentation), + CreateBrush(semanticColors.SignatureActiveParameter, LuaBuiltInThemes.DefaultSignatureActiveParameter), + CreateBrush(semanticColors.SignatureText, ColorToString(TextEditorColorPalette.ToolTipForeground.Color))); + } + + private static SolidColorBrush CreateBrush(string colorValue, string fallbackColorValue) + { + try + { + string effectiveColorValue = string.IsNullOrWhiteSpace(colorValue) + ? fallbackColorValue + : colorValue; + + return CreateFrozenBrush(effectiveColorValue); + } + catch + { + return CreateFrozenBrush(fallbackColorValue); + } + } + + private static string ColorToString(Color color) + => $"#{color.R:X2}{color.G:X2}{color.B:X2}"; +} diff --git a/TombLib/TombLib.Scripting.Lua/Resources/LuaThemeBrushSet.cs b/TombLib/TombLib.Scripting.Lua/Resources/LuaThemeBrushSet.cs new file mode 100644 index 0000000000..101f279415 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/Resources/LuaThemeBrushSet.cs @@ -0,0 +1,127 @@ +using System.Windows.Media; +using TombLib.Scripting.Lua.Objects; + +namespace TombLib.Scripting.Lua.Resources; + +/// +/// Bundles the frozen WPF brushes derived from a resolved Lua theme. +/// +internal sealed class LuaThemeBrushSet( + string themeName, + SolidColorBrush editorBackground, + SolidColorBrush editorForeground, + SolidColorBrush mutedText, + SolidColorBrush misc, + SolidColorBrush method, + SolidColorBrush variable, + SolidColorBrush property, + SolidColorBrush type, + SolidColorBrush keyword, + SolidColorBrush languageConstant, + SolidColorBrush constant, + SolidColorBrush file, + SolidColorBrush signatureParamDoc, + SolidColorBrush signatureActiveParam, + SolidColorBrush signatureForeground) +{ + /// + /// Gets the canonical name of the theme that produced this brush set. + /// + public string ThemeName { get; } = themeName; + + /// + /// Gets the editor background brush. + /// + public SolidColorBrush EditorBackground { get; } = editorBackground; + + /// + /// Gets the editor foreground brush. + /// + public SolidColorBrush EditorForeground { get; } = editorForeground; + + /// + /// Gets the muted text brush used for secondary completion details and similar metadata. + /// + public SolidColorBrush MutedTextBrush { get; } = mutedText; + + /// + /// Gets the fallback brush used for uncategorized symbols. + /// + public SolidColorBrush MiscBrush { get; } = misc; + + /// + /// Gets the brush used for methods and functions. + /// + public SolidColorBrush MethodBrush { get; } = method; + + /// + /// Gets the brush used for variables and parameters. + /// + public SolidColorBrush VariableBrush { get; } = variable; + + /// + /// Gets the brush used for properties and fields. + /// + public SolidColorBrush PropertyBrush { get; } = property; + + /// + /// Gets the brush used for types and namespaces. + /// + public SolidColorBrush TypeBrush { get; } = type; + + /// + /// Gets the brush used for keywords. + /// + public SolidColorBrush KeywordBrush { get; } = keyword; + + /// + /// Gets the brush used for language-defined constants. + /// + public SolidColorBrush LanguageConstantBrush { get; } = languageConstant; + + /// + /// Gets the brush used for user-defined constants and enum members. + /// + public SolidColorBrush ConstantBrush { get; } = constant; + + /// + /// Gets the brush used for file and folder completion items. + /// + public SolidColorBrush FileBrush { get; } = file; + + /// + /// Gets the brush used for signature-help parameter documentation. + /// + public SolidColorBrush SignatureParamDocForeground { get; } = signatureParamDoc; + + /// + /// Gets the brush used to emphasize the active signature parameter. + /// + public SolidColorBrush SignatureActiveParamForeground { get; } = signatureActiveParam; + + /// + /// Gets the base brush used for signature labels. + /// + public SolidColorBrush SignatureForeground { get; } = signatureForeground; + + /// + /// Gets the brush used to render the specified completion item kind. + /// + /// The completion item icon kind. + /// The brush associated with that kind. + public Brush GetCompletionItemBrush(LuaCompletionIconKind kind) => kind switch + { + LuaCompletionIconKind.Variable => VariableBrush, + LuaCompletionIconKind.Field => PropertyBrush, + LuaCompletionIconKind.Method => MethodBrush, + LuaCompletionIconKind.Property => PropertyBrush, + LuaCompletionIconKind.Class => TypeBrush, + LuaCompletionIconKind.Keyword => KeywordBrush, + LuaCompletionIconKind.Constant => ConstantBrush, + LuaCompletionIconKind.Parameter => VariableBrush, + LuaCompletionIconKind.Namespace => TypeBrush, + LuaCompletionIconKind.File => FileBrush, + LuaCompletionIconKind.Folder => FileBrush, + _ => MiscBrush + }; +} diff --git a/TombLib/TombLib.Scripting.Lua/Resources/LuaThemeRepository.cs b/TombLib/TombLib.Scripting.Lua/Resources/LuaThemeRepository.cs new file mode 100644 index 0000000000..280bcff746 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/Resources/LuaThemeRepository.cs @@ -0,0 +1,129 @@ +using NLog; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using TombLib.Scripting.Lua.Objects; + +namespace TombLib.Scripting.Lua.Resources; + +/// +/// Loads and resolves Lua editor themes from disk, falling back to the built-in default theme when needed. +/// +public static class LuaThemeRepository +{ + private static readonly Logger Log = LogManager.GetCurrentClassLogger(); + + private static readonly Lazy Catalog = new(LoadCatalog); + + /// + /// Gets all available Lua themes known to the repository. + /// + /// The ordered list of available themes. + public static IReadOnlyList GetAvailableThemes() + => Catalog.Value.Themes; + + /// + /// Resolves a theme name or alias to the repository's canonical theme name. + /// + /// The theme name or alias to resolve. + /// The canonical theme name. + public static string ResolveThemeName(string themeName) + { + LuaTheme theme = GetTheme(themeName); + return theme.Name; + } + + /// + /// Gets the theme matching the supplied name or alias, or the default theme when no match exists. + /// + /// The theme name or alias to resolve. + /// The resolved theme. + public static LuaTheme GetTheme(string themeName) + { + LuaThemeCatalog catalog = Catalog.Value; + + if (!string.IsNullOrWhiteSpace(themeName) + && catalog.ThemesByLookupName.TryGetValue(themeName, out LuaTheme? theme) + && theme is not null) + { + return theme; + } + + return catalog.DefaultTheme; + } + + private static LuaThemeCatalog LoadCatalog() + { + var themes = new List(); + string themesDirectory = DefaultPaths.LuaThemeConfigsDirectory; + + var serializerOptions = new JsonSerializerOptions + { + AllowTrailingCommas = true, + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip + }; + + if (Directory.Exists(themesDirectory)) + { + foreach (string filePath in Directory.GetFiles(themesDirectory, "*.json", SearchOption.TopDirectoryOnly)) + { + try + { + string fileContent = File.ReadAllText(filePath); + LuaTheme? theme = JsonSerializer.Deserialize(fileContent, serializerOptions); + + if (theme is not null) + themes.Add(theme.Normalize(Path.GetFileNameWithoutExtension(filePath))); + } + catch (Exception exception) + { + Log.Warn(exception, "Failed to load Lua theme '{FilePath}'.", filePath); + } + } + } + + if (themes.Count == 0) + themes.Add(LuaBuiltInThemes.CreateDefaultTheme().Normalize(ConfigurationDefaults.SelectedThemeName)); + + var orderedThemes = themes + .OrderByDescending(theme => string.Equals(theme.Name, ConfigurationDefaults.SelectedThemeName, StringComparison.OrdinalIgnoreCase)) + .ThenBy(theme => theme.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var themesByLookupName = new Dictionary(StringComparer.OrdinalIgnoreCase); + + for (int i = 0; i < orderedThemes.Count; i++) + { + LuaTheme theme = orderedThemes[i]; + AddLookupName(themesByLookupName, theme.Name, theme); + + for (int aliasIndex = 0; aliasIndex < theme.Aliases.Count; aliasIndex++) + AddLookupName(themesByLookupName, theme.Aliases[aliasIndex], theme); + } + + LuaTheme defaultTheme = themesByLookupName.TryGetValue(ConfigurationDefaults.SelectedThemeName, out LuaTheme? configuredTheme) + && configuredTheme is not null + ? configuredTheme + : orderedThemes[0]; + + return new LuaThemeCatalog(orderedThemes, themesByLookupName, defaultTheme); + } + + private static void AddLookupName(Dictionary themesByLookupName, string lookupName, LuaTheme theme) + { + if (string.IsNullOrWhiteSpace(lookupName) || themesByLookupName.ContainsKey(lookupName)) + return; + + themesByLookupName[lookupName] = theme; + } + + private sealed class LuaThemeCatalog(IReadOnlyList themes, IReadOnlyDictionary themesByLookupName, LuaTheme defaultTheme) + { + public IReadOnlyList Themes { get; } = themes; + public IReadOnlyDictionary ThemesByLookupName { get; } = themesByLookupName; + public LuaTheme DefaultTheme { get; } = defaultTheme; + } +} diff --git a/TombLib/TombLib.Scripting.Lua/Resources/Patterns.cs b/TombLib/TombLib.Scripting.Lua/Resources/Patterns.cs deleted file mode 100644 index f0b14206a0..0000000000 --- a/TombLib/TombLib.Scripting.Lua/Resources/Patterns.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace TombLib.Scripting.Lua.Resources -{ - public struct Patterns - { - public static string Comments => @"--.*$"; - public static string Operators => @"(" + string.Join("|", Keywords.Operators) + @")"; - public static string SpecialOperators => @"\b(" + string.Join("|", Keywords.SpecialOperators) + @")\b"; - public static string Statements => @"\b(" + string.Join("|", Keywords.Statements) + @")\b"; - public static string Values => @"\b(" + string.Join("|", Keywords.Values) + @")\b"; - } -} diff --git a/TombLib/TombLib.Scripting.Lua/Services/ILuaIntellisenseProvider.cs b/TombLib/TombLib.Scripting.Lua/Services/ILuaIntellisenseProvider.cs new file mode 100644 index 0000000000..4785901f70 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/Services/ILuaIntellisenseProvider.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using TombLib.Scripting.Lua.Objects; +using TombLib.Scripting.Objects; + +namespace TombLib.Scripting.Lua.Services; + +/// +/// Defines the language-service contract used by to provide Lua IntelliSense features. +/// +public interface ILuaIntellisenseProvider : IDisposable +{ + /// + /// Gets a value indicating whether the provider is ready to serve IntelliSense requests. + /// + bool IsAvailable { get; } + + /// + /// Gets a value indicating whether the provider supports Lua symbol reference requests. + /// + bool SupportsReferences { get; } + + /// + /// Gets a value indicating whether the provider supports Lua symbol rename requests. + /// + bool SupportsRename { get; } + + /// + /// Gets a value indicating whether the provider supports Lua document formatting requests. + /// + bool SupportsFormatting { get; } + + /// + /// Occurs when diagnostics for a document have changed. + /// + event Action>? DiagnosticsUpdated; + + /// + /// Occurs when semantic tokens for a document have changed. + /// + event Action>? SemanticTokensUpdated; + + /// + /// Gets the latest diagnostics known for a document. + /// + /// The path of the document. + /// The diagnostics currently cached for the document. + IReadOnlyList GetDiagnostics(string filePath); + + /// + /// Gets the latest semantic tokens known for a document. + /// + /// The path of the document. + /// The semantic tokens currently cached for the document. + IReadOnlyList GetSemanticTokens(string filePath); + + /// + /// Opens a document in the provider and starts tracking its contents. + /// + /// The document path. + /// The initial document content. + void OpenDocument(string filePath, string content); + + /// + /// Pushes updated content for a document that is already open in the provider. + /// + /// The document path. + /// The updated document content. + void UpdateDocument(string filePath, string content); + + /// + /// Closes a tracked document and releases any provider-side state associated with it. + /// + /// The document path. + void CloseDocument(string filePath); + + /// + /// Rekeys a tracked document to a new path while preserving any provider-side state that still applies. + /// + /// The previous document path. + /// The new document path. + /// The current document content. + void RenameDocument(string oldFilePath, string newFilePath, string content); + + /// + /// Requests completion items for a position within a Lua document. + /// + /// The document path. + /// The current document content. + /// The zero-based line index. + /// The zero-based column index. + /// The optional character that triggered completion. + /// A token that can cancel the request. + /// The available completion items for the requested position. + Task> GetCompletionItemsAsync(string filePath, string content, + int line, int column, char? triggerCharacter = null, CancellationToken cancellationToken = default); + + /// + /// Requests hover information for a position within a Lua document. + /// + /// The document path. + /// The current document content. + /// The zero-based line index. + /// The zero-based column index. + /// A token that can cancel the request. + /// The hover information for the requested position, or when unavailable. + Task GetHoverAsync(string filePath, string content, + int line, int column, CancellationToken cancellationToken = default); + + /// + /// Requests the definition location for a symbol at a position within a Lua document. + /// + /// The document path. + /// The current document content. + /// The zero-based line index. + /// The zero-based column index. + /// A token that can cancel the request. + /// The resolved definition location, or when no definition is available. + Task GetDefinitionAsync(string filePath, string content, + int line, int column, CancellationToken cancellationToken = default); + + /// + /// Requests all known reference locations for a symbol at a position within a Lua document. + /// + /// The document path. + /// The current document content. + /// The zero-based line index. + /// The zero-based column index. + /// A token that can cancel the request. + /// The resolved reference locations, or an empty list when none are available. + Task> GetReferencesAsync(string filePath, string content, + int line, int column, CancellationToken cancellationToken = default); + + /// + /// Requests workspace edits to rename the symbol at a position within a Lua document. + /// + /// The document path. + /// The current document content. + /// The zero-based line index. + /// The zero-based column index. + /// The requested replacement symbol name. + /// A token that can cancel the request. + /// The workspace edit returned by the language server, or when none is available. + Task RenameSymbolAsync(string filePath, string content, + int line, int column, string newName, CancellationToken cancellationToken = default); + + /// + /// Requests formatting edits for a Lua document. + /// + /// The document path. + /// The current document content. + /// The editor formatting preferences to pass to the language server. + /// A token that can cancel the request. + /// The text edits returned by the language server, or an empty list when none are available. + Task> FormatDocumentAsync(string filePath, string content, + LuaFormattingOptions options, CancellationToken cancellationToken = default); + + /// + /// Requests signature help for a function call at a position within a Lua document. + /// + /// The document path. + /// The current document content. + /// The zero-based line index. + /// The zero-based column index. + /// A token that can cancel the request. + /// The signature help information, or when unavailable. + Task GetSignatureHelpAsync(string filePath, string content, + int line, int column, CancellationToken cancellationToken = default); +} diff --git a/TombLib/TombLib.Scripting.Lua/Services/TombEngineLanguageScriptService.cs b/TombLib/TombLib.Scripting.Lua/Services/TombEngineLanguageScriptService.cs deleted file mode 100644 index a9577b0b84..0000000000 --- a/TombLib/TombLib.Scripting.Lua/Services/TombEngineLanguageScriptService.cs +++ /dev/null @@ -1,208 +0,0 @@ -#nullable enable - -using System; -using System.Collections.Generic; -using System.Text; -using System.Text.RegularExpressions; -using ICSharpCode.AvalonEdit.Document; - -namespace TombLib.Scripting.Lua.Services; - -public sealed class TombEngineLanguageScriptService -{ - private static readonly Regex SetStringsRegex = new(@"TEN\.Flow\.SetStrings\s*\(\s*(?[^)\s]+)\s*\)", RegexOptions.IgnoreCase); - - public int? TryInsertLanguageScript(TextDocument document, string languageScript) - { - string? stringsVariableName = TryGetStringsVariableName(document); - - if (stringsVariableName is null) - return null; - - DocumentLine? stringsStartLine = FindStringsStartLine(document, stringsVariableName); - - if (stringsStartLine is null) - return null; - - DocumentLine? stopLine = FindStringsStopLine(document, stringsStartLine); - - if (stopLine is null) - return null; - - DocumentLine? insertionLine = FindLanguageInsertionLine(document, stringsStartLine, stopLine); - - if (insertionLine is not null) - return InsertLanguageScript(document, languageScript, insertionLine); - - return InsertLanguageScriptIntoEmptyTable(document, languageScript, stopLine); - } - - private static string? TryGetStringsVariableName(TextDocument document) - { - foreach (DocumentLine line in document.Lines) - { - string lineText = StripLuaLineComment(document.GetText(line)); - Match match = SetStringsRegex.Match(lineText); - - if (match.Success) - return match.Groups["name"].Value; - } - - return null; - } - - private static DocumentLine? FindStringsStartLine(TextDocument document, string stringsVariableName) - { - var regex = new Regex(@"^\s*local\s+" + Regex.Escape(stringsVariableName) + @"\s*="); - - foreach (DocumentLine line in document.Lines) - { - if (regex.IsMatch(StripLuaLineComment(document.GetText(line)))) - return line; - } - - return null; - } - - private static DocumentLine? FindStringsStopLine(TextDocument document, DocumentLine stringsStartLine) - { - int bracketDepth = 0; - bool foundOpeningBracket = false; - - for (DocumentLine? line = stringsStartLine; line is not null; line = line.NextLine) - { - string lineText = StripLuaLineComment(document.GetText(line)); - - foreach (char character in EnumerateStructuralLuaCharacters(lineText)) - { - if (character == '{') - { - bracketDepth++; - foundOpeningBracket = true; - } - else if (character == '}') - { - if (!foundOpeningBracket || bracketDepth == 0) - return null; - - bracketDepth--; - - if (bracketDepth == 0) - return line; - } - } - } - - return null; - } - - private static DocumentLine? FindLanguageInsertionLine(TextDocument document, DocumentLine stringsStartLine, DocumentLine stopLine) - { - for (int i = stopLine.LineNumber - 1; i > stringsStartLine.LineNumber; i--) - { - DocumentLine line = document.GetLineByNumber(i); - string cleanLine = StripLuaLineComment(document.GetText(line)).TrimEnd(); - - if (cleanLine.EndsWith("}") || cleanLine.EndsWith("},")) - return line; - } - - return null; - } - - private static int InsertLanguageScript(TextDocument document, string languageScript, DocumentLine insertionLine) - { - string rawLine = document.GetText(insertionLine); - string cleanLine = StripLuaLineComment(rawLine).TrimEnd(); - - if (cleanLine.EndsWith("}")) - { - int commaOffset = insertionLine.Offset + cleanLine.Length; - document.Insert(commaOffset, ","); - } - - document.Insert(insertionLine.EndOffset, Environment.NewLine + languageScript); - return insertionLine.LineNumber + 1; - } - - private static int InsertLanguageScriptIntoEmptyTable(TextDocument document, string languageScript, DocumentLine stopLine) - { - document.Insert(stopLine.Offset, languageScript + Environment.NewLine); - return stopLine.LineNumber; - } - - private static string StripLuaLineComment(string lineText) - { - var builder = new StringBuilder(lineText.Length); - - bool isInSingleQuotedString = false; - bool isInDoubleQuotedString = false; - - for (int i = 0; i < lineText.Length; i++) - { - char character = lineText[i]; - - if ((isInSingleQuotedString || isInDoubleQuotedString) && character == '\\' && i + 1 < lineText.Length) - { - builder.Append(character); - builder.Append(lineText[i + 1]); - - i++; - continue; - } - - if (!isInDoubleQuotedString && character == '\'') - { - isInSingleQuotedString = !isInSingleQuotedString; - builder.Append(character); - continue; - } - - if (!isInSingleQuotedString && character == '"') - { - isInDoubleQuotedString = !isInDoubleQuotedString; - builder.Append(character); - continue; - } - - if (!isInSingleQuotedString && !isInDoubleQuotedString && character == '-' && i + 1 < lineText.Length && lineText[i + 1] == '-') - break; - - builder.Append(character); - } - - return builder.ToString(); - } - - private static IEnumerable EnumerateStructuralLuaCharacters(string lineText) - { - bool isInSingleQuotedString = false; - bool isInDoubleQuotedString = false; - - for (int i = 0; i < lineText.Length; i++) - { - char character = lineText[i]; - - if ((isInSingleQuotedString || isInDoubleQuotedString) && character == '\\' && i + 1 < lineText.Length) - { - i++; - continue; - } - - if (!isInDoubleQuotedString && character == '\'') - { - isInSingleQuotedString = !isInSingleQuotedString; - continue; - } - - if (!isInSingleQuotedString && character == '"') - { - isInDoubleQuotedString = !isInDoubleQuotedString; - continue; - } - - if (!isInSingleQuotedString && !isInDoubleQuotedString) - yield return character; - } - } -} diff --git a/TombLib/TombLib.Scripting.Lua/TombLib.Scripting.Lua.csproj b/TombLib/TombLib.Scripting.Lua/TombLib.Scripting.Lua.csproj index 61120144f3..91479ed453 100644 --- a/TombLib/TombLib.Scripting.Lua/TombLib.Scripting.Lua.csproj +++ b/TombLib/TombLib.Scripting.Lua/TombLib.Scripting.Lua.csproj @@ -1,48 +1,37 @@  + - net6.0-windows - Library false + enable true true - true - Debug;Release - x64;x86 - - - ..\..\Build ($(Platform))\ - - - ..\..\BuildRelease ($(Platform))\ - none - true - - - x64 - - - x86 + - - False - ..\..\Libs\ICSharpCode.AvalonEdit.dll - + + GlobalPaths.cs - - UserControl - + + + PreserveNewest + + PreserveNewest + + + PreserveNewest + - \ No newline at end of file + + diff --git a/TombLib/TombLib.Scripting.Lua/Utils/Autocomplete.cs b/TombLib/TombLib.Scripting.Lua/Utils/Autocomplete.cs deleted file mode 100644 index d4ed09353e..0000000000 --- a/TombLib/TombLib.Scripting.Lua/Utils/Autocomplete.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace TombLib.Scripting.Lua.Utils -{ - public static class Autocomplete - { - // TODO - } -} diff --git a/TombLib/TombLib.Scripting.Lua/Utils/LuaAutoIndentationStrategy.cs b/TombLib/TombLib.Scripting.Lua/Utils/LuaAutoIndentationStrategy.cs new file mode 100644 index 0000000000..5bfc82055c --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/Utils/LuaAutoIndentationStrategy.cs @@ -0,0 +1,103 @@ +using ICSharpCode.AvalonEdit; +using ICSharpCode.AvalonEdit.Document; +using ICSharpCode.AvalonEdit.Indentation; +using System; + +namespace TombLib.Scripting.Lua.Utils; + +internal sealed class LuaAutoIndentationStrategy : IIndentationStrategy +{ + private readonly TextEditorOptions _options; + + public LuaAutoIndentationStrategy(TextEditorOptions options) + { + ArgumentNullException.ThrowIfNull(options); + _options = options; + } + + public void IndentLine(TextDocument document, DocumentLine line) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(line); + + string lineText = document.GetText(line); + string desiredIndentation = GetDesiredIndentation(document, line, lineText); + ReplaceLeadingWhitespace(document, line, lineText, desiredIndentation); + } + + public void IndentLines(TextDocument document, int beginLine, int endLine) + { + ArgumentNullException.ThrowIfNull(document); + + if (document.LineCount == 0) + return; + + int startLine = Math.Max(1, Math.Min(beginLine, document.LineCount)); + int lastLine = Math.Max(startLine, Math.Min(endLine, document.LineCount)); + + document.BeginUpdate(); + + try + { + for (int lineNumber = startLine; lineNumber <= lastLine; lineNumber++) + IndentLine(document, document.GetLineByNumber(lineNumber)); + } + finally + { + document.EndUpdate(); + } + } + + private string GetDesiredIndentation(TextDocument document, DocumentLine line, string lineText) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(line); + + if (line.PreviousLine is null) + return LuaIndentationStrategy.GetLeadingWhitespace(lineText); + + DocumentLine previousLine = line.PreviousLine; + string previousLineText = document.GetText(previousLine); + string previousLineIndentation = LuaIndentationStrategy.GetLeadingWhitespace(previousLineText); + + return LuaIndentationStrategy.GetDesiredIndentation( + previousLineText, + lineText, + previousLineIndentation, + LuaIndentationStrategy.CreateIndentationUnit(_options.ConvertTabsToSpaces, _options.IndentationSize, 4), + ShouldUseSmartIndent(document, previousLine)); + } + + private static bool ShouldUseSmartIndent(TextDocument document, DocumentLine previousLine) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(previousLine); + + if (previousLine.Length == 0) + return true; + + return !LuaEditorInteractionRules.IsInsideCommentOrString(document, previousLine.EndOffset - 1); + } + + private static void ReplaceLeadingWhitespace(TextDocument document, DocumentLine line, string lineText, string desiredIndentation) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(line); + + int leadingWhitespaceLength = 0; + + while (leadingWhitespaceLength < lineText.Length + && (lineText[leadingWhitespaceLength] == ' ' || lineText[leadingWhitespaceLength] == '\t')) + { + leadingWhitespaceLength++; + } + + if (leadingWhitespaceLength == desiredIndentation.Length + && string.CompareOrdinal(lineText, 0, desiredIndentation, 0, leadingWhitespaceLength) == 0) + { + return; + } + + document.Replace(line.Offset, leadingWhitespaceLength, desiredIndentation); + } +} \ No newline at end of file diff --git a/TombLib/TombLib.Scripting.Lua/Utils/LuaCompletionNormalizationResult.cs b/TombLib/TombLib.Scripting.Lua/Utils/LuaCompletionNormalizationResult.cs new file mode 100644 index 0000000000..a2893e8464 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/Utils/LuaCompletionNormalizationResult.cs @@ -0,0 +1,6 @@ +namespace TombLib.Scripting.Lua.Utils; + +/// +/// Represents the normalized completion text and its optional caret placement. +/// +internal readonly record struct LuaCompletionNormalizationResult(string Text, int? CaretOffset); \ No newline at end of file diff --git a/TombLib/TombLib.Scripting.Lua/Utils/LuaDocumentLineParserStateCache.cs b/TombLib/TombLib.Scripting.Lua/Utils/LuaDocumentLineParserStateCache.cs new file mode 100644 index 0000000000..9ede3feb86 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/Utils/LuaDocumentLineParserStateCache.cs @@ -0,0 +1,97 @@ +using ICSharpCode.AvalonEdit.Document; +using System; +using System.Collections.Generic; + +namespace TombLib.Scripting.Lua.Utils; + +/// +/// Caches per-line Lua parser continuation state so comment and long-string checks stay fast after edits. +/// +internal sealed class LuaDocumentLineParserStateCache +{ + private readonly object _syncRoot = new(); + private readonly TextDocument _document; + private readonly List _cachedLineStartStates = []; + + /// + /// Initializes a new instance of the class. + /// + /// The document whose line-start parser state should be cached. + public LuaDocumentLineParserStateCache(TextDocument document) + { + _document = document ?? throw new ArgumentNullException(nameof(document)); + _document.Changed += Document_Changed; + } + + /// + /// Gets the parser continuation state that applies at the start of the specified one-based line. + /// + /// The one-based document line number. + /// The cached or computed line-start parser state. + public LuaLineParserState GetLineStartState(int lineNumber) + { + if (lineNumber <= 1) + return default; + + lock (_syncRoot) + { + if (_document.LineCount == 0) + return default; + + int targetLineNumber = Math.Max(1, Math.Min(lineNumber, _document.LineCount)); + + EnsureFirstLineStateCached(); + EnsureStatesCachedThrough(targetLineNumber); + return _cachedLineStartStates[targetLineNumber - 1]; + } + } + + private void Document_Changed(object? sender, DocumentChangeEventArgs e) + { + lock (_syncRoot) + { + if (_cachedLineStartStates.Count == 0) + return; + + int firstAffectedLineNumber = GetSafeLineNumberForOffset(e.Offset); + int preservedLineCount = Math.Max(0, firstAffectedLineNumber - 1); + + if (_cachedLineStartStates.Count > preservedLineCount) + _cachedLineStartStates.RemoveRange(preservedLineCount, _cachedLineStartStates.Count - preservedLineCount); + + if (_cachedLineStartStates.Count > _document.LineCount) + _cachedLineStartStates.RemoveRange(_document.LineCount, _cachedLineStartStates.Count - _document.LineCount); + } + } + + private void EnsureFirstLineStateCached() + { + if (_document.LineCount > 0 && _cachedLineStartStates.Count == 0) + _cachedLineStartStates.Add(default); + } + + private void EnsureStatesCachedThrough(int lineNumber) + { + int targetLineNumber = Math.Min(lineNumber, _document.LineCount); + + if (targetLineNumber <= 0) + return; + + while (_cachedLineStartStates.Count < targetLineNumber) + { + int previousLineNumber = _cachedLineStartStates.Count; + DocumentLine previousLine = _document.GetLineByNumber(previousLineNumber); + LuaLineParser.IsInsideCommentOrString(_document.GetText(previousLine), _cachedLineStartStates[previousLineNumber - 1], out LuaLineParserState nextState); + _cachedLineStartStates.Add(nextState); + } + } + + private int GetSafeLineNumberForOffset(int offset) + { + if (_document.LineCount == 0) + return 1; + + int safeOffset = Math.Max(0, Math.Min(offset, _document.TextLength)); + return _document.GetLineByOffset(safeOffset).LineNumber; + } +} diff --git a/TombLib/TombLib.Scripting.Lua/Utils/LuaEditorInteractionRules.cs b/TombLib/TombLib.Scripting.Lua/Utils/LuaEditorInteractionRules.cs new file mode 100644 index 0000000000..358133b857 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/Utils/LuaEditorInteractionRules.cs @@ -0,0 +1,184 @@ +using ICSharpCode.AvalonEdit.Document; +using System; +using System.Runtime.CompilerServices; + +namespace TombLib.Scripting.Lua.Utils; + +/// +/// Encapsulates Lua-editor interaction rules for hover, completion, and definition navigation. +/// +internal static class LuaEditorInteractionRules +{ + private static readonly ConditionalWeakTable LineStartStateCaches = []; + + /// + /// Determines whether a hover request should be attempted. + /// + /// Whether the completion window is currently open. + /// Whether signature help is currently open. + /// if hover may be requested; otherwise, . + public static bool CanRequestHover(bool isCompletionWindowOpen, bool isSignatureHelpOpen) + => !isCompletionWindowOpen && !isSignatureHelpOpen; + + /// + /// Attempts to resolve the exact offset that should be used for a hover request. + /// + /// The document being inspected. + /// The zero-based character offset under the mouse. + /// When this method returns, contains the resolved hover offset. + /// if a hoverable identifier exists at the requested offset; otherwise, . + public static bool TryGetHoverOffset(TextDocument? document, int offset, out int hoverOffset) + { + hoverOffset = 0; + + if (document is null || document.TextLength == 0) + return false; + + int safeOffset = ClampOffset(document, offset); + + if (safeOffset >= document.TextLength) + return false; + + if (IsInsideCommentOrString(document, safeOffset)) + return false; + + if (!LuaLineParser.IsIdentifierCharacter(document.GetCharAt(safeOffset))) + return false; + + hoverOffset = safeOffset; + return true; + } + + /// + /// Determines whether the current caret context allows an automatic completion request. + /// + /// The document being inspected. + /// The zero-based caret offset after text entry. + /// The character that triggered completion, if any. + /// if autocomplete should be requested; otherwise, . + public static bool IsValidAutocompleteContext(TextDocument? document, int offset, char? triggerCharacter) + { + if (offset <= 0 || document is null || document.TextLength == 0) + return false; + + if (IsInsideCommentOrString(document, offset)) + return false; + + if (triggerCharacter is '.' || triggerCharacter is ':') + return true; + + char typedCharacter = document.GetCharAt(offset - 1); + + if (!LuaLineParser.IsIdentifierCharacter(typedCharacter)) + return false; + + if (offset >= 2 && document.GetCharAt(offset - 2) == '.') + return false; + + return true; + } + + /// + /// Determines whether the current caret context allows a manual completion request. + /// + /// The document being inspected. + /// The zero-based caret offset. + /// if manual completion may be requested; otherwise, . + public static bool IsValidManualCompletionContext(TextDocument? document, int offset) + { + if (document is null) + return false; + + if (document.TextLength == 0) + return true; + + return !IsInsideCommentOrString(document, offset); + } + + /// + /// Attempts to resolve the identifier start offset that should be used for a go-to-definition request. + /// + /// The document being inspected. + /// The zero-based offset near the identifier. + /// When this method returns, contains the identifier start offset. + /// if a definition target offset was found; otherwise, . + public static bool TryGetDefinitionStartOffset(TextDocument? document, int offset, out int definitionOffset) + { + definitionOffset = 0; + + if (document is null || document.TextLength == 0) + return false; + + int safeOffset = ClampOffset(document, offset); + + if (IsInsideCommentOrString(document, safeOffset)) + return false; + + if (!TryGetDefinitionWordBounds(document, safeOffset, out definitionOffset, out _)) + return false; + + return true; + } + + /// + /// Determines whether the specified offset is inside a comment or string using document-aware long-block state. + /// + /// The document being inspected. + /// The zero-based character offset. + /// if the offset is inside a comment or string on the current line; otherwise, . + public static bool IsInsideCommentOrString(TextDocument? document, int offset) + { + if (document is null || document.TextLength == 0) + return false; + + int safeOffset = ClampOffset(document, offset); + DocumentLine currentLine = document.GetLineByOffset(safeOffset); + LuaLineParserState lineStartState = GetLineStartParserState(document, currentLine); + int lineStart = currentLine.Offset; + int inspectedLength = Math.Max(0, Math.Min(safeOffset, currentLine.EndOffset) - lineStart); + string lineText = document.GetText(lineStart, inspectedLength); + + return LuaLineParser.IsInsideCommentOrString(lineText, lineStartState, out _); + } + + private static LuaLineParserState GetLineStartParserState(TextDocument document, DocumentLine currentLine) + => LineStartStateCaches.GetValue(document, static doc => new LuaDocumentLineParserStateCache(doc)).GetLineStartState(currentLine.LineNumber); + + private static int ClampOffset(TextDocument document, int offset) + => Math.Clamp(offset, 0, document.TextLength); + + private static bool TryGetDefinitionWordBounds(TextDocument document, int offset, out int wordStart, out int wordEnd) + { + wordStart = 0; + wordEnd = 0; + + if (document.TextLength == 0) + return false; + + int probeOffset = ClampOffset(document, offset); + + if (probeOffset >= document.TextLength) + probeOffset = document.TextLength - 1; + + if (probeOffset > 0 + && !LuaLineParser.IsIdentifierCharacter(document.GetCharAt(probeOffset)) + && LuaLineParser.IsIdentifierCharacter(document.GetCharAt(probeOffset - 1))) + { + probeOffset--; + } + + if (!LuaLineParser.IsIdentifierCharacter(document.GetCharAt(probeOffset))) + return false; + + wordStart = probeOffset; + wordEnd = probeOffset + 1; + + while (wordStart > 0 && LuaLineParser.IsIdentifierCharacter(document.GetCharAt(wordStart - 1))) + wordStart--; + + while (wordEnd < document.TextLength && LuaLineParser.IsIdentifierCharacter(document.GetCharAt(wordEnd))) + wordEnd++; + + return wordEnd > wordStart; + } +} diff --git a/TombLib/TombLib.Scripting.Lua/Utils/LuaEnterInsertionResult.cs b/TombLib/TombLib.Scripting.Lua/Utils/LuaEnterInsertionResult.cs new file mode 100644 index 0000000000..368b67f7cd --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/Utils/LuaEnterInsertionResult.cs @@ -0,0 +1,6 @@ +namespace TombLib.Scripting.Lua.Utils; + +/// +/// Represents the text inserted for Enter along with caret and whitespace removal metadata. +/// +internal readonly record struct LuaEnterInsertionResult(string Text, int CaretOffset, int RemoveFollowingWhitespaceLength); \ No newline at end of file diff --git a/TombLib/TombLib.Scripting.Lua/Utils/LuaIndentationStrategy.cs b/TombLib/TombLib.Scripting.Lua/Utils/LuaIndentationStrategy.cs new file mode 100644 index 0000000000..1ca913c119 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/Utils/LuaIndentationStrategy.cs @@ -0,0 +1,345 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace TombLib.Scripting.Lua.Utils; + +/// +/// Computes Lua-specific newline indentation and multiline completion normalization. +/// +internal static class LuaIndentationStrategy +{ + private readonly record struct TextLine(string Content, string Delimiter, int StartOffset); + + /// + /// Creates the indentation unit that should be appended for one extra indent level. + /// + public static string CreateIndentationUnit(bool convertTabsToSpaces, int indentationSize, int tabSize) + { + if (!convertTabsToSpaces) + return "\t"; + + int size = indentationSize > 0 + ? indentationSize + : tabSize > 0 + ? tabSize + : 4; + + return new string(' ', size); + } + + /// + /// Gets the leading whitespace prefix for the supplied line. + /// + public static string GetLeadingWhitespace(string lineText) + => string.IsNullOrEmpty(lineText) + ? string.Empty + : lineText[..GetLeadingWhitespaceLength(lineText)]; + + /// + /// Computes the indentation that should be applied to the current line based on the previous Lua line. + /// + public static string GetDesiredIndentation( + string previousLineText, + string currentLineText, + string previousLineIndentation, + string indentationUnit, + bool useSmartIndent) + { + previousLineText ??= string.Empty; + currentLineText ??= string.Empty; + previousLineIndentation ??= string.Empty; + indentationUnit ??= string.Empty; + + string indentation = previousLineIndentation; + + if (!useSmartIndent) + return indentation; + + if (ShouldIncreaseIndentAfterLine(previousLineText)) + indentation += indentationUnit; + + if (StartsWithDedentToken(currentLineText)) + indentation = RemoveSingleIndentLevel(indentation, indentationUnit); + + return indentation; + } + + /// + /// Builds the text that should be inserted when Enter is pressed inside Lua code. + /// + public static LuaEnterInsertionResult BuildEnterInsertion( + string lineTextBeforeCaret, + string lineTextAfterCaret, + string currentLineIndentation, + string indentationUnit, + string newLineText, + bool useSmartIndent) + { + lineTextBeforeCaret ??= string.Empty; + lineTextAfterCaret ??= string.Empty; + currentLineIndentation ??= string.Empty; + indentationUnit ??= string.Empty; + newLineText = string.IsNullOrEmpty(newLineText) ? Environment.NewLine : newLineText; + + string nextLineIndentation = currentLineIndentation; + + if (useSmartIndent && ShouldIncreaseIndentAfterLine(lineTextBeforeCaret)) + nextLineIndentation += indentationUnit; + + bool shouldSplitBeforeDedent = useSmartIndent + && nextLineIndentation.Length > currentLineIndentation.Length + && StartsWithDedentToken(lineTextAfterCaret); + + if (!shouldSplitBeforeDedent) + { + string text = newLineText + nextLineIndentation; + return new LuaEnterInsertionResult(text, text.Length, 0); + } + + string splitText = newLineText + nextLineIndentation + newLineText + currentLineIndentation; + return new LuaEnterInsertionResult( + splitText, + newLineText.Length + nextLineIndentation.Length, + GetLeadingWhitespaceLength(lineTextAfterCaret)); + } + + /// + /// Normalizes multiline completion insertion relative to the current line indentation. + /// + public static LuaCompletionNormalizationResult NormalizeCompletionInsertion( + string text, + int? caretOffset, + string currentLineIndentation, + string indentationUnit) + { + if (string.IsNullOrEmpty(text) || !ContainsLineBreak(text)) + return new LuaCompletionNormalizationResult(text, caretOffset); + + List lines = SplitLines(text); + var builder = new StringBuilder(text.Length + Math.Max(0, lines.Count - 1) * currentLineIndentation.Length); + int? normalizedCaretOffset = null; + int relativeIndentLevel = 0; + + for (int i = 0; i < lines.Count; i++) + { + TextLine line = lines[i]; + int originalLeadingWhitespaceLength = GetLeadingWhitespaceLength(line.Content); + string trimmedContent = line.Content[originalLeadingWhitespaceLength..]; + int currentIndentLevel = i == 0 + ? 0 + : Math.Max(0, relativeIndentLevel - GetDedentLevel(trimmedContent)); + string normalizedIndentation = i == 0 + ? string.Empty + : BuildIndentation(currentLineIndentation, indentationUnit, currentIndentLevel); + string normalizedLineContent = trimmedContent.Length == 0 + ? normalizedIndentation + : normalizedIndentation + trimmedContent; + + if (caretOffset.HasValue + && caretOffset.Value >= line.StartOffset + && caretOffset.Value <= line.StartOffset + line.Content.Length) + { + int caretColumn = caretOffset.Value - line.StartOffset; + int normalizedLeadingWhitespaceLength = normalizedLineContent.Length - trimmedContent.Length; + int contentColumn = Math.Max(0, caretColumn - originalLeadingWhitespaceLength); + normalizedCaretOffset = builder.Length + normalizedLeadingWhitespaceLength + contentColumn; + } + + builder.Append(normalizedLineContent); + builder.Append(line.Delimiter); + relativeIndentLevel = currentIndentLevel + GetIndentIncrease(trimmedContent); + } + + if (caretOffset == text.Length) + normalizedCaretOffset = builder.Length; + + return new LuaCompletionNormalizationResult(builder.ToString(), normalizedCaretOffset ?? caretOffset); + } + + private static string BuildIndentation(string currentLineIndentation, string indentationUnit, int indentLevel) + { + if (indentLevel <= 0) + return currentLineIndentation; + + var builder = new StringBuilder(currentLineIndentation.Length + indentationUnit.Length * indentLevel); + builder.Append(currentLineIndentation); + + for (int i = 0; i < indentLevel; i++) + builder.Append(indentationUnit); + + return builder.ToString(); + } + + private static string RemoveSingleIndentLevel(string indentation, string indentationUnit) + { + if (string.IsNullOrEmpty(indentation)) + return string.Empty; + + if (indentationUnit == "\t" && indentation[^1] == '\t') + return indentation[..^1]; + + int removeLength = indentationUnit.Length > 0 + ? Math.Min(indentationUnit.Length, indentation.Length) + : 1; + + return indentation[..^removeLength]; + } + + private static int GetDedentLevel(string lineText) + { + string codeText = LuaLineParser.ExtractCodeText(lineText).TrimStart(); + + if (string.IsNullOrEmpty(codeText)) + return 0; + + return StartsWithDedentToken(codeText) ? 1 : 0; + } + + private static int GetIndentIncrease(string lineText) + { + string codeText = LuaLineParser.ExtractCodeText(lineText).Trim(); + + if (string.IsNullOrEmpty(codeText)) + return 0; + + return ShouldIncreaseIndentAfterCode(codeText) ? 1 : 0; + } + + private static bool ShouldIncreaseIndentAfterLine(string lineText) + => ShouldIncreaseIndentAfterCode(LuaLineParser.ExtractCodeText(lineText).Trim()); + + private static bool ShouldIncreaseIndentAfterCode(string codeText) + { + if (string.IsNullOrEmpty(codeText)) + return false; + + if (StartsWithWord(codeText, "repeat") + || StartsWithWord(codeText, "else") + || StartsWithWord(codeText, "elseif") + || EndsWithWord(codeText, "then") + || EndsWithWord(codeText, "do") + || ContainsWord(codeText, "function")) + { + return true; + } + + return HasPositiveDelimiterBalance(codeText); + } + + private static bool StartsWithDedentToken(string lineText) + { + string codeText = LuaLineParser.ExtractCodeText(lineText).TrimStart(); + + if (string.IsNullOrEmpty(codeText)) + return false; + + return StartsWithWord(codeText, "end") + || StartsWithWord(codeText, "until") + || StartsWithWord(codeText, "else") + || StartsWithWord(codeText, "elseif") + || StartsWithClosingDelimiter(codeText[0]); + } + + private static bool HasPositiveDelimiterBalance(string codeText) + { + int balance = 0; + + foreach (char character in codeText) + { + balance += character switch + { + '(' or '{' or '[' => 1, + ')' or '}' or ']' => -1, + _ => 0 + }; + } + + return balance > 0; + } + + private static bool StartsWithClosingDelimiter(char character) + => character is ')' or '}' or ']'; + + private static bool StartsWithWord(string text, string word) + => text.StartsWith(word, StringComparison.Ordinal) + && (text.Length == word.Length || !LuaLineParser.IsIdentifierCharacter(text[word.Length])); + + private static bool EndsWithWord(string text, string word) + { + if (!text.EndsWith(word, StringComparison.Ordinal)) + return false; + + int wordStart = text.Length - word.Length; + return wordStart == 0 || !LuaLineParser.IsIdentifierCharacter(text[wordStart - 1]); + } + + private static bool ContainsWord(string text, string word) + { + int searchIndex = 0; + + while (searchIndex < text.Length) + { + int wordIndex = text.IndexOf(word, searchIndex, StringComparison.Ordinal); + + if (wordIndex < 0) + return false; + + bool hasLeadingBoundary = wordIndex == 0 || !LuaLineParser.IsIdentifierCharacter(text[wordIndex - 1]); + int wordEnd = wordIndex + word.Length; + bool hasTrailingBoundary = wordEnd == text.Length || !LuaLineParser.IsIdentifierCharacter(text[wordEnd]); + + if (hasLeadingBoundary && hasTrailingBoundary) + return true; + + searchIndex = wordIndex + word.Length; + } + + return false; + } + + private static bool ContainsLineBreak(string text) + => text.IndexOfAny(['\r', '\n']) >= 0; + + private static int GetLeadingWhitespaceLength(string text) + { + int length = 0; + + while (length < text.Length && char.IsWhiteSpace(text[length]) && text[length] != '\r' && text[length] != '\n') + length++; + + return length; + } + + private static List SplitLines(string text) + { + var lines = new List(); + int lineStart = 0; + int index = 0; + + while (index < text.Length) + { + if (text[index] == '\r' || text[index] == '\n') + { + int delimiterStart = index; + + if (text[index] == '\r' && index + 1 < text.Length && text[index + 1] == '\n') + index++; + + lines.Add(new TextLine( + text[lineStart..delimiterStart], + text[delimiterStart..(index + 1)], + lineStart)); + + index++; + lineStart = index; + continue; + } + + index++; + } + + lines.Add(new TextLine(text[lineStart..], string.Empty, lineStart)); + return lines; + } +} \ No newline at end of file diff --git a/TombLib/TombLib.Scripting.Lua/Utils/LuaLineParser.cs b/TombLib/TombLib.Scripting.Lua/Utils/LuaLineParser.cs new file mode 100644 index 0000000000..8a6e270115 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/Utils/LuaLineParser.cs @@ -0,0 +1,522 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace TombLib.Scripting.Lua.Utils; + +/// +/// Identifies the long-block parser mode that must continue across Lua document lines. +/// +internal enum LuaLineParserStateKind +{ + /// + /// The parser is not inside a multi-line Lua construct. + /// + None, + + /// + /// The parser is inside a long-bracket string literal. + /// + LongString, + + /// + /// The parser is inside a long-bracket comment. + /// + LongComment +} + +/// +/// Stores the parser continuation state needed to evaluate long strings and long comments across line boundaries. +/// +internal readonly struct LuaLineParserState(LuaLineParserStateKind kind, int longBracketEqualsCount) +{ + /// + /// Gets the long-block parser mode that should continue onto the next line. + /// + public LuaLineParserStateKind Kind { get; } = kind; + + /// + /// Gets the number of = characters used by the active long-bracket delimiter. + /// + public int LongBracketEqualsCount { get; } = longBracketEqualsCount; +} + +/// +/// Provides lightweight line-based parsing helpers for Lua identifiers, comments, and long-bracket strings. +/// +internal static class LuaLineParser +{ + private enum ParserState + { + None, + SingleQuotedString, + DoubleQuotedString, + LongString, + LongComment + } + + /// + /// Determines whether a character can appear within a Lua identifier. + /// + /// The character to test. + /// if the character is valid inside an identifier; otherwise, . + public static bool IsIdentifierCharacter(char character) + => char.IsLetterOrDigit(character) || character == '_'; + + /// + /// Determines whether a character can start an identifier-triggered autocomplete request. + /// + /// The character to test. + /// if the character is a valid identifier trigger; otherwise, . + public static bool IsIdentifierTriggerCharacter(char character) + => char.IsLetter(character) || character == '_'; + + /// + /// Determines whether the inspected line fragment currently ends inside a comment or string. + /// + /// The line text to inspect, typically truncated at the current offset. + /// if the fragment is inside a comment or string; otherwise, . + public static bool IsInsideCommentOrString(string lineText) + => IsInsideCommentOrString(lineText, default, out _); + + internal static bool IsInsideCommentOrString(string lineText, LuaLineParserState initialState, out LuaLineParserState finalState) + { + ParserState state = GetInitialParserState(initialState); + int longBracketEqualsCount = initialState.LongBracketEqualsCount; + + if (string.IsNullOrEmpty(lineText)) + { + finalState = CreateContinuationState(state, longBracketEqualsCount); + return state != ParserState.None; + } + + for (int i = 0; i < lineText.Length; i++) + { + char currentChar = lineText[i]; + + if (state == ParserState.LongString || state == ParserState.LongComment) + { + if (TryMatchLongBracketEnd(lineText, i, longBracketEqualsCount, out int endTokenLength)) + { + i += endTokenLength - 1; + state = ParserState.None; + } + + continue; + } + + if (state == ParserState.SingleQuotedString) + { + if (currentChar == '\\' && i + 1 < lineText.Length) + { + i++; + continue; + } + + if (currentChar == '\'') + state = ParserState.None; + + continue; + } + + if (state == ParserState.DoubleQuotedString) + { + if (currentChar == '\\' && i + 1 < lineText.Length) + { + i++; + continue; + } + + if (currentChar == '"') + state = ParserState.None; + + continue; + } + + if (TryMatchLongCommentStart(lineText, i, out longBracketEqualsCount, out int longCommentStartLength)) + { + state = ParserState.LongComment; + i += longCommentStartLength - 1; + continue; + } + + if (IsLineCommentStart(lineText, i)) + { + finalState = default; + return true; + } + + if (TryMatchLongBracketStart(lineText, i, out longBracketEqualsCount, out int longStringStartLength)) + { + state = ParserState.LongString; + i += longStringStartLength - 1; + continue; + } + + if (currentChar == '\'') + state = ParserState.SingleQuotedString; + else if (currentChar == '"') + state = ParserState.DoubleQuotedString; + } + + finalState = CreateContinuationState(state, longBracketEqualsCount); + return state != ParserState.None; + } + + /// + /// Removes a trailing Lua line comment while preserving quoted strings and long-bracket strings. + /// + /// The line text to process. + /// The line text without a trailing line comment. + public static string StripLineComment(string lineText) + { + if (string.IsNullOrEmpty(lineText)) + return string.Empty; + + var builder = new StringBuilder(lineText.Length); + ParserState state = ParserState.None; + int longBracketEqualsCount = 0; + + for (int i = 0; i < lineText.Length; i++) + { + char currentChar = lineText[i]; + + if (state == ParserState.LongComment) + { + if (TryMatchLongBracketEnd(lineText, i, longBracketEqualsCount, out int endTokenLength)) + { + i += endTokenLength - 1; + state = ParserState.None; + } + + continue; + } + + if (state == ParserState.LongString) + { + if (TryMatchLongBracketEnd(lineText, i, longBracketEqualsCount, out int endTokenLength)) + { + builder.Append(lineText, i, endTokenLength); + i += endTokenLength - 1; + state = ParserState.None; + } + else + { + builder.Append(currentChar); + } + + continue; + } + + if (state == ParserState.SingleQuotedString || state == ParserState.DoubleQuotedString) + { + builder.Append(currentChar); + + if (currentChar == '\\' && i + 1 < lineText.Length) + { + builder.Append(lineText[i + 1]); + i++; + continue; + } + + if ((state == ParserState.SingleQuotedString && currentChar == '\'') + || (state == ParserState.DoubleQuotedString && currentChar == '"')) + { + state = ParserState.None; + } + + continue; + } + + if (TryMatchLongCommentStart(lineText, i, out longBracketEqualsCount, out int longCommentStartLength)) + { + state = ParserState.LongComment; + i += longCommentStartLength - 1; + continue; + } + + if (IsLineCommentStart(lineText, i)) + break; + + if (TryMatchLongBracketStart(lineText, i, out longBracketEqualsCount, out int longStringStartLength)) + { + builder.Append(lineText, i, longStringStartLength); + state = ParserState.LongString; + i += longStringStartLength - 1; + continue; + } + + if (currentChar == '\'') + state = ParserState.SingleQuotedString; + else if (currentChar == '"') + state = ParserState.DoubleQuotedString; + + builder.Append(currentChar); + } + + return builder.ToString(); + } + + /// + /// Extracts the code-visible characters from a line while skipping comment and string contents. + /// + /// The line text to process. + /// The characters that remain visible to Lua block-indentation heuristics. + public static string ExtractCodeText(string lineText) + { + if (string.IsNullOrEmpty(lineText)) + return string.Empty; + + var builder = new StringBuilder(lineText.Length); + ParserState state = ParserState.None; + int longBracketEqualsCount = 0; + + for (int i = 0; i < lineText.Length; i++) + { + char currentChar = lineText[i]; + + if (state == ParserState.LongComment || state == ParserState.LongString) + { + if (TryMatchLongBracketEnd(lineText, i, longBracketEqualsCount, out int endTokenLength)) + { + i += endTokenLength - 1; + state = ParserState.None; + longBracketEqualsCount = 0; + } + + continue; + } + + if (state == ParserState.SingleQuotedString || state == ParserState.DoubleQuotedString) + { + if (currentChar == '\\' && i + 1 < lineText.Length) + { + i++; + continue; + } + + if ((state == ParserState.SingleQuotedString && currentChar == '\'') + || (state == ParserState.DoubleQuotedString && currentChar == '"')) + { + state = ParserState.None; + } + + continue; + } + + if (TryMatchLongCommentStart(lineText, i, out longBracketEqualsCount, out int longCommentStartLength)) + { + state = ParserState.LongComment; + i += longCommentStartLength - 1; + continue; + } + + if (IsLineCommentStart(lineText, i)) + break; + + if (TryMatchLongBracketStart(lineText, i, out longBracketEqualsCount, out int longStringStartLength)) + { + state = ParserState.LongString; + i += longStringStartLength - 1; + continue; + } + + if (currentChar == '\'') + { + state = ParserState.SingleQuotedString; + continue; + } + + if (currentChar == '"') + { + state = ParserState.DoubleQuotedString; + continue; + } + + builder.Append(currentChar); + } + + return builder.ToString(); + } + + /// + /// Enumerates structural characters that remain after stripping comments and string content from a line. + /// + /// The line text to inspect. + /// The structural characters that participate in brace and delimiter analysis. + public static IEnumerable EnumerateStructuralCharacters(string lineText) + => EnumerateStructuralCharactersCore(lineText, default, captureFinalState: null); + + /// + /// Enumerates structural characters from a line while carrying long-string and long-comment + /// continuation state across line boundaries. Use the returned + /// as the next line's so multi-line [[...]] blocks can + /// not break callers that do brace tracking across the whole document. + /// + internal static IEnumerable EnumerateStructuralCharacters(string lineText, LuaLineParserState initialState, Action captureFinalState) + => EnumerateStructuralCharactersCore(lineText, initialState, captureFinalState); + + private static IEnumerable EnumerateStructuralCharactersCore(string lineText, LuaLineParserState initialState, Action? captureFinalState) + { + ParserState state = GetInitialParserState(initialState); + int longBracketEqualsCount = initialState.LongBracketEqualsCount; + + if (string.IsNullOrEmpty(lineText)) + { + captureFinalState?.Invoke(CreateContinuationState(state, longBracketEqualsCount)); + yield break; + } + + for (int i = 0; i < lineText.Length; i++) + { + char currentChar = lineText[i]; + + if (state == ParserState.LongComment || state == ParserState.LongString) + { + if (TryMatchLongBracketEnd(lineText, i, longBracketEqualsCount, out int endTokenLength)) + { + i += endTokenLength - 1; + state = ParserState.None; + longBracketEqualsCount = 0; + } + + continue; + } + + if (state == ParserState.SingleQuotedString || state == ParserState.DoubleQuotedString) + { + if (currentChar == '\\' && i + 1 < lineText.Length) + { + i++; + } + else if ((state == ParserState.SingleQuotedString && currentChar == '\'') + || (state == ParserState.DoubleQuotedString && currentChar == '"')) + { + state = ParserState.None; + } + + continue; + } + + if (TryMatchLongCommentStart(lineText, i, out longBracketEqualsCount, out int longCommentStartLength)) + { + state = ParserState.LongComment; + i += longCommentStartLength - 1; + continue; + } + + if (IsLineCommentStart(lineText, i)) + { + captureFinalState?.Invoke(CreateContinuationState(ParserState.None, 0)); + yield break; + } + + if (TryMatchLongBracketStart(lineText, i, out longBracketEqualsCount, out int longStringStartLength)) + { + state = ParserState.LongString; + i += longStringStartLength - 1; + continue; + } + + if (currentChar == '\'') + { + state = ParserState.SingleQuotedString; + continue; + } + + if (currentChar == '"') + { + state = ParserState.DoubleQuotedString; + continue; + } + + yield return currentChar; + } + + // Single/double-quoted strings do not legally span lines in Lua, so they implicitly + // terminate at the newline; only long-bracket modes are propagated to the next line. + if (state != ParserState.LongString && state != ParserState.LongComment) + state = ParserState.None; + + captureFinalState?.Invoke(CreateContinuationState(state, longBracketEqualsCount)); + } + + private static bool TryMatchLongCommentStart(string lineText, int index, out int equalsCount, out int tokenLength) + { + equalsCount = 0; + tokenLength = 0; + + if (!IsLineCommentStart(lineText, index) || !TryMatchLongBracketStart(lineText, index + 2, out equalsCount, out int bracketTokenLength)) + return false; + + tokenLength = 2 + bracketTokenLength; + return true; + } + + private static bool TryMatchLongBracketStart(string lineText, int index, out int equalsCount, out int tokenLength) + { + equalsCount = 0; + tokenLength = 0; + + if (index >= lineText.Length || lineText[index] != '[') + return false; + + int probeIndex = index + 1; + + while (probeIndex < lineText.Length && lineText[probeIndex] == '=') + { + equalsCount++; + probeIndex++; + } + + if (probeIndex >= lineText.Length || lineText[probeIndex] != '[') + { + equalsCount = 0; + return false; + } + + tokenLength = probeIndex - index + 1; + return true; + } + + private static bool TryMatchLongBracketEnd(string lineText, int index, int equalsCount, out int tokenLength) + { + tokenLength = 0; + + if (index >= lineText.Length || lineText[index] != ']') + return false; + + int probeIndex = index + 1; + + for (int i = 0; i < equalsCount; i++) + { + if (probeIndex >= lineText.Length || lineText[probeIndex] != '=') + return false; + + probeIndex++; + } + + if (probeIndex >= lineText.Length || lineText[probeIndex] != ']') + return false; + + tokenLength = probeIndex - index + 1; + return true; + } + + private static bool IsLineCommentStart(string lineText, int index) + => lineText[index] == '-' && index + 1 < lineText.Length && lineText[index + 1] == '-'; + + private static ParserState GetInitialParserState(LuaLineParserState initialState) => initialState.Kind switch + { + LuaLineParserStateKind.LongString => ParserState.LongString, + LuaLineParserStateKind.LongComment => ParserState.LongComment, + _ => ParserState.None + }; + + private static LuaLineParserState CreateContinuationState(ParserState state, int longBracketEqualsCount) => state switch + { + ParserState.LongString => new LuaLineParserState(LuaLineParserStateKind.LongString, longBracketEqualsCount), + ParserState.LongComment => new LuaLineParserState(LuaLineParserStateKind.LongComment, longBracketEqualsCount), + _ => default + }; +} diff --git a/TombLib/TombLib.Scripting.Lua/Utils/TombEngineLanguageScriptService.cs b/TombLib/TombLib.Scripting.Lua/Utils/TombEngineLanguageScriptService.cs new file mode 100644 index 0000000000..a0f0e332f9 --- /dev/null +++ b/TombLib/TombLib.Scripting.Lua/Utils/TombEngineLanguageScriptService.cs @@ -0,0 +1,175 @@ +using ICSharpCode.AvalonEdit.Document; +using System; +using System.Text.RegularExpressions; + +namespace TombLib.Scripting.Lua.Utils; + +/// +/// Inserts generated Tomb Engine language strings into an existing Lua strings table. +/// +public sealed partial class TombEngineLanguageScriptService +{ + [GeneratedRegex(@"TEN\.Flow\.SetStrings\s*\(\s*(?[^)\s]+)\s*\)", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + private static partial Regex GetSetStringsRegex(); + + private static readonly Regex SetStringsRegex = GetSetStringsRegex(); + + /// + /// Attempts to insert a generated language entry into the strings table referenced by TEN.Flow.SetStrings(...). + /// + /// The document to modify. + /// The generated language-table entry to insert. + /// The one-based line number of the inserted entry, or when no suitable strings table could be found. + public int? TryInsertLanguageScript(TextDocument document, string languageScript) + { + string? stringsVariableName = TryGetStringsVariableName(document); + + if (stringsVariableName is null) + return null; + + DocumentLine? stringsStartLine = FindStringsStartLine(document, stringsVariableName); + + if (stringsStartLine is null) + return null; + + DocumentLine? stopLine = FindStringsStopLine(document, stringsStartLine); + + if (stopLine is null) + return null; + + DocumentLine? insertionLine = FindLanguageInsertionLine(document, stringsStartLine, stopLine); + + if (insertionLine is not null) + return InsertLanguageScript(document, languageScript, insertionLine); + + return InsertLanguageScriptIntoEmptyTable(document, languageScript, stopLine); + } + + private static string? TryGetStringsVariableName(TextDocument document) + { + foreach (DocumentLine line in document.Lines) + { + string lineText = LuaLineParser.StripLineComment(document.GetText(line)); + Match match = SetStringsRegex.Match(lineText); + + if (match.Success) + return match.Groups["name"].Value; + } + + return null; + } + + private static DocumentLine? FindStringsStartLine(TextDocument document, string stringsVariableName) + { + Regex regex = CreateStringsStartRegex(stringsVariableName); + + foreach (DocumentLine line in document.Lines) + { + if (regex.IsMatch(LuaLineParser.StripLineComment(document.GetText(line)))) + return line; + } + + return null; + } + + private static Regex CreateStringsStartRegex(string stringsVariableName) + => new(@"^\s*local\s+" + Regex.Escape(stringsVariableName) + @"\s*=", RegexOptions.Compiled); + + private static DocumentLine? FindStringsStopLine(TextDocument document, DocumentLine stringsStartLine) + { + int bracketDepth = 0; + bool foundOpeningBracket = false; + LuaLineParserState parserState = default; + + for (DocumentLine? line = stringsStartLine; line is not null; line = line.NextLine) + { + // Use the raw line text (not StripLineComment) so the structural enumerator can keep + // long-string and long-comment continuation state in sync across lines. A multi-line + // `[[...]]` entry inside the strings table would otherwise leak `{` / `}` characters + // from the string body into our brace counter. + string lineText = document.GetText(line); + LuaLineParserState capturedState = parserState; + + foreach (char character in LuaLineParser.EnumerateStructuralCharacters(lineText, parserState, state => capturedState = state)) + { + if (character == '{') + { + bracketDepth++; + foundOpeningBracket = true; + } + else if (character == '}') + { + if (!foundOpeningBracket || bracketDepth == 0) + return null; + + bracketDepth--; + + if (bracketDepth == 0) + return line; + } + } + + parserState = capturedState; + } + + return null; + } + + private static DocumentLine? FindLanguageInsertionLine(TextDocument document, DocumentLine stringsStartLine, DocumentLine stopLine) + { + // Walk forward from the strings-table opener so we can keep parser continuation state in + // lockstep, then pick the latest line that ends with `}` or `},` while not sitting inside + // a long string or comment carried over from earlier lines. + LuaLineParserState parserState = default; + DocumentLine? bestCandidate = null; + + for (DocumentLine? line = stringsStartLine; line is not null && line.LineNumber <= stopLine.LineNumber; line = line.NextLine) + { + LuaLineParserState capturedState = parserState; + bool insideLongBlockAtLineStart = parserState.Kind != LuaLineParserStateKind.None; + + // Drain the enumerator to advance parser state to the next line; ignore the chars. + foreach (char _ in LuaLineParser.EnumerateStructuralCharacters(document.GetText(line), parserState, state => capturedState = state)) + { } + + if (line.LineNumber <= stringsStartLine.LineNumber || line.LineNumber >= stopLine.LineNumber) + { + parserState = capturedState; + continue; + } + + if (!insideLongBlockAtLineStart) + { + string cleanLine = LuaLineParser.StripLineComment(document.GetText(line)).TrimEnd(); + + if (cleanLine.EndsWith('}') || cleanLine.EndsWith("},")) + bestCandidate = line; + } + + parserState = capturedState; + } + + return bestCandidate; + } + + private static int InsertLanguageScript(TextDocument document, string languageScript, DocumentLine insertionLine) + { + string rawLine = document.GetText(insertionLine); + string cleanLine = LuaLineParser.StripLineComment(rawLine).TrimEnd(); + + if (cleanLine.EndsWith('}')) + { + int commaOffset = insertionLine.Offset + cleanLine.Length; + document.Insert(commaOffset, ","); + } + + document.Insert(insertionLine.EndOffset, Environment.NewLine + languageScript); + return insertionLine.LineNumber + 1; + } + + private static int InsertLanguageScriptIntoEmptyTable(TextDocument document, string languageScript, DocumentLine stopLine) + { + document.Insert(stopLine.Offset, languageScript + Environment.NewLine); + return stopLine.LineNumber; + } +} diff --git a/TombLib/TombLib.Scripting.Tomb1Main/Services/Implementations/AutocompleteManager.cs b/TombLib/TombLib.Scripting.Tomb1Main/Services/Implementations/AutocompleteManager.cs index 9e72ee2a69..dbabace25e 100644 --- a/TombLib/TombLib.Scripting.Tomb1Main/Services/Implementations/AutocompleteManager.cs +++ b/TombLib/TombLib.Scripting.Tomb1Main/Services/Implementations/AutocompleteManager.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using TombLib.Scripting.Tomb1Main.Parsers; +using TombLib.Scripting.Utils; namespace TombLib.Scripting.Tomb1Main.Services.Implementations; @@ -54,7 +55,8 @@ public List FilterCompletions(List autocomplet public bool ShouldTriggerAutocompleteOnEmptyLine(TextDocument document, int caretOffset) { string currentLineText = LineParser.EscapeComments(document.GetText(document.GetLineByOffset(caretOffset))).Trim(); - return (currentLineText.Length == 1 && char.IsLetter(currentLineText[0])) || currentLineText.Equals("\"\""); + return EditorCompletionTriggerHelper.IsSingleCharacterLine(currentLineText, char.IsLetter) + || currentLineText.Equals("\"\""); } public (int startOffset, int endOffset) GetCompletionWindowOffsets(TextDocument document, int caretOffset, string currentWord) diff --git a/TombLib/TombLib.Scripting.Tomb1Main/Services/Implementations/GameflowHoverService.cs b/TombLib/TombLib.Scripting.Tomb1Main/Services/Implementations/GameflowHoverService.cs index 15c28f9e3a..b669cea8d3 100644 --- a/TombLib/TombLib.Scripting.Tomb1Main/Services/Implementations/GameflowHoverService.cs +++ b/TombLib/TombLib.Scripting.Tomb1Main/Services/Implementations/GameflowHoverService.cs @@ -96,10 +96,10 @@ public GameflowHoverService(IGameflowSchemaService schemaService) private static string FormatPropertyInfo(string propertyName, JSchema propertySchema) { - var info = $"\"{propertyName}\""; + var info = $"`\"{propertyName}\"`"; if (propertySchema.Type.HasValue) - info += $"\nType: {propertySchema.Type.Value}"; + info += $"\nType: `{propertySchema.Type.Value}`"; if (!string.IsNullOrEmpty(propertySchema.Description)) info += $"\n\n{propertySchema.Description}"; diff --git a/TombLib/TombLib.Scripting.Tomb1Main/Tomb1MainEditor.cs b/TombLib/TombLib.Scripting.Tomb1Main/Tomb1MainEditor.cs index 5064136913..c13c0c6f99 100644 --- a/TombLib/TombLib.Scripting.Tomb1Main/Tomb1MainEditor.cs +++ b/TombLib/TombLib.Scripting.Tomb1Main/Tomb1MainEditor.cs @@ -1,8 +1,6 @@ #nullable enable -using ICSharpCode.AvalonEdit.CodeCompletion; using ICSharpCode.AvalonEdit.Document; -using ICSharpCode.AvalonEdit.Rendering; using System; using System.Collections.Generic; using System.ComponentModel; @@ -17,6 +15,7 @@ using TombLib.Scripting.Tomb1Main.Services; using TombLib.Scripting.Tomb1Main.Services.Implementations; using TombLib.Scripting.Tomb1Main.Utils; +using TombLib.Scripting.Utils; using TombLib.Scripting.Workers; namespace TombLib.Scripting.Tomb1Main @@ -71,9 +70,8 @@ private void BindEventMethods() private void TextArea_TextEntering(object sender, TextCompositionEventArgs e) { // Handle Ctrl+Space autocomplete - if (AutocompleteEnabled && IsCtrlSpaceInput(e.Text, Keyboard.Modifiers)) + if (TryHandleCtrlSpaceCompletion(e, HandleCtrlSpaceAutocomplete)) { - HandleCtrlSpaceAutocomplete(e); return; } @@ -118,29 +116,31 @@ private void TextEditor_TextChanged(object? sender, EventArgs e) private void TextView_MouseHover(object? sender, MouseEventArgs e) { int hoveredOffset = GetOffsetFromPoint(e.GetPosition(this)); + + if (hoveredOffset == -1) + return; + + if (TryShowDiagnosticToolTip(hoveredOffset)) + return; + string? hoverInfo = _hoverService.GetHoverInfo(Document, hoveredOffset); if (!string.IsNullOrEmpty(hoverInfo)) - ShowToolTip(hoverInfo); + ShowMarkdownToolTip(hoverInfo); } private void ErrorWorker_RunWorkerCompleted(object? sender, RunWorkerCompletedEventArgs e) { - if (e.Result is null) + if (e.Result is not IReadOnlyList diagnostics) return; - ResetAllErrors(); - ApplyErrorsToLines(e.Result as List); - TextArea.TextView.InvalidateLayer(KnownLayer.Caret); + SetDiagnostics(diagnostics); } #endregion Event handlers #region Text input handling - private static bool IsCtrlSpaceInput(string inputText, ModifierKeys modifiers) - => inputText == " " && modifiers.HasFlag(ModifierKeys.Control); - private static bool ShouldTriggerAutocomplete(string inputText) => inputText == "\""; @@ -148,7 +148,7 @@ private static bool ShouldTriggerAutocomplete(string inputText) #region Autocomplete handling - private void HandleCtrlSpaceAutocomplete(TextCompositionEventArgs e) + private void HandleCtrlSpaceAutocomplete() { // Only allow Ctrl+Space if caret is at end of word or in whitespace if (_textAnalysisService.IsValidPositionForCtrlSpaceAutocomplete(Document, CaretOffset) && _completionWindow is null) @@ -156,8 +156,6 @@ private void HandleCtrlSpaceAutocomplete(TextCompositionEventArgs e) string currentWord = _textAnalysisService.GetCurrentWordBeingTyped(Document, CaretOffset); TryShowCompletionWindow(currentWord); } - - e.Handled = true; // Prevents the space character from being inserted } private void HandleAutocompleteOnTextEntered(TextCompositionEventArgs e) @@ -185,8 +183,7 @@ private void UpdateExistingCompletionWindow() if (matchingCompletions.Count == 0) { - _completionWindow.Close(); - _completionWindow = null; + CloseCompletionWindowCore(); } } @@ -199,33 +196,20 @@ private void TryHandleAutocompleteOnEmptyLine() } } - private bool TryShowCompletionWindow(string currentWord = "") + private void TryShowCompletionWindow(string currentWord = "") { var autocompleteData = _autocompleteService.GetAutocompleteData(); if (autocompleteData.Count == 0) - return false; + return; var filteredCompletions = _autocompleteManager.FilterCompletions(autocompleteData, currentWord); if (filteredCompletions.Count == 0) - return false; - - InitializeCompletionWindow(); - SetCompletionWindowOffsets(currentWord); - - foreach (ICompletionData item in filteredCompletions) - _completionWindow.CompletionList.CompletionData.Add(item); - - ShowCompletionWindow(); - return true; - } + return; - private void SetCompletionWindowOffsets(string currentWord) - { var (startOffset, endOffset) = _autocompleteManager.GetCompletionWindowOffsets(Document, CaretOffset, currentWord); - _completionWindow.StartOffset = startOffset; - _completionWindow.EndOffset = endOffset; + TryOpenCompletionWindow(filteredCompletions, startOffset, endOffset); } #endregion Autocomplete handling diff --git a/TombLib/TombLib.Scripting.Tomb1Main/TombLib.Scripting.Tomb1Main.csproj b/TombLib/TombLib.Scripting.Tomb1Main/TombLib.Scripting.Tomb1Main.csproj index 854819d119..435a2bca6c 100644 --- a/TombLib/TombLib.Scripting.Tomb1Main/TombLib.Scripting.Tomb1Main.csproj +++ b/TombLib/TombLib.Scripting.Tomb1Main/TombLib.Scripting.Tomb1Main.csproj @@ -1,34 +1,10 @@  - net6.0-windows - Library false true - true - Debug;Release - x64;x86 - - ..\..\Build ($(Platform))\ - - - ..\..\BuildRelease ($(Platform))\ - none - true - - - x64 - - - x86 - - - - False - ..\..\Libs\ICSharpCode.AvalonEdit.dll - - + GlobalPaths.cs diff --git a/TombLib/TombLib.Scripting.Tomb1Main/Utils/ErrorDetector.cs b/TombLib/TombLib.Scripting.Tomb1Main/Utils/ErrorDetector.cs index 61f9187bba..5f9b1a39d6 100644 --- a/TombLib/TombLib.Scripting.Tomb1Main/Utils/ErrorDetector.cs +++ b/TombLib/TombLib.Scripting.Tomb1Main/Utils/ErrorDetector.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using TombLib.Scripting.Interfaces; +using TombLib.Scripting.Objects; using TombLib.Scripting.Tomb1Main.Parsers; using TombLib.Scripting.Tomb1Main.Resources; @@ -9,18 +10,18 @@ namespace TombLib.Scripting.Tomb1Main.Utils; public class ErrorDetector : IErrorDetector { - public object FindErrors(string editorContent, Version engineVersion) + public IReadOnlyList FindErrors(string editorContent, Version engineVersion) { // Anything before 4.8 should not have errors checked if (engineVersion < new Version(4, 8)) - return null; + return Array.Empty(); return DetectErrorLines(new TextDocument(editorContent), engineVersion); } - private static List DetectErrorLines(TextDocument document, Version engineVersion) + private static List DetectErrorLines(TextDocument document, Version engineVersion) { - var errorLines = new List(); + var errorLines = new List(); foreach (DocumentLine processedLine in document.Lines) { @@ -30,7 +31,7 @@ private static List DetectErrorLines(TextDocument document, Version e continue; processedLineText = LineParser.EscapeComments(processedLineText); - ErrorLine error = FindErrorsInLine(processedLine, processedLineText, engineVersion); + TextEditorDiagnostic error = FindErrorsInLine(processedLine, processedLineText, engineVersion); if (error != null) errorLines.Add(error); @@ -39,7 +40,7 @@ private static List DetectErrorLines(TextDocument document, Version e return errorLines; } - private static ErrorLine FindErrorsInLine(DocumentLine line, string lineText, Version engineVersion) + private static TextEditorDiagnostic FindErrorsInLine(DocumentLine line, string lineText, Version engineVersion) { // Check whether there are JSON keys which are marked as "Removed" foreach (RemovedKeyword keyword in Keywords.RemovedProperties) @@ -51,9 +52,9 @@ private static ErrorLine FindErrorsInLine(DocumentLine line, string lineText, Ve if (lineText.Contains(keyPattern)) { - return new ErrorLine($"This property has been removed from the script syntax and cannot be used in TR1X {keyword.RemovedVersion} or newer." - + (string.IsNullOrEmpty(keyword.Message) ? "" : "\n" + keyword.Message), - line.LineNumber, keyPattern); + return CreateDiagnostic(line, lineText, + $"This property has been removed from the script syntax and cannot be used in TR1X {keyword.RemovedVersion} or newer." + + (string.IsNullOrEmpty(keyword.Message) ? "" : "\n" + keyword.Message), keyPattern); } } @@ -66,12 +67,26 @@ private static ErrorLine FindErrorsInLine(DocumentLine line, string lineText, Ve if (lineText.Contains(keyPattern)) { - return new ErrorLine($"This constant has been removed from the script syntax and cannot be used in TR1X {keyword.RemovedVersion} or newer." - + (string.IsNullOrEmpty(keyword.Message) ? "" : "\n" + keyword.Message), - line.LineNumber, keyPattern); + return CreateDiagnostic(line, lineText, + $"This constant has been removed from the script syntax and cannot be used in TR1X {keyword.RemovedVersion} or newer." + + (string.IsNullOrEmpty(keyword.Message) ? "" : "\n" + keyword.Message), keyPattern); } } return null; } + + private static TextEditorDiagnostic CreateDiagnostic(DocumentLine line, string lineText, string message, string keyPattern) + { + int matchIndex = string.IsNullOrWhiteSpace(keyPattern) + ? -1 + : lineText.IndexOf(keyPattern, StringComparison.Ordinal); + + int startOffset = matchIndex >= 0 ? line.Offset + matchIndex : line.Offset; + int endOffset = matchIndex >= 0 + ? startOffset + keyPattern.Length + : Math.Max(line.Offset + 1, line.EndOffset); + + return new TextEditorDiagnostic(TextEditorDiagnosticSeverity.Error, message, startOffset, endOffset); + } } diff --git a/TombLib/TombLib.Scripting/Bases/TextEditorBase.cs b/TombLib/TombLib.Scripting/Bases/TextEditorBase.cs index 21c13ccaee..eb69d7377d 100644 --- a/TombLib/TombLib.Scripting/Bases/TextEditorBase.cs +++ b/TombLib/TombLib.Scripting/Bases/TextEditorBase.cs @@ -1,4 +1,6 @@ -using DarkUI.Forms; +#nullable enable + +using DarkUI.Forms; using ICSharpCode.AvalonEdit; using ICSharpCode.AvalonEdit.CodeCompletion; using ICSharpCode.AvalonEdit.Document; @@ -6,11 +8,13 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Text; using System.Windows; using System.Windows.Controls; +using System.Windows.Controls.Primitives; using System.Windows.Documents; using System.Windows.Input; using System.Windows.Media; @@ -20,13 +24,29 @@ using TombLib.Scripting.Objects; using TombLib.Scripting.Rendering; using TombLib.Scripting.Resources; +using TombLib.Scripting.Services; using TombLib.Scripting.Utils; using TombLib.Scripting.Workers; +using static TombLib.WPF.BrushHelpers; namespace TombLib.Scripting.Bases { - public abstract class TextEditorBase : TextEditor, IEditorControl, ISupportsFindReplace + public abstract class TextEditorBase : TextEditor, IEditorControl { + protected const double ToolTipTextMaxWidth = 500.0; + protected static readonly double ToolTipTextFontSize = Math.Max(SystemFonts.MessageFontSize + 1.0, 14.0); + protected static readonly SolidColorBrush DefaultToolTipBorder = TextEditorColorPalette.ToolTipBorder; + protected static readonly SolidColorBrush DefaultToolTipBackground = TextEditorColorPalette.ToolTipBackground; + private static readonly SolidColorBrush ErrorToolTipBorder = TextEditorColorPalette.ErrorToolTipBorder; + private static readonly SolidColorBrush ErrorToolTipBackground = TextEditorColorPalette.ErrorToolTipBackground; + private static readonly SolidColorBrush WarningToolTipBorder = TextEditorColorPalette.WarningToolTipBorder; + private static readonly SolidColorBrush WarningToolTipBackground = TextEditorColorPalette.WarningToolTipBackground; + private static readonly SolidColorBrush InformationToolTipBorder = TextEditorColorPalette.InformationToolTipBorder; + private static readonly SolidColorBrush InformationToolTipBackground = TextEditorColorPalette.InformationToolTipBackground; + private static readonly SolidColorBrush HintToolTipBorder = TextEditorColorPalette.HintToolTipBorder; + private static readonly SolidColorBrush HintToolTipBackground = TextEditorColorPalette.HintToolTipBackground; + protected static readonly SolidColorBrush ToolTipForeground = TextEditorColorPalette.ToolTipForeground; + public EditorType EditorType => EditorType.Text; public abstract string DefaultFileExtension { get; } @@ -105,16 +125,24 @@ public TimeSpan TextChangedDelayedInterval #region Fields - protected ToolTip _specialToolTip = new ToolTip(); - protected CompletionWindow _completionWindow; + protected Popup _specialToolTip; + protected CompletionWindow? _completionWindow; private ContentChangedWorker _contentChangedWorker; + private readonly CompletionWindowHost _completionWindowHost; + private readonly EditorToolTipPresenter _toolTipPresenter; - private DispatcherTimer _textChangedDelayedTimer = new DispatcherTimer(); + private readonly DispatcherTimer _textChangedDelayedTimer = new DispatcherTimer(); + private readonly Border _specialToolTipBorder; + private readonly ContentPresenter _specialToolTipPresenter; + private readonly List _bookmarkAnchors = new List(); + private IReadOnlyList _diagnostics = Array.Empty(); private IBackgroundRenderer _bookmarkRenderer; private IBackgroundRenderer _errorRenderer; + internal IReadOnlyList Diagnostics => _diagnostics; + #endregion Fields #region Construction @@ -122,6 +150,11 @@ public TimeSpan TextChangedDelayedInterval public TextEditorBase(Version engineVersion) { SetNewDefaultSettings(); + _completionWindowHost = new CompletionWindowHost(TextArea); + _toolTipPresenter = new EditorToolTipPresenter(this); + _specialToolTip = _toolTipPresenter.Popup; + _specialToolTipBorder = _toolTipPresenter.Border; + _specialToolTipPresenter = _toolTipPresenter.ContentPresenter; InitializeBackgroundWorkers(); InitializeTimers(); @@ -149,6 +182,7 @@ private void SetNewDefaultSettings() VerticalScrollBarVisibility = ScrollBarVisibility.Auto; } + [MemberNotNull(nameof(_contentChangedWorker))] private void InitializeBackgroundWorkers() { _contentChangedWorker = new ContentChangedWorker(); @@ -161,6 +195,7 @@ private void InitializeTimers() _textChangedDelayedTimer.Tick += TextChangedDelayedTimer_Tick; } + [MemberNotNull(nameof(_bookmarkRenderer), nameof(_errorRenderer))] private void InitializeRenderers() { _bookmarkRenderer = new BookmarkRenderer(this); @@ -189,39 +224,39 @@ private void BindEventMethods() #region Events - public event EventHandler StatusChanged; + public event EventHandler? StatusChanged; protected virtual void OnStatusChanged(EventArgs e) => StatusChanged?.Invoke(this, e); - public event EventHandler ZoomChanged; + public event EventHandler? ZoomChanged; protected virtual void OnZoomChanged(EventArgs e) { ZoomChanged?.Invoke(this, e); OnStatusChanged(EventArgs.Empty); } - public event EventHandler TextChangedDelayed; + public event EventHandler? TextChangedDelayed; protected virtual void OnTextChangedDelayed(EventArgs e) => TextChangedDelayed?.Invoke(this, e); - public event EventHandler ContentChangedWorkerRunCompleted; + public event EventHandler? ContentChangedWorkerRunCompleted; protected virtual void OnContentChangedWorkerRunCompleted(EventArgs e) => ContentChangedWorkerRunCompleted?.Invoke(this, e); - private void ContentChangedWorker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e) + private void ContentChangedWorker_RunWorkerCompleted(object? sender, RunWorkerCompletedEventArgs e) { LastModified = DateTime.Now; - IsContentChanged = (bool)e.Result; + IsContentChanged = e.Result is bool isContentChanged && isContentChanged; OnContentChangedWorkerRunCompleted(EventArgs.Empty); } - private void TextArea_TextEntering(object sender, TextCompositionEventArgs e) + private void TextArea_TextEntering(object? sender, TextCompositionEventArgs e) { - CloseDefinitionToolTip(); // Prevents the ToolTip from covering the screen while typing + CloseDefinitionToolTip(true); // Prevents the ToolTip from covering the screen while typing HandleAutoClosing(e); } - private void TextEditor_TextChanged(object sender, EventArgs e) + private void TextEditor_TextChanged(object? sender, EventArgs e) { IsContentChanged = true; @@ -229,7 +264,7 @@ private void TextEditor_TextChanged(object sender, EventArgs e) _textChangedDelayedTimer.Start(); } - private void TextChangedDelayedTimer_Tick(object sender, EventArgs e) + private void TextChangedDelayedTimer_Tick(object? sender, EventArgs e) { TryRunContentChangedWorker(); @@ -237,32 +272,47 @@ private void TextChangedDelayedTimer_Tick(object sender, EventArgs e) _textChangedDelayedTimer.Stop(); } - private void TextEditor_MouseHover(object sender, MouseEventArgs e) + private void TextEditor_MouseHover(object? sender, MouseEventArgs e) + => HandleMouseHover(e); + + protected virtual void HandleMouseHover(MouseEventArgs e) => HandleErrorToolTips(e); - private void TextEditor_MouseHoverStopped(object sender, MouseEventArgs e) - => CloseDefinitionToolTip(); + private void TextEditor_MouseHoverStopped(object? sender, MouseEventArgs e) + => ScheduleDefinitionToolTipClose(); - private void TextEditor_PreviewMouseWheel(object sender, MouseWheelEventArgs e) + private void TextEditor_PreviewMouseWheel(object? sender, MouseWheelEventArgs e) { if (Keyboard.Modifiers == ModifierKeys.Control) HandleZoom(e); } - private void TextEditor_MouseRightButtonDown(object sender, MouseButtonEventArgs e) + private void TextEditor_MouseRightButtonDown(object? sender, MouseButtonEventArgs e) => MoveCaretToMousePosition(); - private void CloseDefinitionToolTip() + protected void CloseDefinitionToolTip(bool force = false) + => _toolTipPresenter.Close(force); + + protected bool TryHandleCtrlSpaceCompletion(TextCompositionEventArgs e, Action onTriggered) { - if (_specialToolTip.IsOpen) - _specialToolTip.IsOpen = false; + if (!AutocompleteEnabled || !EditorCompletionTriggerHelper.IsCtrlSpaceInput(e.Text, Keyboard.Modifiers)) + return false; + + if (_completionWindow is null) + onTriggered(); + + e.Handled = true; + return true; } + private void ScheduleDefinitionToolTipClose() + => _toolTipPresenter.ScheduleClose(); + private void MoveCaretToMousePosition() { if (string.IsNullOrEmpty(SelectedText)) { - TextViewPosition? position = TextArea.TextView.GetPosition(Mouse.GetPosition(TextArea.TextView) + TextArea.TextView.ScrollOffset); + TextViewPosition? position = GetTextViewPosition(Mouse.GetPosition(this)); if (position != null) { @@ -302,7 +352,10 @@ public void Save() private void SaveBookmarks() { - IEnumerable bookmarkedLines = Document.Lines.Where(line => line.IsBookmarked); + if (string.IsNullOrWhiteSpace(FilePath)) + return; + + List bookmarkedLines = CollectBookmarkedLines(); var builder = new StringBuilder(); @@ -313,7 +366,7 @@ private void SaveBookmarks() { string bookmarkFileName = FilePath + ".bkmrk"; - if (bookmarkedLines.Count() > 0) + if (bookmarkedLines.Count > 0) File.WriteAllText(bookmarkFileName, builder.ToString()); else if (File.Exists(bookmarkFileName)) File.Delete(bookmarkFileName); @@ -326,6 +379,8 @@ private void SaveBookmarks() private void RestoreBookmarks() { + _bookmarkAnchors.Clear(); + string bookmarkFileName = FilePath + ".bkmrk"; if (!File.Exists(bookmarkFileName)) @@ -335,12 +390,12 @@ private void RestoreBookmarks() { foreach (string line in File.ReadAllLines(bookmarkFileName)) { - if (int.TryParse(line, out int lineNumber)) + if (int.TryParse(line, out int lineNumber) && lineNumber >= 1 && lineNumber <= Document.LineCount) { DocumentLine documentLine = Document.GetLineByNumber(lineNumber); - if (documentLine != null) - documentLine.IsBookmarked = true; + if (FindBookmarkAnchor(documentLine) is null) + AddBookmark(documentLine); } } } @@ -383,21 +438,27 @@ private void SetContent(string content) #region Error handling - public void ApplyErrorsToLines(List errorLines) + public void SetDiagnostics(IReadOnlyList diagnostics) { - foreach (ErrorLine line in errorLines) - { - if (line.LineNumber > Document.LineCount) - continue; + _diagnostics = diagnostics ?? Array.Empty(); + InvalidateDiagnosticLayer(); + } - Document.GetLineByNumber(line.LineNumber).Error = line; - } + public void ClearDiagnostics() + { + if (_diagnostics.Count == 0) + return; + + _diagnostics = Array.Empty(); + InvalidateDiagnosticLayer(); } - public void ResetAllErrors() + private void InvalidateDiagnosticLayer() { - foreach (DocumentLine line in Document.Lines) - line.ClearError(); + TextArea.TextView.InvalidateLayer(KnownLayer.Background); + TextArea.TextView.InvalidateLayer(KnownLayer.Selection); + TextArea.TextView.InvalidateLayer(KnownLayer.Caret); + TextArea.TextView.InvalidateVisual(); } private void HandleErrorToolTips(MouseEventArgs e) @@ -407,15 +468,59 @@ private void HandleErrorToolTips(MouseEventArgs e) if (hoveredOffset == -1) return; - DocumentLine hoveredLine = Document.GetLineByOffset(hoveredOffset); + TryShowDiagnosticToolTip(hoveredOffset); + } + + protected bool TryGetDiagnosticInfo(int hoveredOffset, [NotNullWhen(true)] out string? message, out TextEditorDiagnosticSeverity severity, bool allowLineFallback = true) + { + message = null; + severity = TextEditorDiagnosticSeverity.Error; + + if (!LiveErrorUnderlining || _diagnostics.Count == 0) + return false; - if (hoveredLine.HasError) - ShowToolTip("Error:\n" + hoveredLine.Error.Message, - new SolidColorBrush(Color.FromRgb(128, 96, 96)), - new SolidColorBrush(Color.FromRgb(96, 64, 64)), - new SolidColorBrush(Colors.Gainsboro)); + List hoveredDiagnostics = GetDiagnosticsAtOffset(hoveredOffset); + + if (hoveredDiagnostics.Count == 0 && allowLineFallback) + hoveredDiagnostics = GetDiagnosticsForLine(Document.GetLineByOffset(hoveredOffset)); + + if (hoveredDiagnostics.Count == 0) + return false; + + severity = hoveredDiagnostics + .OrderBy(diagnostic => diagnostic.Severity) + .Select(diagnostic => diagnostic.Severity) + .First(); + + message = string.Join(Environment.NewLine + Environment.NewLine, + hoveredDiagnostics + .OrderBy(diagnostic => diagnostic.Severity) + .ThenBy(diagnostic => diagnostic.StartOffset) + .Select(FormatDiagnosticMessage) + .Distinct(StringComparer.Ordinal)); + + return true; + } + + protected void ShowDiagnosticToolTip(string message, TextEditorDiagnosticSeverity severity) + { + GetDiagnosticToolTipColors(severity, out SolidColorBrush border, out SolidColorBrush background); + ShowToolTip(message, border, background, ToolTipForeground); + } + + protected bool TryShowDiagnosticToolTip(int hoveredOffset) + { + if (!TryGetDiagnosticInfo(hoveredOffset, out string? message, out TextEditorDiagnosticSeverity severity) + || string.IsNullOrWhiteSpace(message)) + return false; + + ShowDiagnosticToolTip(message, severity); + return true; } + protected bool HasDiagnosticsOnLine(DocumentLine line) + => line is not null && GetDiagnosticsForLine(line).Count > 0; + #endregion Error handling #region Auto bracket closing @@ -470,9 +575,13 @@ private void TryPerformElementSkip(TextCompositionEventArgs e, string element) { CaretOffset++; e.Handled = true; + OnAutoClosingElementSkipped(element); } } + protected virtual void OnAutoClosingElementSkipped(string element) + { } + #endregion Auto bracket closing #region Multiline commenting @@ -480,36 +589,43 @@ private void TryPerformElementSkip(TextCompositionEventArgs e, string element) // TODO: Refactor public void CommentOutLines() + { + if (string.IsNullOrWhiteSpace(CommentPrefix)) + return; + + ApplyLineCommentTransformation(CommentLine); + } + + public void UncommentLines() + { + if (string.IsNullOrWhiteSpace(CommentPrefix)) + return; + + ApplyLineCommentTransformation(UncommentLine); + } + + public void ToggleCommentLines() + { + if (string.IsNullOrWhiteSpace(CommentPrefix)) + return; + + ApplyLineCommentTransformation(ShouldUncommentSelectedLines() ? UncommentLine : CommentLine); + } + + private void ApplyLineCommentTransformation(Func transformLine) { DocumentLine startLine = Document.GetLineByOffset(SelectionStart); DocumentLine endLine = Document.GetLineByOffset(SelectionStart + SelectionLength); int totalLineLength = 0; - var builder = new StringBuilder(); - for (int i = startLine.LineNumber; i <= endLine.LineNumber; i++) + for (int lineNumber = startLine.LineNumber; lineNumber <= endLine.LineNumber; lineNumber++) { - DocumentLine currentLine = Document.GetLineByNumber(i); + DocumentLine currentLine = Document.GetLineByNumber(lineNumber); string currentLineText = Document.GetText(currentLine.Offset, currentLine.Length); - var whitespaceBuilder = new StringBuilder(); - - for (int j = 0; j < currentLineText.Length; j++) - { - char c = currentLineText[j]; - - if (char.IsWhiteSpace(c)) - whitespaceBuilder.Append(c); - else - break; - } - - if (!string.IsNullOrWhiteSpace(currentLineText)) - builder.AppendLine(whitespaceBuilder.ToString() + CommentPrefix + currentLineText.TrimStart()); - else - builder.AppendLine(whitespaceBuilder.ToString()); - + builder.AppendLine(transformLine(currentLineText, CommentPrefix)); totalLineLength += currentLine.TotalLength; } @@ -519,44 +635,63 @@ public void CommentOutLines() Select(startLine.Offset, SelectionLength - 1); } - public void UncommentLines() + private bool ShouldUncommentSelectedLines() { DocumentLine startLine = Document.GetLineByOffset(SelectionStart); DocumentLine endLine = Document.GetLineByOffset(SelectionStart + SelectionLength); + bool foundCommentableLine = false; - int totalLineLength = 0; - - var builder = new StringBuilder(); - - for (int i = startLine.LineNumber; i <= endLine.LineNumber; i++) + for (int lineNumber = startLine.LineNumber; lineNumber <= endLine.LineNumber; lineNumber++) { - DocumentLine currentLine = Document.GetLineByNumber(i); + DocumentLine currentLine = Document.GetLineByNumber(lineNumber); string currentLineText = Document.GetText(currentLine.Offset, currentLine.Length); + string trimmedLineText = currentLineText.TrimStart(); - var whitespaceBuilder = new StringBuilder(); + if (string.IsNullOrWhiteSpace(trimmedLineText)) + continue; - for (int j = 0; j < currentLineText.Length; j++) - { - char c = currentLineText[j]; + foundCommentableLine = true; - if (char.IsWhiteSpace(c)) - whitespaceBuilder.Append(c); - else - break; - } + if (!trimmedLineText.StartsWith(CommentPrefix, StringComparison.Ordinal)) + return false; + } - if (currentLineText.TrimStart().StartsWith(CommentPrefix)) - builder.AppendLine(whitespaceBuilder.ToString() + currentLineText.TrimStart().Remove(0, CommentPrefix.Length)); - else - builder.AppendLine(currentLineText); + return foundCommentableLine; + } - totalLineLength += currentLine.TotalLength; - } + private static string CommentLine(string currentLineText, string commentPrefix) + { + string leadingWhitespace = GetLeadingWhitespace(currentLineText); + return !string.IsNullOrWhiteSpace(currentLineText) + ? leadingWhitespace + commentPrefix + currentLineText.TrimStart() + : leadingWhitespace; + } - Select(startLine.Offset, totalLineLength); - SelectedText = builder.ToString(); + private static string UncommentLine(string currentLineText, string commentPrefix) + { + string leadingWhitespace = GetLeadingWhitespace(currentLineText); + string trimmedLineText = currentLineText.TrimStart(); - Select(startLine.Offset, SelectionLength - 1); + return trimmedLineText.StartsWith(commentPrefix, StringComparison.Ordinal) + ? leadingWhitespace + trimmedLineText.Remove(0, commentPrefix.Length) + : currentLineText; + } + + private static string GetLeadingWhitespace(string currentLineText) + { + var whitespaceBuilder = new StringBuilder(); + + for (int index = 0; index < currentLineText.Length; index++) + { + char character = currentLineText[index]; + + if (char.IsWhiteSpace(character)) + whitespaceBuilder.Append(character); + else + break; + } + + return whitespaceBuilder.ToString(); } #endregion Multiline commenting @@ -568,7 +703,13 @@ public void UncommentLines() public void ToggleBookmark() { DocumentLine currentLine = Document.GetLineByOffset(CaretOffset); - currentLine.IsBookmarked = !currentLine.IsBookmarked; + + TextAnchor? bookmarkAnchor = FindBookmarkAnchor(currentLine); + + if (bookmarkAnchor is null) + AddBookmark(currentLine); + else + _bookmarkAnchors.Remove(bookmarkAnchor); TextArea.TextView.InvalidateLayer(KnownLayer.Background); @@ -578,61 +719,31 @@ public void ToggleBookmark() public void GoToNextBookmark() { DocumentLine currentLine = Document.GetLineByOffset(CaretOffset); + List bookmarkedLines = CollectBookmarkedLines(); - for (int i = 1; i < Document.LineCount; i++) - { - DocumentLine iLine = Document.GetLineByNumber(i); + if (bookmarkedLines.Count == 0) + return; - if (iLine.IsBookmarked && iLine.LineNumber > currentLine.LineNumber) - { - CaretOffset = iLine.EndOffset; - ScrollToLine(iLine.LineNumber); - break; - } + DocumentLine nextBookmark = bookmarkedLines.FirstOrDefault(line => line.LineNumber > currentLine.LineNumber) + ?? bookmarkedLines[0]; - if (i == Document.LineCount - 1) - for (int j = 1; j < Document.LineCount; j++) - { - DocumentLine jLine = Document.GetLineByNumber(j); - - if (jLine.IsBookmarked) - { - CaretOffset = jLine.EndOffset; - ScrollToLine(jLine.LineNumber); - break; - } - } - } + CaretOffset = nextBookmark.EndOffset; + ScrollToLine(nextBookmark.LineNumber); } public void GoToPrevBookmark() { DocumentLine currentLine = Document.GetLineByOffset(CaretOffset); + List bookmarkedLines = CollectBookmarkedLines(); - for (int i = Document.LineCount - 1; i > 0; i--) - { - if (i == 1) - for (int j = Document.LineCount - 1; j > 0; j--) - { - DocumentLine jLine = Document.GetLineByNumber(j); - - if (jLine.IsBookmarked) - { - CaretOffset = jLine.EndOffset; - ScrollToLine(jLine.LineNumber); - break; - } - } + if (bookmarkedLines.Count == 0) + return; - DocumentLine iLine = Document.GetLineByNumber(i); + DocumentLine previousBookmark = bookmarkedLines.LastOrDefault(line => line.LineNumber < currentLine.LineNumber) + ?? bookmarkedLines[bookmarkedLines.Count - 1]; - if (iLine.IsBookmarked && iLine.LineNumber < currentLine.LineNumber) - { - CaretOffset = iLine.EndOffset; - ScrollToLine(iLine.LineNumber); - break; - } - } + CaretOffset = previousBookmark.EndOffset; + ScrollToLine(previousBookmark.LineNumber); } #endregion Bookmarks @@ -680,24 +791,46 @@ private void HandleZoom(MouseWheelEventArgs e) #region CompletionWindow public void InitializeCompletionWindow(int width = 300, int height = 300) + => _completionWindow = _completionWindowHost.Create(width, height, DefaultToolTipBorder, DefaultToolTipBackground, ToolTipForeground); + + public void ShowCompletionWindow() { - _completionWindow = new CompletionWindow(TextArea) - { - WindowStyle = WindowStyle.None, - ResizeMode = ResizeMode.NoResize, - BorderThickness = new Thickness(2), - Background = new SolidColorBrush(Color.FromRgb(69, 69, 69)), - Foreground = Brushes.White, - BorderBrush = Brushes.Black, - Width = width, - Height = height - }; + if (_completionWindow is null) + return; + + _completionWindowHost.Show(_completionWindow, () => _completionWindow = null); } - public void ShowCompletionWindow() + protected void CloseCompletionWindowCore() + => _completionWindowHost.Close(_completionWindow, () => _completionWindow = null); + + protected bool TryOpenCompletionWindow(IEnumerable items, + int? startOffset = null, + int? endOffset = null, + int width = 300, + int height = 300) { - _completionWindow.Show(); - _completionWindow.Closed += delegate { _completionWindow = null; }; + ICompletionData[] completionItems = items?.ToArray() ?? []; + + if (completionItems.Length == 0) + return false; + + InitializeCompletionWindow(width, height); + + if (_completionWindow is null) + return false; + + if (startOffset.HasValue) + _completionWindow.StartOffset = startOffset.Value; + + if (endOffset.HasValue) + _completionWindow.EndOffset = endOffset.Value; + + foreach (ICompletionData item in completionItems) + _completionWindow.CompletionList.CompletionData.Add(item); + + ShowCompletionWindow(); + return true; } #endregion CompletionWindow @@ -710,9 +843,7 @@ public void ClearAllBookmarks(System.Windows.Forms.IWin32Window promptOwner) "Are you sure?", System.Windows.Forms.MessageBoxButtons.YesNo, System.Windows.Forms.MessageBoxIcon.Question); if (result == System.Windows.Forms.DialogResult.Yes) - foreach (DocumentLine line in Document.Lines) - if (line.IsBookmarked) - line.IsBookmarked = false; + _bookmarkAnchors.Clear(); TextArea.TextView.InvalidateLayer(KnownLayer.Background); @@ -754,13 +885,13 @@ public void ReplaceContent(string newContent) public int GetOffsetFromPoint(Point point) { - TextViewPosition? position = GetPositionFromPoint(point); + TextViewPosition? position = GetTextViewPosition(point); if (position == null) return -1; DocumentLine pointLine = Document.GetLineByNumber(((TextViewPosition)position).Line); - int offset = pointLine.Offset + ((TextViewPosition)position).Column; + int offset = pointLine.Offset + Math.Min(pointLine.Length, Math.Max(0, ((TextViewPosition)position).Column - 1)); if (offset > Document.TextLength) return -1; @@ -768,7 +899,16 @@ public int GetOffsetFromPoint(Point point) return offset; } - public string GetWordFromOffset(int offset) + private TextViewPosition? GetTextViewPosition(Point point) + { + if (TextArea?.TextView is null) + return null; + + Point textViewPoint = TranslatePoint(point, TextArea.TextView); + return TextArea.TextView.GetPosition(textViewPoint + TextArea.TextView.ScrollOffset); + } + + public string? GetWordFromOffset(int offset) { int wordStart = TextUtilities.GetNextCaretPosition(Document, offset, LogicalDirection.Backward, CaretPositioningMode.WordBorder); int wordEnd = TextUtilities.GetNextCaretPosition(Document, offset, LogicalDirection.Forward, CaretPositioningMode.WordBorder); @@ -781,25 +921,176 @@ public string GetWordFromOffset(int offset) public void ShowToolTip(string content) => ShowToolTip(content, - new SolidColorBrush(Color.FromRgb(96, 96, 96)), - new SolidColorBrush(Color.FromRgb(64, 64, 64)), - new SolidColorBrush(Colors.Gainsboro)); + DefaultToolTipBorder, + DefaultToolTipBackground, + ToolTipForeground); + + public void ShowMarkdownToolTip(string content) + => ShowMarkdownToolTip(content, + DefaultToolTipBorder, + DefaultToolTipBackground, + ToolTipForeground); public void ShowToolTip(string content, SolidColorBrush border, SolidColorBrush background, SolidColorBrush foreground) + => ShowToolTip(CreatePlainToolTipContent(content, foreground), border, background); + + public void ShowMarkdownToolTip(string content, SolidColorBrush border, SolidColorBrush background, SolidColorBrush foreground) + => ShowToolTip(CreateMarkdownToolTipContent(content, foreground, background), border, background); + + protected void ShowToolTip(object content, SolidColorBrush border, SolidColorBrush background) + => _toolTipPresenter.Show(content, border, background); + + private static bool IsSeverityPrefixed(string message) + => !string.IsNullOrWhiteSpace(message) + && (message.StartsWith("Error:", StringComparison.OrdinalIgnoreCase) + || message.StartsWith("Warning:", StringComparison.OrdinalIgnoreCase) + || message.StartsWith("Information:", StringComparison.OrdinalIgnoreCase) + || message.StartsWith("Hint:", StringComparison.OrdinalIgnoreCase) + || message.StartsWith("Diagnostic:", StringComparison.OrdinalIgnoreCase)); + + private static object CreatePlainToolTipContent(string content, Brush foreground) + => MarkdownToolTipRenderer.CreatePlainTextContent(content, foreground); + + private static object CreateMarkdownToolTipContent(string content, Brush foreground, Brush background) { - _specialToolTip.PlacementTarget = this; // Required for property inheritance + string normalizedContent = NormalizeToolTipLineEndings(content); - _specialToolTip.BorderBrush = border; - _specialToolTip.Background = background; - _specialToolTip.Foreground = foreground; + if (string.IsNullOrWhiteSpace(normalizedContent)) + return CreatePlainToolTipContent(string.Empty, foreground); - _specialToolTip.Content = content; - _specialToolTip.IsOpen = true; + return MarkdownToolTipRenderer.CreateContent(normalizedContent, foreground, background); + } + + private static string NormalizeToolTipLineEndings(string text) + => (text ?? string.Empty) + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n'); + + private List CollectBookmarkedLines() + { + var bookmarkedLines = new List(); + var invalidAnchors = new List(); + var seenLineNumbers = new HashSet(); + + foreach (TextAnchor anchor in _bookmarkAnchors) + { + DocumentLine? line = GetBookmarkedLine(anchor); + + if (line is null) + { + invalidAnchors.Add(anchor); + continue; + } + + if (seenLineNumbers.Add(line.LineNumber)) + bookmarkedLines.Add(line); + } + + foreach (TextAnchor anchor in invalidAnchors) + _bookmarkAnchors.Remove(anchor); + + bookmarkedLines.Sort((left, right) => left.LineNumber.CompareTo(right.LineNumber)); + return bookmarkedLines; + } + + internal IReadOnlyList GetBookmarkedLines() + => CollectBookmarkedLines(); + + private void AddBookmark(DocumentLine line) + { + if (line is null) + return; + + var anchor = Document.CreateAnchor(line.Offset); + anchor.MovementType = AnchorMovementType.BeforeInsertion; + anchor.SurviveDeletion = true; + _bookmarkAnchors.Add(anchor); + } + + private TextAnchor? FindBookmarkAnchor(DocumentLine line) + { + if (line is null) + return null; + + foreach (TextAnchor anchor in _bookmarkAnchors) + { + DocumentLine? bookmarkedLine = GetBookmarkedLine(anchor); + + if (bookmarkedLine is not null && bookmarkedLine.LineNumber == line.LineNumber) + return anchor; + } + + return null; + } + + private DocumentLine? GetBookmarkedLine(TextAnchor anchor) + { + if (anchor is null || anchor.IsDeleted || Document.LineCount == 0) + return null; + + int offset = Math.Max(0, Math.Min(anchor.Offset, Document.TextLength)); + return Document.GetLineByOffset(offset); + } + + private List GetDiagnosticsAtOffset(int offset) + => _diagnostics + .Where(diagnostic => diagnostic.ContainsOffset(offset)) + .ToList(); + + private List GetDiagnosticsForLine(DocumentLine? line) + { + if (line is null || _diagnostics.Count == 0) + return new List(); + + int endOffset = Math.Max(line.EndOffset, line.Offset + 1); + + return _diagnostics + .Where(diagnostic => diagnostic.Intersects(line.Offset, endOffset)) + .ToList(); + } + + private static string FormatDiagnosticMessage(TextEditorDiagnostic diagnostic) + { + if (diagnostic is null) + return string.Empty; + + if (IsSeverityPrefixed(diagnostic.Message)) + return diagnostic.Message; + + return diagnostic.Severity.GetLabel() + ":\n" + diagnostic.Message; + } + + protected static void GetDiagnosticToolTipColors(TextEditorDiagnosticSeverity severity, + out SolidColorBrush border, out SolidColorBrush background) + { + switch (severity) + { + case TextEditorDiagnosticSeverity.Warning: + border = WarningToolTipBorder; + background = WarningToolTipBackground; + break; + + case TextEditorDiagnosticSeverity.Information: + border = InformationToolTipBorder; + background = InformationToolTipBackground; + break; + + case TextEditorDiagnosticSeverity.Hint: + border = HintToolTipBorder; + background = HintToolTipBackground; + break; + + default: + border = ErrorToolTipBorder; + background = ErrorToolTipBackground; + break; + } } public virtual void UpdateSettings(ConfigurationBase configuration) { - var config = configuration as TextEditorConfigBase; + if (configuration is not TextEditorConfigBase config) + return; FontSize = config.FontSize; DefaultFontSize = config.FontSize; @@ -835,7 +1126,7 @@ public virtual void TidyCode(bool trimOnly = false) void IEditorControl.Undo() => Undo(); void IEditorControl.Redo() => Redo(); - public virtual void GoToObject(string objectName, object identifyingObject = null) + public virtual void GoToObject(string objectName, object? identifyingObject = null) { } // Bruh public void Dispose() diff --git a/TombLib/TombLib.Scripting/Highlighting/LuaBuiltInTextMateThemeDefaults.cs b/TombLib/TombLib.Scripting/Highlighting/LuaBuiltInTextMateThemeDefaults.cs new file mode 100644 index 0000000000..f895db57d1 --- /dev/null +++ b/TombLib/TombLib.Scripting/Highlighting/LuaBuiltInTextMateThemeDefaults.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; + +namespace TombLib.Scripting.Highlighting +{ + public static class LuaBuiltInTextMateThemeDefaults + { + public const string DefaultEditorBackground = "#2D2D2D"; + public const string DefaultEditorForeground = "#CCCCCC"; + + public const string DefaultMutedText = "#999999"; + public const string DefaultMisc = "#CCCCCC"; + public const string DefaultMethod = "#FFCC66"; + public const string DefaultVariable = "#E6C8FF"; + public const string DefaultProperty = "#D7B8FF"; + public const string DefaultType = "#66CCCC"; + public const string DefaultKeyword = "#6699CC"; + public const string DefaultLanguageConstant = "#66CCCC"; + public const string DefaultConstant = "#99CC99"; + public const string DefaultFile = "#F99157"; + public const string DefaultSignatureParameterDocumentation = "#999999"; + public const string DefaultSignatureActiveParameter = "#FFCC66"; + public const string DefaultSignatureText = "#CCCCCC"; + + public static TextMateTokenTheme CreateDefaultTextMateTheme() + { + return new TextMateTokenTheme + { + Rules = new List + { + new TextMateTokenThemeRule { Scope = "comment", Foreground = DefaultMutedText }, + new TextMateTokenThemeRule { Scope = "string", Foreground = DefaultFile }, + new TextMateTokenThemeRule { Scope = "constant.numeric", Foreground = DefaultConstant }, + new TextMateTokenThemeRule { Scope = "constant.character.escape", Foreground = DefaultFile }, + new TextMateTokenThemeRule { Scope = "constant.language", Foreground = DefaultLanguageConstant, FontStyle = "bold" }, + new TextMateTokenThemeRule { Scope = "keyword, storage", Foreground = DefaultKeyword, FontStyle = "bold" }, + new TextMateTokenThemeRule { Scope = "keyword.control.goto, keyword.control.return, keyword.control.break", Foreground = "#CC99CC" }, + new TextMateTokenThemeRule { Scope = "keyword.control.local", Foreground = DefaultFile, FontStyle = "bold" }, + new TextMateTokenThemeRule { Scope = "entity.name.class, support.class, support.type, support.variable, variable.language.self", Foreground = DefaultType, FontStyle = "bold" }, + new TextMateTokenThemeRule { Scope = "entity.other.attribute, support.type.property-name", Foreground = DefaultProperty }, + new TextMateTokenThemeRule { Scope = "variable.parameter, variable.other.object", Foreground = DefaultVariable }, + new TextMateTokenThemeRule { Scope = "entity.name.function, support.function, support.function.library, support.function.any-method", Foreground = DefaultMethod, FontStyle = "bold" }, + new TextMateTokenThemeRule { Scope = "keyword.operator, punctuation", Foreground = DefaultMisc } + } + }; + } + } +} \ No newline at end of file diff --git a/TombLib/TombLib.Scripting/Highlighting/LuaTextMateSyntaxHighlighting.cs b/TombLib/TombLib.Scripting/Highlighting/LuaTextMateSyntaxHighlighting.cs new file mode 100644 index 0000000000..b866d12e33 --- /dev/null +++ b/TombLib/TombLib.Scripting/Highlighting/LuaTextMateSyntaxHighlighting.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.IO; +using ICSharpCode.AvalonEdit; +using ICSharpCode.AvalonEdit.Highlighting; +using ICSharpCode.AvalonEdit.Highlighting.Xshd; +using TextMateSharp.Grammars; +using TextMateSharp.Model; +using TextMateSharp.Registry; +using TextMateSharp.Themes; +using System.Xml; + +namespace TombLib.Scripting.Highlighting +{ + public static class LuaTextMateSyntaxHighlighting + { + private static readonly Lazy GrammarState = new Lazy(LoadGrammarState); + private static readonly Lazy FallbackHighlightingState = new Lazy(LoadFallbackHighlightingCore); + private static readonly TextMateTokenTheme DefaultTheme = LuaBuiltInTextMateThemeDefaults.CreateDefaultTextMateTheme(); + + public static bool TryInstall(TextEditor editor, out LuaTextMateInstallation installation) + => TryInstall(editor, DefaultTheme, out installation); + + public static bool TryInstall(TextEditor editor, TextMateTokenTheme theme, out LuaTextMateInstallation installation) + { + installation = null; + + if (editor?.Document is null) + return false; + + IGrammar grammar = GrammarState.Value; + + if (grammar is null) + return false; + + var documentLines = new TextMateDocumentLineList(editor.Document); + var model = new TMModel(documentLines); + model.SetGrammar(grammar); + var styleResolver = new TextMateThemeStyleResolver(theme ?? DefaultTheme); + var transformer = new TextMateColorizingTransformer(editor.TextArea.TextView, model, styleResolver); + + editor.TextArea.TextView.LineTransformers.Add(transformer); + installation = new LuaTextMateInstallation(editor, documentLines, model, transformer); + return true; + } + + public static IHighlightingDefinition LoadFallbackHighlighting() + => FallbackHighlightingState.Value; + + private static IGrammar LoadGrammarState() + { + string grammarFilePath = Path.Combine(AppContext.BaseDirectory, "Configs", "TextEditors", "Grammars", "Lua", "lua.tmLanguage.json"); + + if (!File.Exists(grammarFilePath)) + return null; + + var registry = new Registry(new RegistryOptions(ThemeName.DarkPlus)); + return registry.LoadGrammarFromPathSync(grammarFilePath, 0, new Dictionary()); + } + + private static IHighlightingDefinition LoadFallbackHighlightingCore() + { + string fallbackFilePath = Path.Combine(AppContext.BaseDirectory, "Configs", "TextEditors", "ColorSchemes", "Lua", "Default.xml"); + + if (!File.Exists(fallbackFilePath)) + return null; + + using var stream = File.OpenRead(fallbackFilePath); + using var reader = XmlReader.Create(stream); + return HighlightingLoader.Load(reader, HighlightingManager.Instance); + } + + } + + public sealed class LuaTextMateInstallation : IDisposable + { + private readonly TextEditor _editor; + private readonly TextMateDocumentLineList _documentLines; + private readonly TextMateColorizingTransformer _transformer; + private bool _isDisposed; + + internal LuaTextMateInstallation(TextEditor editor, TextMateDocumentLineList documentLines, TMModel model, + TextMateColorizingTransformer transformer) + { + _editor = editor; + _documentLines = documentLines; + _transformer = transformer; + Model = model; + } + + public TMModel Model { get; } + + public void Dispose() + { + if (_isDisposed) + return; + + _isDisposed = true; + + _transformer.Dispose(); + + if (_editor.TextArea.TextView.LineTransformers.Contains(_transformer)) + _editor.TextArea.TextView.LineTransformers.Remove(_transformer); + + Model.Dispose(); + _documentLines.Dispose(); + } + } +} \ No newline at end of file diff --git a/TombLib/TombLib.Scripting/Highlighting/TextMateColorizingTransformer.cs b/TombLib/TombLib.Scripting/Highlighting/TextMateColorizingTransformer.cs new file mode 100644 index 0000000000..6723c424d3 --- /dev/null +++ b/TombLib/TombLib.Scripting/Highlighting/TextMateColorizingTransformer.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.Windows; +using System.Windows.Media; +using ICSharpCode.AvalonEdit.Document; +using ICSharpCode.AvalonEdit.Rendering; +using TextMateSharp.Model; + +namespace TombLib.Scripting.Highlighting +{ + internal sealed class TextMateColorizingTransformer : DocumentColorizingTransformer, IDisposable, IModelTokensChangedListener + { + private readonly TextView _textView; + private readonly TMModel _model; + private readonly TextMateThemeStyleResolver _styleResolver; + private bool _isDisposed; + + public TextMateColorizingTransformer(TextView textView, TMModel model, TextMateThemeStyleResolver styleResolver) + { + _textView = textView ?? throw new ArgumentNullException(nameof(textView)); + _model = model ?? throw new ArgumentNullException(nameof(model)); + _styleResolver = styleResolver ?? throw new ArgumentNullException(nameof(styleResolver)); + + _model.AddModelTokensChangedListener(this); + } + + protected override void ColorizeLine(DocumentLine line) + { + if (_isDisposed || line is null) + return; + + int lineIndex = Math.Max(0, line.LineNumber - 1); + List tokens = _model.GetLineTokens(lineIndex); + + if (tokens is null || _model.IsLineInvalid(lineIndex)) + { + _model.ForceTokenization(lineIndex); + tokens = _model.GetLineTokens(lineIndex); + } + + if (tokens is null || tokens.Count == 0) + return; + + int lineLength = line.Length; + + for (int i = 0; i < tokens.Count; i++) + { + TMToken token = tokens[i]; + int startIndex = ClampToLine(token.StartIndex, lineLength); + int endIndex = i + 1 < tokens.Count + ? ClampToLine(tokens[i + 1].StartIndex, lineLength) + : lineLength; + + if (endIndex <= startIndex) + continue; + + TextMateHighlightingStyle style = _styleResolver.Resolve(token.Scopes); + + if (!style.HasFormatting) + continue; + + int startOffset = line.Offset + startIndex; + int endOffset = line.Offset + endIndex; + + ChangeLinePart(startOffset, endOffset, element => ApplyStyle(element, style)); + } + } + + public void Dispose() + { + if (_isDisposed) + return; + + _isDisposed = true; + _model.RemoveModelTokensChangedListener(this); + } + + void IModelTokensChangedListener.ModelTokensChanged(ModelTokensChangedEvent e) + { + if (_isDisposed) + return; + + // Always defer to avoid reentrancy during visual line construction. + _textView.Dispatcher.BeginInvoke(new Action(() => + { + if (!_isDisposed) + _textView.Redraw(); + })); + } + + private static int ClampToLine(int index, int lineLength) + => Math.Max(0, Math.Min(index, lineLength)); + + private static void ApplyStyle(VisualLineElement element, TextMateHighlightingStyle style) + { + VisualLineElementTextRunProperties properties = element.TextRunProperties; + + if (style.Foreground is not null) + properties.SetForegroundBrush(style.Foreground); + + if (style.IsBold || style.IsItalic) + properties.SetTypeface(style.CreateTypeface(properties.Typeface)); + + if (style.TextDecorations is not null) + properties.SetTextDecorations(style.TextDecorations); + } + } +} \ No newline at end of file diff --git a/TombLib/TombLib.Scripting/Highlighting/TextMateDocumentLineList.cs b/TombLib/TombLib.Scripting/Highlighting/TextMateDocumentLineList.cs new file mode 100644 index 0000000000..01692f4feb --- /dev/null +++ b/TombLib/TombLib.Scripting/Highlighting/TextMateDocumentLineList.cs @@ -0,0 +1,187 @@ +using System; +using System.Collections.Generic; +using ICSharpCode.AvalonEdit.Document; +using TextMateSharp.Grammars; +using TextMateSharp.Model; + +namespace TombLib.Scripting.Highlighting +{ + internal sealed class TextMateDocumentLineList : AbstractLineList + { + private readonly TextDocument _document; + private readonly object _syncRoot = new object(); + private readonly List _lineTexts = new List(); + + public TextMateDocumentLineList(TextDocument document) + { + _document = document ?? throw new ArgumentNullException(nameof(document)); + + InitializeSnapshot(); + + _document.Changed += Document_Changed; + } + + public override void UpdateLine(int lineIndex) + => InvalidateLineRange(lineIndex, Math.Max(lineIndex, GetNumberOfLines() - 1)); + + public override int GetNumberOfLines() + { + lock (_syncRoot) + return _lineTexts.Count; + } + + public override LineText GetLineTextIncludingTerminators(int lineIndex) + { + lock (_syncRoot) + { + if (lineIndex < 0 || lineIndex >= _lineTexts.Count) + return new LineText(string.Empty); + + return new LineText(_lineTexts[lineIndex]); + } + } + + public override int GetLineLength(int lineIndex) + { + lock (_syncRoot) + { + if (lineIndex < 0 || lineIndex >= _lineTexts.Count) + return 0; + + return _lineTexts[lineIndex].Length; + } + } + + public override void Dispose() + => _document.Changed -= Document_Changed; + + private void Document_Changed(object sender, DocumentChangeEventArgs e) + { + (int startLineIndex, int removedLineCount, int insertedLineCount) = GetChangeInfo(_document, e); + int lineDelta = insertedLineCount - removedLineCount; + + lock (_syncRoot) + { + ReplaceSnapshotLines(startLineIndex, removedLineCount, insertedLineCount); + + if (lineDelta > 0) + { + for (int i = 0; i < lineDelta; i++) + AddLine(startLineIndex + 1 + i); + } + else if (lineDelta < 0) + { + for (int i = 0; i < -lineDelta; i++) + { + int removeIndex = Math.Min(startLineIndex + 1, GetNumberOfLines() - 1); + + if (removeIndex >= 0) + RemoveLine(removeIndex); + } + } + + UpdateLine(Math.Min(startLineIndex, Math.Max(0, GetNumberOfLines() - 1))); + } + } + + internal static (int StartLineIndex, int RemovedLineCount, int InsertedLineCount) GetChangeInfo(TextDocument document, DocumentChangeEventArgs change) + { + if (document is null) + throw new ArgumentNullException(nameof(document)); + + if (change is null) + throw new ArgumentNullException(nameof(change)); + + return ( + GetStartLineIndex(document, change.Offset), + GetAffectedLineCount(change.RemovedText?.Text), + GetAffectedLineCount(change.InsertedText?.Text)); + } + + private void InitializeSnapshot() + { + lock (_syncRoot) + { + _lineTexts.Clear(); + + for (int i = 0; i < _document.LineCount; i++) + { + _lineTexts.Add(ReadDocumentLineText(i)); + AddLine(i); + } + } + } + + private void ReplaceSnapshotLines(int startLineIndex, int removedLineCount, int insertedLineCount) + { + int safeStartLineIndex = Math.Max(0, Math.Min(startLineIndex, _lineTexts.Count)); + int removableLineCount = Math.Max(0, Math.Min(removedLineCount, _lineTexts.Count - safeStartLineIndex)); + + if (removableLineCount > 0) + _lineTexts.RemoveRange(safeStartLineIndex, removableLineCount); + + _lineTexts.InsertRange(safeStartLineIndex, ReadDocumentLines(safeStartLineIndex, insertedLineCount)); + } + + private List ReadDocumentLines(int startLineIndex, int lineCount) + { + var lines = new List(); + + if (_document.LineCount == 0) + return lines; + + int safeStartLineIndex = Math.Max(0, Math.Min(startLineIndex, _document.LineCount - 1)); + int safeLineCount = Math.Max(1, Math.Min(lineCount, _document.LineCount - safeStartLineIndex)); + + for (int i = 0; i < safeLineCount; i++) + lines.Add(ReadDocumentLineText(safeStartLineIndex + i)); + + return lines; + } + + private string ReadDocumentLineText(int lineIndex) + { + DocumentLine line = _document.GetLineByNumber(Math.Max(1, lineIndex + 1)); + return _document.GetText(line.Offset, line.TotalLength); + } + + private static int GetStartLineIndex(TextDocument document, int offset) + { + if (document.LineCount == 0) + return 0; + + int safeOffset = Math.Max(0, Math.Min(offset, document.TextLength)); + DocumentLine line = document.GetLineByOffset(safeOffset); + return Math.Max(0, line.LineNumber - 1); + } + + private static int CountLineBreaks(string text) + { + if (string.IsNullOrEmpty(text)) + return 0; + + int count = 0; + + for (int i = 0; i < text.Length; i++) + { + if (text[i] == '\r') + { + count++; + + if (i + 1 < text.Length && text[i + 1] == '\n') + i++; + + continue; + } + + if (text[i] == '\n') + count++; + } + + return count; + } + + private static int GetAffectedLineCount(string text) + => CountLineBreaks(text) + 1; + } +} \ No newline at end of file diff --git a/TombLib/TombLib.Scripting/Highlighting/TextMateHighlightingStyle.cs b/TombLib/TombLib.Scripting/Highlighting/TextMateHighlightingStyle.cs new file mode 100644 index 0000000000..5174d50853 --- /dev/null +++ b/TombLib/TombLib.Scripting/Highlighting/TextMateHighlightingStyle.cs @@ -0,0 +1,34 @@ +using System.Windows; +using System.Windows.Media; + +namespace TombLib.Scripting.Highlighting +{ + internal sealed class TextMateHighlightingStyle + { + public static readonly TextMateHighlightingStyle Empty = new TextMateHighlightingStyle(null, false, false, null); + + public TextMateHighlightingStyle(Brush foreground, bool isBold, bool isItalic, TextDecorationCollection textDecorations) + { + Foreground = foreground; + IsBold = isBold; + IsItalic = isItalic; + TextDecorations = textDecorations; + } + + public Brush Foreground { get; } + public bool IsBold { get; } + public bool IsItalic { get; } + public TextDecorationCollection TextDecorations { get; } + + public bool HasFormatting + => Foreground is not null || IsBold || IsItalic || TextDecorations is not null; + + public Typeface CreateTypeface(Typeface baseTypeface) + { + FontStyle fontStyle = IsItalic ? FontStyles.Italic : baseTypeface.Style; + FontWeight fontWeight = IsBold ? FontWeights.Bold : baseTypeface.Weight; + + return new Typeface(baseTypeface.FontFamily, fontStyle, fontWeight, baseTypeface.Stretch); + } + } +} \ No newline at end of file diff --git a/TombLib/TombLib.Scripting/Highlighting/TextMateThemeStyleResolver.cs b/TombLib/TombLib.Scripting/Highlighting/TextMateThemeStyleResolver.cs new file mode 100644 index 0000000000..d7b79815f4 --- /dev/null +++ b/TombLib/TombLib.Scripting/Highlighting/TextMateThemeStyleResolver.cs @@ -0,0 +1,277 @@ +using System; +using System.Collections.Generic; +using System.Windows; +using System.Windows.Media; +using static TombLib.WPF.BrushHelpers; + +namespace TombLib.Scripting.Highlighting +{ + internal sealed class TextMateThemeStyleResolver + { + private static readonly TextDecorationCollection UnderlineDecorations = CreateTextDecorations(TextDecorations.Underline); + private static readonly TextDecorationCollection StrikethroughDecorations = CreateTextDecorations(TextDecorations.Strikethrough); + + private readonly List _rules; + private readonly Dictionary _cache = new Dictionary(StringComparer.Ordinal); + + public TextMateThemeStyleResolver(TextMateTokenTheme theme) + { + if (theme is null) + throw new ArgumentNullException(nameof(theme)); + + _rules = CreateRules(theme); + } + + public TextMateHighlightingStyle Resolve(IList scopes) + { + if (scopes is null || scopes.Count == 0) + return TextMateHighlightingStyle.Empty; + + string cacheKey = string.Join(" ", scopes); + + if (_cache.TryGetValue(cacheKey, out TextMateHighlightingStyle cachedStyle)) + return cachedStyle; + + Brush foreground = null; + bool? isBold = null; + bool? isItalic = null; + bool? isUnderline = null; + bool? isStrikethrough = null; + + var matches = new List(); + + for (int i = 0; i < _rules.Count; i++) + { + int score = _rules[i].GetMatchScore(scopes); + + if (score >= 0) + matches.Add(new RuleMatch(_rules[i], score)); + } + + matches.Sort((left, right) => right.Score.CompareTo(left.Score)); + + for (int i = 0; i < matches.Count; i++) + { + ParsedThemeRule rule = matches[i].Rule; + + if (foreground is null && rule.Foreground is not null) + foreground = rule.Foreground; + + if (!isBold.HasValue && rule.IsBold.HasValue) + isBold = rule.IsBold.Value; + + if (!isItalic.HasValue && rule.IsItalic.HasValue) + isItalic = rule.IsItalic.Value; + + if (!isUnderline.HasValue && rule.IsUnderline.HasValue) + isUnderline = rule.IsUnderline.Value; + + if (!isStrikethrough.HasValue && rule.IsStrikethrough.HasValue) + isStrikethrough = rule.IsStrikethrough.Value; + + if (foreground is not null + && isBold.HasValue + && isItalic.HasValue + && isUnderline.HasValue + && isStrikethrough.HasValue) + break; + } + + TextDecorationCollection textDecorations = CreateTextDecorations(isUnderline ?? false, isStrikethrough ?? false); + + TextMateHighlightingStyle style = new TextMateHighlightingStyle( + foreground, + isBold ?? false, + isItalic ?? false, + textDecorations); + + _cache[cacheKey] = style; + return style; + } + + private static List CreateRules(TextMateTokenTheme theme) + { + var rules = new List(); + + if (theme.Rules is null) + return rules; + + for (int i = 0; i < theme.Rules.Count; i++) + { + TextMateTokenThemeRule rawRule = theme.Rules[i]; + + if (rawRule is null || string.IsNullOrWhiteSpace(rawRule.Scope)) + continue; + + string[] selectors = rawRule.Scope.Split(','); + + for (int selectorIndex = 0; selectorIndex < selectors.Length; selectorIndex++) + selectors[selectorIndex] = selectors[selectorIndex].Trim(); + + Brush foreground = null; + + if (!string.IsNullOrWhiteSpace(rawRule.Foreground)) + { + try + { + foreground = CreateFrozenBrush(rawRule.Foreground); + } + catch + { + foreground = null; + } + } + + ParseFontStyle(rawRule.FontStyle, out bool? isBold, out bool? isItalic, out bool? isUnderline, out bool? isStrikethrough); + + rules.Add(new ParsedThemeRule(selectors, foreground, isBold, isItalic, isUnderline, isStrikethrough)); + } + + return rules; + } + + private static void ParseFontStyle(string fontStyleValue, out bool? isBold, out bool? isItalic, out bool? isUnderline, out bool? isStrikethrough) + { + isBold = null; + isItalic = null; + isUnderline = null; + isStrikethrough = null; + + if (string.IsNullOrWhiteSpace(fontStyleValue)) + return; + + string[] parts = fontStyleValue.Split(' ', StringSplitOptions.RemoveEmptyEntries); + + for (int i = 0; i < parts.Length; i++) + { + switch (parts[i].Trim()) + { + case "bold": + isBold = true; + break; + + case "italic": + isItalic = true; + break; + + case "underline": + isUnderline = true; + break; + + case "strikethrough": + isStrikethrough = true; + break; + + case "none": + isBold = false; + isItalic = false; + isUnderline = false; + isStrikethrough = false; + break; + } + } + } + + private static TextDecorationCollection CreateTextDecorations(bool isUnderline, bool isStrikethrough) + { + if (isUnderline && isStrikethrough) + { + var decorations = new TextDecorationCollection(); + + foreach (TextDecoration decoration in UnderlineDecorations) + decorations.Add(decoration); + + foreach (TextDecoration decoration in StrikethroughDecorations) + decorations.Add(decoration); + + decorations.Freeze(); + return decorations; + } + + if (isUnderline) + return UnderlineDecorations; + + if (isStrikethrough) + return StrikethroughDecorations; + + return null; + } + + private static TextDecorationCollection CreateTextDecorations(TextDecorationCollection source) + { + var clone = source.Clone(); + clone.Freeze(); + return clone; + } + + private sealed class ParsedThemeRule + { + private readonly string[] _selectors; + + public ParsedThemeRule(string[] selectors, Brush foreground, bool? isBold, bool? isItalic, bool? isUnderline, bool? isStrikethrough) + { + _selectors = selectors; + Foreground = foreground; + IsBold = isBold; + IsItalic = isItalic; + IsUnderline = isUnderline; + IsStrikethrough = isStrikethrough; + } + + public Brush Foreground { get; } + public bool? IsBold { get; } + public bool? IsItalic { get; } + public bool? IsUnderline { get; } + public bool? IsStrikethrough { get; } + + public int GetMatchScore(IList scopes) + { + int bestScore = -1; + + for (int selectorIndex = 0; selectorIndex < _selectors.Length; selectorIndex++) + { + string selector = _selectors[selectorIndex]; + + if (string.IsNullOrWhiteSpace(selector)) + continue; + + for (int scopeIndex = 0; scopeIndex < scopes.Count; scopeIndex++) + { + string scope = scopes[scopeIndex]; + + if (!MatchesScope(selector, scope)) + continue; + + int selectorDepth = selector.Split('.').Length; + int score = (selectorDepth * 1000) + (selector.Length * 10) + scope.Length; + + if (score > bestScore) + bestScore = score; + } + } + + return bestScore; + } + + private static bool MatchesScope(string selector, string scope) + { + if (string.Equals(scope, selector, StringComparison.Ordinal)) + return true; + + return scope.StartsWith(selector + ".", StringComparison.Ordinal); + } + } + + private readonly struct RuleMatch + { + public RuleMatch(ParsedThemeRule rule, int score) + { + Rule = rule; + Score = score; + } + + public ParsedThemeRule Rule { get; } + public int Score { get; } + } + } +} \ No newline at end of file diff --git a/TombLib/TombLib.Scripting/Highlighting/TextMateTokenTheme.cs b/TombLib/TombLib.Scripting/Highlighting/TextMateTokenTheme.cs new file mode 100644 index 0000000000..858617a12b --- /dev/null +++ b/TombLib/TombLib.Scripting/Highlighting/TextMateTokenTheme.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; + +namespace TombLib.Scripting.Highlighting +{ + public sealed class TextMateTokenTheme + { + public List Rules { get; set; } = new List(); + } + + public sealed class TextMateTokenThemeRule + { + public string Scope { get; set; } = string.Empty; + public string Foreground { get; set; } = string.Empty; + public string FontStyle { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/TombLib/TombLib.Scripting/Interfaces/IErrorDetector.cs b/TombLib/TombLib.Scripting/Interfaces/IErrorDetector.cs index 8ef77a6e20..205e221be8 100644 --- a/TombLib/TombLib.Scripting/Interfaces/IErrorDetector.cs +++ b/TombLib/TombLib.Scripting/Interfaces/IErrorDetector.cs @@ -1,9 +1,11 @@ using System; +using System.Collections.Generic; +using TombLib.Scripting.Objects; namespace TombLib.Scripting.Interfaces { public interface IErrorDetector { - object FindErrors(string editorContent, Version engineVersion); + IReadOnlyList FindErrors(string editorContent, Version engineVersion); } } diff --git a/TombLib/TombLib.Scripting/Interfaces/ISupportsFindReplace.cs b/TombLib/TombLib.Scripting/Interfaces/ISupportsFindReplace.cs deleted file mode 100644 index 5d534a877c..0000000000 --- a/TombLib/TombLib.Scripting/Interfaces/ISupportsFindReplace.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace TombLib.Scripting.Interfaces -{ - internal interface ISupportsFindReplace - { - // TODO - } -} diff --git a/TombLib/TombLib.Scripting/Objects/TextEditorDiagnostic.cs b/TombLib/TombLib.Scripting/Objects/TextEditorDiagnostic.cs new file mode 100644 index 0000000000..b1855a6ecb --- /dev/null +++ b/TombLib/TombLib.Scripting/Objects/TextEditorDiagnostic.cs @@ -0,0 +1,26 @@ +using System; + +namespace TombLib.Scripting.Objects +{ + public sealed class TextEditorDiagnostic + { + public TextEditorDiagnostic(TextEditorDiagnosticSeverity severity, string message, int startOffset, int endOffset) + { + Severity = severity; + Message = message ?? throw new ArgumentNullException(nameof(message)); + StartOffset = Math.Max(0, startOffset); + EndOffset = Math.Max(StartOffset + 1, endOffset); + } + + public TextEditorDiagnosticSeverity Severity { get; } + public string Message { get; } + public int StartOffset { get; } + public int EndOffset { get; } + + public bool ContainsOffset(int offset) + => offset >= StartOffset && offset < EndOffset; + + public bool Intersects(int startOffset, int endOffset) + => endOffset > startOffset && EndOffset > startOffset && StartOffset < endOffset; + } +} \ No newline at end of file diff --git a/TombLib/TombLib.Scripting/Objects/TextEditorDiagnosticSeverity.cs b/TombLib/TombLib.Scripting/Objects/TextEditorDiagnosticSeverity.cs new file mode 100644 index 0000000000..653a6e06aa --- /dev/null +++ b/TombLib/TombLib.Scripting/Objects/TextEditorDiagnosticSeverity.cs @@ -0,0 +1,23 @@ +namespace TombLib.Scripting.Objects +{ + public enum TextEditorDiagnosticSeverity + { + Error = 1, + Warning = 2, + Information = 3, + Hint = 4 + } + + public static class TextEditorDiagnosticSeverityExtensions + { + public static string GetLabel(this TextEditorDiagnosticSeverity severity) + => severity switch + { + TextEditorDiagnosticSeverity.Error => "Error", + TextEditorDiagnosticSeverity.Warning => "Warning", + TextEditorDiagnosticSeverity.Information => "Information", + TextEditorDiagnosticSeverity.Hint => "Hint", + _ => "Diagnostic" + }; + } +} \ No newline at end of file diff --git a/TombLib/TombLib.Scripting/Properties/AssemblyInfo.cs b/TombLib/TombLib.Scripting/Properties/AssemblyInfo.cs index 96afce423d..727a7541cb 100644 --- a/TombLib/TombLib.Scripting/Properties/AssemblyInfo.cs +++ b/TombLib/TombLib.Scripting/Properties/AssemblyInfo.cs @@ -1,5 +1,6 @@ using System.Reflection; using System.Runtime.InteropServices; +using System.Runtime.CompilerServices; using System.Runtime.Versioning; // General Information about an assembly is controlled through the following @@ -35,3 +36,4 @@ // [assembly: AssemblyVersion("1.0.*")] [assembly: AssemblyVersion("1.0.0.0")] [assembly: AssemblyFileVersion("1.0.0.0")] +[assembly: InternalsVisibleTo("TombLib.Test")] diff --git a/TombLib/TombLib.Scripting/Rendering/BookmarkRenderer.cs b/TombLib/TombLib.Scripting/Rendering/BookmarkRenderer.cs index fdf78ad5d1..56d239664d 100644 --- a/TombLib/TombLib.Scripting/Rendering/BookmarkRenderer.cs +++ b/TombLib/TombLib.Scripting/Rendering/BookmarkRenderer.cs @@ -3,11 +3,14 @@ using System.Windows; using System.Windows.Media; using TombLib.Scripting.Bases; +using static TombLib.WPF.BrushHelpers; namespace TombLib.Scripting.Rendering { public sealed class BookmarkRenderer : IBackgroundRenderer { + private static readonly Brush BackgroundBrush = CreateFrozenBrush(Color.FromArgb(40, 128, 128, 255)); + private TextEditorBase _editor; #region Construction @@ -23,16 +26,13 @@ public BookmarkRenderer(TextEditorBase e) public void Draw(TextView textView, DrawingContext drawingContext) { - foreach (DocumentLine line in _editor.Document.Lines) - if (line.IsBookmarked) - { - var segment = new TextSegment { StartOffset = line.Offset, EndOffset = line.EndOffset }; - var background = new SolidColorBrush(Color.FromArgb(40, 128, 128, 255)); // Light blue - var border = new Pen(Brushes.Transparent, 0); - - foreach (Rect rect in BackgroundGeometryBuilder.GetRectsForSegment(textView, segment, true)) - drawingContext.DrawRectangle(background, border, new Rect(rect.Location, new Size(textView.ActualWidth, rect.Height))); - } + foreach (DocumentLine line in _editor.GetBookmarkedLines()) + { + var segment = new TextSegment { StartOffset = line.Offset, EndOffset = line.EndOffset }; + + foreach (Rect rect in BackgroundGeometryBuilder.GetRectsForSegment(textView, segment, true)) + drawingContext.DrawRectangle(BackgroundBrush, null, new Rect(rect.Location, new Size(textView.ActualWidth, rect.Height))); + } } #endregion Drawing diff --git a/TombLib/TombLib.Scripting/Rendering/ErrorRenderer.cs b/TombLib/TombLib.Scripting/Rendering/ErrorRenderer.cs index e10f32566c..c16c9a581d 100644 --- a/TombLib/TombLib.Scripting/Rendering/ErrorRenderer.cs +++ b/TombLib/TombLib.Scripting/Rendering/ErrorRenderer.cs @@ -1,13 +1,25 @@ using ICSharpCode.AvalonEdit.Document; using ICSharpCode.AvalonEdit.Rendering; +using System; using System.Windows; using System.Windows.Media; using TombLib.Scripting.Bases; +using TombLib.Scripting.Objects; +using static TombLib.WPF.BrushHelpers; namespace TombLib.Scripting.Rendering { public sealed class ErrorRenderer : IBackgroundRenderer { + private static readonly Brush ErrorBrush = CreateFrozenBrush(Color.FromArgb(224, 220, 76, 60)); + private static readonly Brush WarningBrush = CreateFrozenBrush(Color.FromArgb(224, 226, 165, 44)); + private static readonly Brush InformationBrush = CreateFrozenBrush(Color.FromArgb(224, 88, 170, 255)); + private static readonly Brush HintBrush = CreateFrozenBrush(Color.FromArgb(192, 166, 166, 166)); + private static readonly Pen ErrorPen = CreateFrozenPen(ErrorBrush, 1.4); + private static readonly Pen WarningPen = CreatePen(WarningBrush, new double[] { 1.0, 2.0 }); + private static readonly Pen InformationPen = CreatePen(InformationBrush, new double[] { 2.0, 2.0 }); + private static readonly Pen HintPen = CreatePen(HintBrush, new double[] { 1.0, 3.0 }); + private TextEditorBase _editor; #region Construction @@ -23,35 +35,109 @@ public ErrorRenderer(TextEditorBase e) public void Draw(TextView textView, DrawingContext drawingContext) { - foreach (DocumentLine line in _editor.Document.Lines) + if (!_editor.LiveErrorUnderlining || _editor.Diagnostics.Count == 0) + return; + + foreach (TextEditorDiagnostic diagnostic in _editor.Diagnostics) { - if (!line.HasError) + if (!TryCreateSegment(textView.Document, diagnostic, out TextSegment segment)) continue; - string lineText = _editor.Document.GetText(line.Offset, line.Length); + foreach (Rect rect in BackgroundGeometryBuilder.GetRectsForSegment(textView, segment, false)) + { + if (rect.Width < 2.0) + continue; - int matchIndex = lineText.IndexOf(line.Error.ErrorSegmentText); + switch (diagnostic.Severity) + { + case TextEditorDiagnosticSeverity.Warning: + DrawStraightUnderline(drawingContext, rect, WarningPen); + break; - if (matchIndex == -1) - continue; + case TextEditorDiagnosticSeverity.Information: + DrawStraightUnderline(drawingContext, rect, InformationPen); + break; - var segment = new TextSegment - { - StartOffset = line.Offset + matchIndex, - Length = line.Error.ErrorSegmentText.Length - }; + case TextEditorDiagnosticSeverity.Hint: + DrawStraightUnderline(drawingContext, rect, HintPen); + break; - foreach (Rect rect in BackgroundGeometryBuilder.GetRectsForSegment(textView, segment)) - { - ImageSource underlining = TextRendering.CreateZigZagUnderlining((int)rect.Width, System.Drawing.Color.FromArgb(192, 255, 0, 0)); + default: + DrawErrorUnderline(drawingContext, rect); + break; + } + } + } + } - if (underlining == null) - continue; + private static bool TryCreateSegment(TextDocument document, TextEditorDiagnostic diagnostic, out TextSegment segment) + { + segment = null; + + if (document is null || diagnostic is null || document.TextLength == 0) + return false; + + int startOffset = Math.Max(0, Math.Min(diagnostic.StartOffset, document.TextLength - 1)); + int endOffset = Math.Max(startOffset + 1, Math.Min(diagnostic.EndOffset, document.TextLength)); - drawingContext.DrawImage(underlining, - new Rect(new Point(rect.Location.X, rect.Location.Y + rect.Height - 2), new Size(rect.Width, 4))); + if (endOffset <= startOffset) + return false; + + segment = new TextSegment + { + StartOffset = startOffset, + EndOffset = endOffset + }; + + return true; + } + + private static void DrawErrorUnderline(DrawingContext drawingContext, Rect rect) + { + double baseline = rect.Bottom - 1.0; + double amplitude = 1.6; + double step = 4.0; + + var geometry = new StreamGeometry(); + + using (StreamGeometryContext context = geometry.Open()) + { + bool goingUp = true; + context.BeginFigure(new Point(rect.Left, baseline), false, false); + + for (double x = rect.Left; x < rect.Right; x += step) + { + double nextX = Math.Min(x + step / 2.0, rect.Right); + double y = baseline + (goingUp ? -amplitude : amplitude); + context.LineTo(new Point(nextX, y), true, false); + goingUp = !goingUp; + + nextX = Math.Min(x + step, rect.Right); + context.LineTo(new Point(nextX, baseline), true, false); } } + + geometry.Freeze(); + drawingContext.DrawGeometry(null, ErrorPen, geometry); + } + + private static void DrawStraightUnderline(DrawingContext drawingContext, Rect rect, Pen pen) + { + double y = rect.Bottom - 1.0; + drawingContext.DrawLine(pen, new Point(rect.Left, y), new Point(rect.Right, y)); + } + + private static Pen CreatePen(Brush brush, double[] dashPattern) + { + var pen = new Pen(brush, 1.5) + { + DashStyle = new DashStyle(dashPattern, 0.0), + StartLineCap = PenLineCap.Round, + EndLineCap = PenLineCap.Round + }; + + pen.Freeze(); + return pen; } #endregion Drawing diff --git a/TombLib/TombLib.Scripting/Rendering/MarkdownToolTipRenderer.cs b/TombLib/TombLib.Scripting/Rendering/MarkdownToolTipRenderer.cs new file mode 100644 index 0000000000..1a0b2d0eec --- /dev/null +++ b/TombLib/TombLib.Scripting/Rendering/MarkdownToolTipRenderer.cs @@ -0,0 +1,677 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Documents; +using System.Windows.Input; +using System.Windows.Media; +using System.Xml; +using ICSharpCode.AvalonEdit; +using ICSharpCode.AvalonEdit.Highlighting; +using ICSharpCode.AvalonEdit.Highlighting.Xshd; +using MdXaml; +using TombLib.Scripting.Highlighting; +using TombLib.Scripting.Resources; +using TombLib.WPF; +using static TombLib.WPF.BrushHelpers; + +namespace TombLib.Scripting.Rendering +{ + public static class MarkdownToolTipRenderer + { + private const int MaxVisibleCodeBlockLines = 14; + private const double ToolTipMaxHeight = 420.0; + private const double ToolTipMaxWidth = 540.0; + private const double ToolTipTextMaxWidth = 500.0; + private static readonly Regex FencedCodeBlockPattern = new Regex( + @"(?ms)(^|\n)(?`{3,}|~{3,})[ \t]*(?[^\n]*)\n(?.*?)(?:\n)\k[ \t]*(?=\n|$)", + RegexOptions.Compiled); + private static readonly FontFamily BodyFontFamily = SystemFonts.MessageFontFamily; + private static readonly FontFamily CodeFontFamily = new FontFamily(TextEditorBaseDefaults.FontFamily); + private static readonly double BodyFontSize = Math.Max(SystemFonts.MessageFontSize + 1.0, 14.0); + private static readonly double CodeFontSize = Math.Max(BodyFontSize - 1.0, 13.0); + private static readonly Brush DefaultForeground = TextEditorColorPalette.ToolTipForeground; + private static readonly Brush DefaultBackground = TextEditorColorPalette.ToolTipBackground; + private static readonly Brush DefaultLinkForeground = CreateFrozenBrush(Color.FromRgb(112, 192, 231)); + private static readonly HashSet SupportedHyperlinkSchemes = new HashSet(StringComparer.OrdinalIgnoreCase) + { + Uri.UriSchemeHttp, + Uri.UriSchemeHttps + }; + private static readonly Lazy LuaHighlighting = new Lazy(LoadLuaHighlighting); + + public static FrameworkElement CreateContent(string content, Brush foreground, Brush background, bool allowScrolling = true) + { + string normalizedContent = NormalizeLineEndings(content); + string originalContent = normalizedContent; + + if (string.IsNullOrWhiteSpace(normalizedContent)) + return CreatePlainTextContent(string.Empty, foreground, allowScrolling); + + try + { + List fencedCodeBlocks = ExtractFencedCodeBlocks(ref normalizedContent); + + var markdown = new Markdown + { + DisabledContextMenu = true + }; + + FlowDocument document = markdown.Transform(normalizedContent); + ApplyDocumentTheme(document, foreground); + ReplaceCodeBlocks(document, fencedCodeBlocks, foreground, background, allowScrolling); + ApplyInlineCodeTheme(document, foreground, background); + ApplyHyperlinkTheme(document); + + var viewer = new FlowDocumentScrollViewer + { + Document = document, + Background = Brushes.Transparent, + BorderThickness = new Thickness(0.0), + Padding = new Thickness(0.0), + Margin = new Thickness(0.0), + IsToolBarVisible = false, + VerticalScrollBarVisibility = allowScrolling + ? ScrollBarVisibility.Auto + : ScrollBarVisibility.Hidden, + HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, + HorizontalAlignment = HorizontalAlignment.Left, + IsSelectionEnabled = false, + Focusable = false, + MaxHeight = ToolTipMaxHeight, + MaxWidth = ToolTipMaxWidth + }; + + if (allowScrolling) + viewer.PreviewMouseWheel += ScrollHost_PreviewMouseWheel; + + viewer.PreviewMouseLeftButtonUp += HyperlinkHost_PreviewMouseLeftButtonUp; + return viewer; + } + catch (Exception exception) + { + Debug.WriteLine($"[MarkdownToolTipRenderer] Failed to render markdown tooltip: {exception}"); + return CreatePlainTextContent(originalContent, foreground, allowScrolling); + } + } + + public static FrameworkElement CreatePlainTextContent(string content, Brush foreground, bool allowScrolling = true) + => CreateFallbackContent(content, foreground, allowScrolling); + + private static void ApplyDocumentTheme(FlowDocument document, Brush foreground) + { + document.FontFamily = BodyFontFamily; + document.FontSize = BodyFontSize; + document.Foreground = foreground ?? DefaultForeground; + document.Background = Brushes.Transparent; + document.PagePadding = new Thickness(0.0); + document.ColumnWidth = ToolTipTextMaxWidth; + } + + private static void ApplyInlineCodeTheme(FlowDocument document, Brush foreground, Brush background) + { + Brush codeBackground = CreateCodeBackground(background); + Brush codeBorder = CreateCodeBorder(background); + var codeSpanElements = new List(); + + foreach (TextElement element in EnumerateTextElements(document)) + { + if (!string.Equals(element.Tag as string, "CodeSpan", StringComparison.Ordinal) + || element is not Inline inline) + continue; + + codeSpanElements.Add(inline); + } + + foreach (Inline inline in codeSpanElements) + { + string inlineText = NormalizeLineEndings(ExtractInlineText(inline)).TrimEnd('\n'); + + ReplaceInline(inline, CreateInlineCodeContainer(inlineText, foreground, codeBackground, codeBorder)); + } + } + + private static string ExtractInlineText(Inline inline) + { + if (inline is null) + return string.Empty; + + switch (inline) + { + case Run run: + return run.Text ?? string.Empty; + + case LineBreak: + return Environment.NewLine; + + case Span span: + var builder = new StringBuilder(); + + foreach (Inline childInline in span.Inlines) + builder.Append(ExtractInlineText(childInline)); + + return builder.ToString(); + + default: + return new TextRange(inline.ContentStart, inline.ContentEnd).Text; + } + } + + private static Inline CreateInlineCodeContainer(string text, Brush foreground, Brush background, Brush borderBrush) + => new InlineUIContainer( + new Border + { + Background = background, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1.0), + CornerRadius = new CornerRadius(2.0), + Padding = new Thickness(4.0, 1.0, 4.0, 1.0), + Child = new TextBlock + { + Text = text ?? string.Empty, + Foreground = foreground ?? DefaultForeground, + FontFamily = CodeFontFamily, + FontSize = CodeFontSize, + TextWrapping = TextWrapping.NoWrap + } + }) + { + BaselineAlignment = BaselineAlignment.Center + }; + + private static void ReplaceInline(Inline source, Inline replacement) + { + switch (source?.Parent) + { + case Paragraph paragraph: + paragraph.Inlines.InsertBefore(source, replacement); + paragraph.Inlines.Remove(source); + break; + + case Span span: + span.Inlines.InsertBefore(source, replacement); + span.Inlines.Remove(source); + break; + } + } + + private static void ApplyHyperlinkTheme(FlowDocument document) + { + foreach (TextElement element in EnumerateTextElements(document)) + { + if (element is not Hyperlink hyperlink) + continue; + + bool canOpen = IsSupportedHyperlink(hyperlink.NavigateUri); + + if (!canOpen) + hyperlink.NavigateUri = null; + + hyperlink.Foreground = DefaultLinkForeground; + hyperlink.TextDecorations = TextDecorations.Underline; + hyperlink.Cursor = canOpen ? Cursors.Hand : Cursors.Arrow; + hyperlink.Focusable = false; + } + } + + private static List ExtractFencedCodeBlocks(ref string content) + { + var codeBlocks = new List(); + int index = 0; + + content = FencedCodeBlockPattern.Replace(content, match => + { + string placeholder = $"__TOMBIDE_MD_CODE_BLOCK_{index++}__"; + codeBlocks.Add(new CodeBlockInfo(placeholder, match.Groups["lang"].Value.Trim(), match.Groups["code"].Value)); + return match.Groups[1].Value + placeholder; + }); + + return codeBlocks; + } + + private static void ReplaceCodeBlocks(FlowDocument document, IReadOnlyList fencedCodeBlocks, Brush foreground, Brush background, bool allowScrolling) + { + var codeBlockLookup = new Dictionary(StringComparer.Ordinal); + + foreach (CodeBlockInfo codeBlock in fencedCodeBlocks) + codeBlockLookup[codeBlock.Placeholder] = codeBlock; + + ReplaceCodeBlocks(document.Blocks, codeBlockLookup, foreground, background, allowScrolling); + } + + private static void ReplaceCodeBlocks(BlockCollection blocks, IReadOnlyDictionary fencedCodeBlocks, Brush foreground, Brush background, bool allowScrolling) + { + Block currentBlock = blocks.FirstBlock; + + while (currentBlock is not null) + { + Block nextBlock = currentBlock.NextBlock; + + if (TryCreateReplacementBlock(currentBlock, fencedCodeBlocks, foreground, background, allowScrolling, out Block? replacementBlock) + && replacementBlock is not null) + { + blocks.InsertBefore(currentBlock, replacementBlock); + blocks.Remove(currentBlock); + } + else + { + switch (currentBlock) + { + case Section section: + ReplaceCodeBlocks(section.Blocks, fencedCodeBlocks, foreground, background, allowScrolling); + break; + + case List list: + foreach (ListItem item in list.ListItems) + ReplaceCodeBlocks(item.Blocks, fencedCodeBlocks, foreground, background, allowScrolling); + break; + + case Table table: + foreach (TableRowGroup rowGroup in table.RowGroups) + foreach (TableRow row in rowGroup.Rows) + foreach (TableCell cell in row.Cells) + ReplaceCodeBlocks(cell.Blocks, fencedCodeBlocks, foreground, background, allowScrolling); + break; + } + } + + currentBlock = nextBlock; + } + } + + private static bool TryCreateReplacementBlock(Block block, IReadOnlyDictionary fencedCodeBlocks, Brush foreground, Brush background, bool allowScrolling, out Block? replacementBlock) + { + replacementBlock = null; + + if (block is not Paragraph paragraph) + return false; + + string rawText = NormalizeLineEndings(new TextRange(paragraph.ContentStart, paragraph.ContentEnd).Text); + string normalizedText = rawText.Trim(); + + if (fencedCodeBlocks.TryGetValue(normalizedText, out CodeBlockInfo? fencedCodeBlock) + && fencedCodeBlock is not null) + { + replacementBlock = new BlockUIContainer(CreateCodeBlockElement(fencedCodeBlock.Language, fencedCodeBlock.Code, foreground, background, allowScrolling)); + return true; + } + + if (!string.Equals(paragraph.Tag as string, "CodeBlock", StringComparison.Ordinal)) + return false; + + replacementBlock = new BlockUIContainer(CreateCodeBlockElement(null, rawText.TrimEnd('\n'), foreground, background, allowScrolling)); + return true; + } + + private static FrameworkElement CreateCodeBlockElement(string? language, string code, Brush foreground, Brush background, bool allowScrolling) + { + TextEditor editor = CreateCodeBlockEditor(language, code, foreground ?? DefaultForeground); + string normalizedCode = editor.Text; + + double lineHeight = GetEditorLineHeight(editor); + double maxVisibleHeight = Math.Max(lineHeight + 4.0, Math.Ceiling(MaxVisibleCodeBlockLines * lineHeight) + 2.0); + double desiredHeight = Math.Max(lineHeight + 4.0, MeasureWrappedCodeHeight(normalizedCode, ToolTipTextMaxWidth, lineHeight)); + + editor.Height = Math.Min(desiredHeight, maxVisibleHeight); + editor.VerticalScrollBarVisibility = allowScrolling && desiredHeight > maxVisibleHeight + ? ScrollBarVisibility.Auto + : ScrollBarVisibility.Hidden; + + if (allowScrolling) + editor.PreviewMouseWheel += ScrollHost_PreviewMouseWheel; + + return new Border + { + Background = CreateCodeBackground(background), + BorderBrush = CreateCodeBorder(background), + BorderThickness = new Thickness(1.0), + CornerRadius = new CornerRadius(3.0), + Padding = new Thickness(8.0, 6.0, 8.0, 6.0), + Margin = new Thickness(0.0, 4.0, 0.0, 6.0), + MaxWidth = ToolTipTextMaxWidth, + Child = editor + }; + } + + internal static TextEditor CreateCodeBlockEditor(string? language, string code, Brush foreground) + { + string normalizedCode = NormalizeCodeBlockText(code); + + var editor = new TextEditor + { + Text = normalizedCode, + IsReadOnly = true, + Background = Brushes.Transparent, + Foreground = foreground ?? DefaultForeground, + BorderThickness = new Thickness(0.0), + Margin = new Thickness(0.0), + Padding = new Thickness(0.0), + Width = ToolTipTextMaxWidth, + MaxWidth = ToolTipTextMaxWidth, + HorizontalAlignment = HorizontalAlignment.Stretch, + HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, + FontFamily = CodeFontFamily, + FontSize = CodeFontSize, + ShowLineNumbers = false, + WordWrap = true, + Focusable = false, + IsTabStop = false + }; + + editor.Options.AllowScrollBelowDocument = false; + editor.Options.EnableHyperlinks = false; + editor.Options.EnableEmailHyperlinks = false; + editor.Options.HighlightCurrentLine = false; + editor.Options.ShowBoxForControlCharacters = false; + editor.TextArea.Margin = new Thickness(0.0); + editor.TextArea.Focusable = false; + editor.TextArea.IsTabStop = false; + KeyboardNavigation.SetIsTabStop(editor, false); + KeyboardNavigation.SetIsTabStop(editor.TextArea, false); + + // Tooltip code-block editors are intentionally passive: they reuse TextMate highlighting when available, + // but do not own any unload-driven disposal hook because tooltip hosts can unload and reuse the same editor. + if (!string.Equals(language?.Trim(), "lua", StringComparison.OrdinalIgnoreCase) + || !LuaTextMateSyntaxHighlighting.TryInstall(editor, out _)) + { + editor.SyntaxHighlighting = ResolveHighlighting(language); + } + + return editor; + } + + private static double GetEditorLineHeight(TextEditor editor) + { + double lineHeight = editor.TextArea.TextView.DefaultLineHeight; + + if (!double.IsNaN(lineHeight) && lineHeight > 0.0) + return Math.Ceiling(lineHeight); + + var typeface = new Typeface(editor.FontFamily, editor.FontStyle, editor.FontWeight, editor.FontStretch); + var formattedText = new FormattedText( + "Ag", + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + editor.FontSize, + Brushes.Transparent, + 1.0); + + return Math.Ceiling(Math.Max(1.0, formattedText.Height)); + } + + private static double MeasureWrappedCodeHeight(string code, double width, double lineHeight) + { + var textBlock = new TextBlock + { + Text = string.IsNullOrEmpty(code) ? " " : code, + FontFamily = CodeFontFamily, + FontSize = CodeFontSize, + TextWrapping = TextWrapping.Wrap, + MaxWidth = width + }; + + textBlock.Measure(new Size(width, double.PositiveInfinity)); + return Math.Max(Math.Ceiling(textBlock.DesiredSize.Height) + 2.0, Math.Ceiling(lineHeight) + 4.0); + } + + private static string NormalizeCodeBlockText(string code) + { + string normalizedCode = NormalizeLineEndings(code); + + if (string.IsNullOrEmpty(normalizedCode)) + return string.Empty; + + string[] lines = normalizedCode.Split('\n'); + int lastContentLineIndex = lines.Length - 1; + + while (lastContentLineIndex >= 0 && string.IsNullOrWhiteSpace(lines[lastContentLineIndex])) + lastContentLineIndex--; + + if (lastContentLineIndex < 0) + return string.Empty; + + return string.Join(Environment.NewLine, lines, 0, lastContentLineIndex + 1); + } + + private static IEnumerable EnumerateTextElements(FlowDocument document) + { + foreach (Block block in document.Blocks) + foreach (TextElement element in EnumerateBlock(block)) + yield return element; + } + + private static IEnumerable EnumerateBlock(Block block) + { + yield return block; + + switch (block) + { + case Paragraph paragraph: + foreach (Inline inline in paragraph.Inlines) + foreach (TextElement element in EnumerateInline(inline)) + yield return element; + break; + + case Section section: + foreach (Block childBlock in section.Blocks) + foreach (TextElement element in EnumerateBlock(childBlock)) + yield return element; + break; + + case List list: + foreach (ListItem item in list.ListItems) + { + yield return item; + foreach (Block childBlock in item.Blocks) + foreach (TextElement element in EnumerateBlock(childBlock)) + yield return element; + } + break; + + case Table table: + foreach (TableRowGroup rowGroup in table.RowGroups) + { + yield return rowGroup; + foreach (TableRow row in rowGroup.Rows) + { + yield return row; + foreach (TableCell cell in row.Cells) + { + yield return cell; + foreach (Block childBlock in cell.Blocks) + foreach (TextElement element in EnumerateBlock(childBlock)) + yield return element; + } + } + } + break; + } + } + + private static IEnumerable EnumerateInline(Inline inline) + { + yield return inline; + + if (inline is Span span) + foreach (Inline childInline in span.Inlines) + foreach (TextElement element in EnumerateInline(childInline)) + yield return element; + } + + private static FrameworkElement CreateFallbackContent(string content, Brush foreground, bool allowScrolling) + { + var textBlock = new TextBlock + { + Foreground = foreground ?? DefaultForeground, + Text = content ?? string.Empty, + TextWrapping = TextWrapping.Wrap, + FontFamily = BodyFontFamily, + FontSize = BodyFontSize, + MaxWidth = ToolTipTextMaxWidth + }; + + var scrollViewer = new ScrollViewer + { + Content = textBlock, + MaxHeight = ToolTipMaxHeight, + MaxWidth = ToolTipMaxWidth, + VerticalScrollBarVisibility = allowScrolling + ? ScrollBarVisibility.Auto + : ScrollBarVisibility.Hidden, + HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, + CanContentScroll = true + }; + + if (allowScrolling) + { + scrollViewer.PreviewMouseWheel += ScrollHost_PreviewMouseWheel; + } + + return scrollViewer; + } + + private static void HyperlinkHost_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + Hyperlink? hyperlink = (e.OriginalSource as DependencyObject)?.FindAncestorOrSelf(); + + if (hyperlink is not null && TryOpenHyperlink(hyperlink.NavigateUri)) + e.Handled = true; + } + + private static bool TryOpenHyperlink(Uri? uri) + { + if (uri is null || !IsSupportedHyperlink(uri)) + return false; + + try + { + Process.Start(new ProcessStartInfo(uri.AbsoluteUri) { UseShellExecute = true }); + return true; + } + catch + { + return false; + } + } + + private static bool IsSupportedHyperlink(Uri? uri) + => uri is not null && uri.IsAbsoluteUri && SupportedHyperlinkSchemes.Contains(uri.Scheme); + + private static void ScrollHost_PreviewMouseWheel(object sender, MouseWheelEventArgs e) + { + ScrollViewer? scrollViewer = sender as ScrollViewer ?? (sender as DependencyObject)?.FindVisualDescendant(); + + if (scrollViewer is null || scrollViewer.ScrollableHeight <= 0.0) + return; + + if (e.Delta > 0) + scrollViewer.LineUp(); + else if (e.Delta < 0) + scrollViewer.LineDown(); + + e.Handled = true; + } + + private static string NormalizeLineEndings(string text) + => (text ?? string.Empty) + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n'); + + private static IHighlightingDefinition? ResolveHighlighting(string? language) + { + if (string.IsNullOrWhiteSpace(language)) + return null; + + string normalizedLanguage = language.Trim().ToLowerInvariant(); + + if (normalizedLanguage == "lua") + return LuaHighlighting.Value; + + IHighlightingDefinition? definition = HighlightingManager.Instance.GetDefinition(normalizedLanguage); + + if (definition is not null) + return definition; + + definition = HighlightingManager.Instance.GetDefinitionByExtension(normalizedLanguage.StartsWith(".", StringComparison.Ordinal) + ? normalizedLanguage + : "." + normalizedLanguage); + + if (definition is not null) + return definition; + + return normalizedLanguage switch + { + "cs" => HighlightingManager.Instance.GetDefinitionByExtension(".cs"), + "csharp" => HighlightingManager.Instance.GetDefinitionByExtension(".cs"), + "js" => HighlightingManager.Instance.GetDefinitionByExtension(".js"), + "ts" => HighlightingManager.Instance.GetDefinitionByExtension(".ts"), + "json5" => HighlightingManager.Instance.GetDefinitionByExtension(".json"), + _ => null + }; + } + + private static IHighlightingDefinition? LoadLuaHighlighting() + { + string xmlFilePath = Path.Combine(AppContext.BaseDirectory, "Configs", "TextEditors", "ColorSchemes", "Lua", "Default.xml"); + + if (!File.Exists(xmlFilePath)) + return null; + + using var stream = new FileStream(xmlFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new XmlTextReader(stream); + return HighlightingLoader.Load(reader, HighlightingManager.Instance); + } + + private static Brush CreateCodeBackground(Brush background) + { + Color baseColor = background is SolidColorBrush solidBrush + ? solidBrush.Color + : ((SolidColorBrush)DefaultBackground).Color; + + return CreateFrozenBrush(Blend(baseColor, Colors.Black, 0.32)); + } + + private static Brush CreateCodeBorder(Brush background) + { + Color baseColor = background is SolidColorBrush solidBrush + ? solidBrush.Color + : ((SolidColorBrush)DefaultBackground).Color; + + return CreateFrozenBrush(Blend(baseColor, Colors.White, 0.18)); + } + + private static Color Blend(Color first, Color second, double ratio) + { + double clampedRatio = Math.Max(0.0, Math.Min(1.0, ratio)); + double inverseRatio = 1.0 - clampedRatio; + + return Color.FromArgb( + (byte)Math.Round(first.A * inverseRatio + second.A * clampedRatio), + (byte)Math.Round(first.R * inverseRatio + second.R * clampedRatio), + (byte)Math.Round(first.G * inverseRatio + second.G * clampedRatio), + (byte)Math.Round(first.B * inverseRatio + second.B * clampedRatio)); + } + + private sealed class CodeBlockInfo + { + public CodeBlockInfo(string placeholder, string language, string code) + { + Placeholder = placeholder; + Language = language; + Code = code; + } + + public string Placeholder { get; } + public string Language { get; } + public string Code { get; } + } + } +} \ No newline at end of file diff --git a/TombLib/TombLib.Scripting/Rendering/TextRendering.cs b/TombLib/TombLib.Scripting/Rendering/TextRendering.cs deleted file mode 100644 index 2c085f10e2..0000000000 --- a/TombLib/TombLib.Scripting/Rendering/TextRendering.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Drawing; -using System.Drawing.Drawing2D; -using System.Windows; -using System.Windows.Interop; -using System.Windows.Media.Imaging; - -namespace TombLib.Scripting.Rendering -{ - public static class TextRendering - { - public static System.Windows.Media.ImageSource CreateZigZagUnderlining(int textWidth, Color color) - { - if (textWidth < 3) - return null; - - List zigZagPoints = GenerateZigZagPatternPoints(textWidth); - - var bitmap = new Bitmap(textWidth, 4); - DrawPatternLines(bitmap, zigZagPoints, color); - - IntPtr handle = bitmap.GetHbitmap(); - - try { return Imaging.CreateBitmapSourceFromHBitmap(handle, IntPtr.Zero, Int32Rect.Empty, BitmapSizeOptions.FromEmptyOptions()); } - finally { NativeMethods.DeleteObject(handle); } - } - - private static List GenerateZigZagPatternPoints(int textWidth) - { - var zigZagPoints = new List(); - - bool toggle = false; - - for (int i = 0; i <= textWidth; i += 3) - { - float x = i; - float y = toggle ? 0 : 3; - - zigZagPoints.Add(new PointF(x, y)); - toggle = !toggle; - } - - return zigZagPoints; - } - - private static void DrawPatternLines(Bitmap bitmap, List points, Color color) - { - using (var graphics = System.Drawing.Graphics.FromImage(bitmap)) - { - graphics.SmoothingMode = SmoothingMode.HighSpeed; - graphics.DrawLines(new Pen(color, 2), points.ToArray()); - } - } - } -} diff --git a/TombLib/TombLib.Scripting/Resources/TextEditorColorPalette.cs b/TombLib/TombLib.Scripting/Resources/TextEditorColorPalette.cs new file mode 100644 index 0000000000..b526c0e092 --- /dev/null +++ b/TombLib/TombLib.Scripting/Resources/TextEditorColorPalette.cs @@ -0,0 +1,21 @@ +using System.Windows.Media; +using static TombLib.WPF.BrushHelpers; + +namespace TombLib.Scripting.Resources +{ + public static class TextEditorColorPalette + { + public static readonly SolidColorBrush ToolTipBorder = CreateFrozenBrush(Color.FromRgb(96, 96, 96)); + public static readonly SolidColorBrush ToolTipBackground = CreateFrozenBrush(Color.FromRgb(64, 64, 64)); + public static readonly SolidColorBrush ToolTipForeground = CreateFrozenBrush(Colors.Gainsboro); + + public static readonly SolidColorBrush ErrorToolTipBorder = CreateFrozenBrush(Color.FromRgb(128, 86, 86)); + public static readonly SolidColorBrush ErrorToolTipBackground = CreateFrozenBrush(Color.FromRgb(78, 44, 44)); + public static readonly SolidColorBrush WarningToolTipBorder = CreateFrozenBrush(Color.FromRgb(145, 122, 62)); + public static readonly SolidColorBrush WarningToolTipBackground = CreateFrozenBrush(Color.FromRgb(86, 69, 30)); + public static readonly SolidColorBrush InformationToolTipBorder = CreateFrozenBrush(Color.FromRgb(80, 118, 168)); + public static readonly SolidColorBrush InformationToolTipBackground = CreateFrozenBrush(Color.FromRgb(46, 68, 104)); + public static readonly SolidColorBrush HintToolTipBorder = CreateFrozenBrush(Color.FromRgb(108, 108, 108)); + public static readonly SolidColorBrush HintToolTipBackground = CreateFrozenBrush(Color.FromRgb(58, 58, 58)); + } +} \ No newline at end of file diff --git a/TombLib/TombLib.Scripting/Services/CompletionWindowHost.cs b/TombLib/TombLib.Scripting/Services/CompletionWindowHost.cs new file mode 100644 index 0000000000..246f85cea5 --- /dev/null +++ b/TombLib/TombLib.Scripting/Services/CompletionWindowHost.cs @@ -0,0 +1,93 @@ +#nullable enable + +using ICSharpCode.AvalonEdit.CodeCompletion; +using ICSharpCode.AvalonEdit.Editing; +using System; +using System.Windows; +using System.Windows.Media; + +namespace TombLib.Scripting.Services; + +internal sealed class CompletionWindowHost +{ + private readonly TextArea _textArea; + private EventHandler? _closedHandler; + private Action? _trackedClosedAction; + private CompletionWindow? _trackedWindow; + + public CompletionWindowHost(TextArea textArea) + => _textArea = textArea; + + public CompletionWindow Create(double width, double height, Brush borderBrush, Brush background, Brush foreground) + { + CloseTrackedWindow(); + + return new(_textArea) + { + WindowStyle = WindowStyle.None, + ResizeMode = ResizeMode.NoResize, + BorderThickness = new Thickness(1.0), + Background = background, + Foreground = foreground, + BorderBrush = borderBrush, + Width = width, + Height = height + }; + } + + public void Show(CompletionWindow completionWindow, Action onClosed) + { + TrackWindow(completionWindow, onClosed); + + completionWindow.Show(); + } + + public void Close(CompletionWindow? completionWindow, Action onClosed) + { + if (completionWindow is null) + return; + + UntrackWindow(completionWindow); + completionWindow.Close(); + onClosed(); + } + + private void TrackWindow(CompletionWindow completionWindow, Action onClosed) + { + UntrackWindow(_trackedWindow); + + _trackedWindow = completionWindow; + _trackedClosedAction = onClosed; + _closedHandler = (sender, e) => + { + UntrackWindow(completionWindow); + onClosed(); + }; + + completionWindow.Closed += _closedHandler; + } + + private void UntrackWindow(CompletionWindow? completionWindow) + { + if (completionWindow is null || _closedHandler is null || !ReferenceEquals(completionWindow, _trackedWindow)) + return; + + completionWindow.Closed -= _closedHandler; + _closedHandler = null; + _trackedClosedAction = null; + _trackedWindow = null; + } + + private void CloseTrackedWindow() + { + if (_trackedWindow is null) + return; + + CompletionWindow trackedWindow = _trackedWindow; + Action? onClosed = _trackedClosedAction; + + UntrackWindow(trackedWindow); + trackedWindow.Close(); + onClosed?.Invoke(); + } +} diff --git a/TombLib/TombLib.Scripting/Services/EditorToolTipPresenter.cs b/TombLib/TombLib.Scripting/Services/EditorToolTipPresenter.cs new file mode 100644 index 0000000000..7ec7eb3dba --- /dev/null +++ b/TombLib/TombLib.Scripting/Services/EditorToolTipPresenter.cs @@ -0,0 +1,116 @@ +#nullable enable + +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Threading; + +namespace TombLib.Scripting.Services; + +internal sealed class EditorToolTipPresenter +{ + private static readonly TimeSpan CloseDelay = TimeSpan.FromMilliseconds(900.0); + + private readonly FrameworkElement _owner; + private readonly DispatcherTimer _closeTimer = new(); + private bool _contentHovered; + + public EditorToolTipPresenter(FrameworkElement owner) + { + _owner = owner; + + Popup = new Popup + { + AllowsTransparency = true, + PopupAnimation = PopupAnimation.None, + StaysOpen = true, + Placement = PlacementMode.RelativePoint + }; + + Border = new Border + { + SnapsToDevicePixels = true, + CornerRadius = new CornerRadius(3.0), + BorderThickness = new Thickness(1.0), + Padding = new Thickness(8.0, 6.0, 8.0, 6.0) + }; + + ContentPresenter = new ContentPresenter(); + Border.Child = ContentPresenter; + Border.MouseEnter += Border_MouseEnter; + Border.MouseLeave += Border_MouseLeave; + + Popup.Child = Border; + + _closeTimer.Interval = CloseDelay; + _closeTimer.Tick += CloseTimer_Tick; + } + + public Popup Popup { get; } + + public Border Border { get; } + + public ContentPresenter ContentPresenter { get; } + + public void Show(object content, Brush borderBrush, Brush background) + { + _closeTimer.Stop(); + _contentHovered = false; + Popup.PlacementTarget = _owner; + + Point mousePosition = Mouse.GetPosition(_owner); + Popup.HorizontalOffset = mousePosition.X + 14.0; + Popup.VerticalOffset = mousePosition.Y + 20.0; + + Border.BorderBrush = borderBrush; + Border.Background = background; + ContentPresenter.Content = content; + Popup.IsOpen = true; + } + + public void Close(bool force = false) + { + _closeTimer.Stop(); + + if (!force && (_contentHovered || Border.IsMouseOver)) + return; + + if (Popup.IsOpen) + Popup.IsOpen = false; + + ContentPresenter.Content = null; + _contentHovered = false; + } + + public void ScheduleClose() + { + if (!Popup.IsOpen) + return; + + _closeTimer.Stop(); + _closeTimer.Start(); + } + + private void CloseTimer_Tick(object? sender, EventArgs e) + { + _closeTimer.Stop(); + + if (!_contentHovered && !Border.IsMouseOver) + Close(true); + } + + private void Border_MouseEnter(object sender, MouseEventArgs e) + { + _contentHovered = true; + _closeTimer.Stop(); + } + + private void Border_MouseLeave(object sender, MouseEventArgs e) + { + _contentHovered = false; + ScheduleClose(); + } +} diff --git a/TombLib/TombLib.Scripting/TombLib.Scripting.csproj b/TombLib/TombLib.Scripting/TombLib.Scripting.csproj index 50f9d1c001..a38e061219 100644 --- a/TombLib/TombLib.Scripting/TombLib.Scripting.csproj +++ b/TombLib/TombLib.Scripting/TombLib.Scripting.csproj @@ -1,33 +1,14 @@  - net6.0-windows - Library false true true - true - Debug;Release - x64;x86 - - - ..\..\Build ($(Platform))\ - - - ..\..\BuildRelease ($(Platform))\ - none - true - - - x64 - - - x86 - - False - ..\..\Libs\ICSharpCode.AvalonEdit.dll - + + + + @@ -46,5 +27,6 @@ + \ No newline at end of file diff --git a/TombLib/TombLib.Scripting/Utils/EditorCompletionTriggerHelper.cs b/TombLib/TombLib.Scripting/Utils/EditorCompletionTriggerHelper.cs new file mode 100644 index 0000000000..ba918ab349 --- /dev/null +++ b/TombLib/TombLib.Scripting/Utils/EditorCompletionTriggerHelper.cs @@ -0,0 +1,18 @@ +#nullable enable + +using System; +using System.Windows.Input; + +namespace TombLib.Scripting.Utils; + +public static class EditorCompletionTriggerHelper +{ + public static bool IsCtrlSpaceInput(string? inputText, ModifierKeys modifiers) + => inputText == " " && modifiers.HasFlag(ModifierKeys.Control); + + public static bool IsSingleCharacterLine(string? currentLineText) + => currentLineText?.Length == 1; + + public static bool IsSingleCharacterLine(string? currentLineText, Func characterPredicate) + => currentLineText?.Length == 1 && characterPredicate(currentLineText[0]); +} diff --git a/TombLib/TombLib.Scripting/Workers/ErrorDetectionWorker.cs b/TombLib/TombLib.Scripting/Workers/ErrorDetectionWorker.cs index 3e7bfc3944..0ad6d7f3cc 100644 --- a/TombLib/TombLib.Scripting/Workers/ErrorDetectionWorker.cs +++ b/TombLib/TombLib.Scripting/Workers/ErrorDetectionWorker.cs @@ -1,8 +1,10 @@ -using System; -using System.Collections.Generic; +#nullable enable + +using System; using System.ComponentModel; using System.Windows.Threading; using TombLib.Scripting.Interfaces; +using TombLib.Scripting.Objects; namespace TombLib.Scripting.Workers { @@ -10,7 +12,7 @@ public class ErrorDetectionWorker : BackgroundWorker { #region Properties - public IErrorDetector ErrorDetector { get; set; } + public IErrorDetector? ErrorDetector { get; set; } public TimeSpan IdleDelayInterval { @@ -24,7 +26,7 @@ public TimeSpan IdleDelayInterval #region Fields - private DispatcherTimer _errorUpdateTimer = new(); + private readonly DispatcherTimer _errorUpdateTimer = new(); private string _editorContent = string.Empty; @@ -34,9 +36,9 @@ public TimeSpan IdleDelayInterval public ErrorDetectionWorker() : this(null, new Version(0, 0)) { } - public ErrorDetectionWorker(IErrorDetector errorDetector, Version engineVersion) : this(errorDetector, engineVersion, new TimeSpan(500)) + public ErrorDetectionWorker(IErrorDetector? errorDetector, Version engineVersion) : this(errorDetector, engineVersion, new TimeSpan(500)) { } - public ErrorDetectionWorker(IErrorDetector errorDetector, Version engineVersion, TimeSpan idleDelayInterval) + public ErrorDetectionWorker(IErrorDetector? errorDetector, Version engineVersion, TimeSpan idleDelayInterval) { ErrorDetector = errorDetector; IdleDelayInterval = idleDelayInterval; @@ -53,9 +55,15 @@ protected override void OnDoWork(DoWorkEventArgs e) { base.OnDoWork(e); - var errorDetector = (e.Argument as List)[0] as IErrorDetector; - string editorContent = (e.Argument as List)[1].ToString(); + IErrorDetector? errorDetector = ErrorDetector; + + if (errorDetector is null) + { + e.Result = Array.Empty(); + return; + } + string editorContent = e.Argument as string ?? string.Empty; e.Result = errorDetector.FindErrors(editorContent, EngineVersion); } @@ -63,34 +71,28 @@ protected override void OnDoWork(DoWorkEventArgs e) #region Public methods - public void RunErrorCheckOnIdle(string editorContent) + public void RunErrorCheckOnIdle(string? editorContent) { if (_errorUpdateTimer.IsEnabled) _errorUpdateTimer.Stop(); - _editorContent = editorContent; + _editorContent = editorContent ?? string.Empty; _errorUpdateTimer.Start(); } - public void CheckForErrorsAsync(string editorContent) + public void CheckForErrorsAsync(string? editorContent) { - if (ErrorDetector == null) + if (ErrorDetector is null) return; - var args = new List - { - ErrorDetector, - editorContent - }; - - base.RunWorkerAsync(args); + base.RunWorkerAsync(editorContent ?? string.Empty); } #endregion Public methods #region Events - private void ErrorUpdateTimer_Tick(object sender, EventArgs e) + private void ErrorUpdateTimer_Tick(object? sender, EventArgs e) { if (!IsBusy) CheckForErrorsAsync(_editorContent); diff --git a/TombLib/TombLib.Test/ClassicScriptEditorCompletionWindowTests.cs b/TombLib/TombLib.Test/ClassicScriptEditorCompletionWindowTests.cs new file mode 100644 index 0000000000..73a45fa03a --- /dev/null +++ b/TombLib/TombLib.Test/ClassicScriptEditorCompletionWindowTests.cs @@ -0,0 +1,40 @@ +using ICSharpCode.AvalonEdit.CodeCompletion; +using System; +using System.Windows; +using System.Windows.Threading; +using TombLib.Scripting.ClassicScript; + +namespace TombLib.Test; + +[TestClass] +public class ClassicScriptEditorCompletionWindowTests +{ + [TestMethod] + public void HandleAutocompleteOnEmptyLine_OpensCompletionWindowAtLineOffset() + { + WpfTestHelper.RunInSta(() => + { + var editor = new ClassicScriptEditor(new Version(1, 0)) + { + Text = string.Empty + }; + Window hostWindow = WpfTestHelper.ShowInHostWindow(editor); + + try + { + WpfTestHelper.InvokeInstanceMethod(editor, "HandleAutocompleteOnEmptyLine", Type.EmptyTypes); + WpfTestHelper.PumpDispatcher(editor.Dispatcher, DispatcherPriority.Background); + + CompletionWindow completionWindow = WpfTestHelper.GetPrivateField(editor, "_completionWindow"); + + Assert.IsTrue(completionWindow.CompletionList.CompletionData.Count > 0); + Assert.AreEqual(0, completionWindow.StartOffset); + } + finally + { + WpfTestHelper.GetPrivateField(editor, "_completionWindow").Close(); + hostWindow.Close(); + } + }); + } +} diff --git a/TombLib/TombLib.Test/EditorCompletionTriggerHelperTests.cs b/TombLib/TombLib.Test/EditorCompletionTriggerHelperTests.cs new file mode 100644 index 0000000000..7376534b67 --- /dev/null +++ b/TombLib/TombLib.Test/EditorCompletionTriggerHelperTests.cs @@ -0,0 +1,29 @@ +using System.Windows.Input; +using TombLib.Scripting.Utils; + +namespace TombLib.Test; + +[TestClass] +public class EditorCompletionTriggerHelperTests +{ + [TestMethod] + public void IsCtrlSpaceInput_ReturnsTrueOnlyForCtrlSpace() + { + Assert.IsTrue(EditorCompletionTriggerHelper.IsCtrlSpaceInput(" ", ModifierKeys.Control)); + Assert.IsTrue(EditorCompletionTriggerHelper.IsCtrlSpaceInput(" ", ModifierKeys.Control | ModifierKeys.Shift)); + Assert.IsFalse(EditorCompletionTriggerHelper.IsCtrlSpaceInput("a", ModifierKeys.Control)); + Assert.IsFalse(EditorCompletionTriggerHelper.IsCtrlSpaceInput(" ", ModifierKeys.None)); + } + + [TestMethod] + public void IsSingleCharacterLine_HandlesGeneralAndPredicateChecks() + { + Assert.IsTrue(EditorCompletionTriggerHelper.IsSingleCharacterLine("a")); + Assert.IsFalse(EditorCompletionTriggerHelper.IsSingleCharacterLine(string.Empty)); + Assert.IsFalse(EditorCompletionTriggerHelper.IsSingleCharacterLine("ab")); + Assert.IsFalse(EditorCompletionTriggerHelper.IsSingleCharacterLine(null)); + + Assert.IsTrue(EditorCompletionTriggerHelper.IsSingleCharacterLine("a", char.IsLetter)); + Assert.IsFalse(EditorCompletionTriggerHelper.IsSingleCharacterLine("1", char.IsLetter)); + } +} diff --git a/TombLib/TombLib.Test/GameFlowEditorCompletionWindowTests.cs b/TombLib/TombLib.Test/GameFlowEditorCompletionWindowTests.cs new file mode 100644 index 0000000000..4aa5ce5847 --- /dev/null +++ b/TombLib/TombLib.Test/GameFlowEditorCompletionWindowTests.cs @@ -0,0 +1,115 @@ +using ICSharpCode.AvalonEdit.CodeCompletion; +using ICSharpCode.AvalonEdit.Document; +using System.Collections.Generic; +using System.Windows; +using System.Windows.Threading; +using TombLib.Scripting.GameFlowScript; +using TombLib.Scripting.Objects; + +namespace TombLib.Test; + +[TestClass] +public class GameFlowEditorCompletionWindowTests +{ + [TestMethod] + public void ShowCompletionWindow_ClearsFieldWhenWindowCloses() + { + WpfTestHelper.RunInSta(() => + { + var editor = new GameFlowEditor(new Version(1, 0)); + Window hostWindow = WpfTestHelper.ShowInHostWindow(editor); + + try + { + editor.InitializeCompletionWindow(); + editor.ShowCompletionWindow(); + WpfTestHelper.PumpDispatcher(editor.Dispatcher, DispatcherPriority.Background); + + CompletionWindow completionWindow = WpfTestHelper.GetPrivateField(editor, "_completionWindow"); + completionWindow.Close(); + WpfTestHelper.PumpDispatcher(editor.Dispatcher, DispatcherPriority.Background); + + Assert.IsNull(WpfTestHelper.FindInstanceField(editor.GetType(), "_completionWindow")?.GetValue(editor)); + } + finally + { + hostWindow.Close(); + } + }); + } + + [TestMethod] + public void InitializeCompletionWindow_ReplacesVisibleWindowInsteadOfLeavingItOpen() + { + WpfTestHelper.RunInSta(() => + { + var editor = new GameFlowEditor(new Version(1, 0)); + Window hostWindow = WpfTestHelper.ShowInHostWindow(editor); + + try + { + editor.InitializeCompletionWindow(); + editor.ShowCompletionWindow(); + WpfTestHelper.PumpDispatcher(editor.Dispatcher, DispatcherPriority.Background); + + CompletionWindow firstWindow = WpfTestHelper.GetPrivateField(editor, "_completionWindow"); + + editor.InitializeCompletionWindow(); + editor.ShowCompletionWindow(); + WpfTestHelper.PumpDispatcher(editor.Dispatcher, DispatcherPriority.Background); + + CompletionWindow secondWindow = WpfTestHelper.GetPrivateField(editor, "_completionWindow"); + + Assert.AreNotSame(firstWindow, secondWindow); + Assert.IsFalse(firstWindow.IsVisible); + Assert.IsTrue(secondWindow.IsVisible); + } + finally + { + WpfTestHelper.GetPrivateField(editor, "_completionWindow").Close(); + hostWindow.Close(); + } + }); + } + + [TestMethod] + public void TryOpenCompletionWindow_PopulatesItemsAndOffsets() + { + WpfTestHelper.RunInSta(() => + { + var editor = new GameFlowEditor(new Version(1, 0)) + { + Text = "test" + }; + Window hostWindow = WpfTestHelper.ShowInHostWindow(editor); + + try + { + bool opened = (bool)(WpfTestHelper.InvokeInstanceMethod( + editor, + "TryOpenCompletionWindow", + [typeof(IEnumerable), typeof(int?), typeof(int?), typeof(int), typeof(int)], + new List { new CompletionData("Level") }, + 1, + 3, + 300, + 300) + ?? throw new InvalidOperationException("Instance method 'TryOpenCompletionWindow' returned null.")); + + WpfTestHelper.PumpDispatcher(editor.Dispatcher, DispatcherPriority.Background); + + CompletionWindow completionWindow = WpfTestHelper.GetPrivateField(editor, "_completionWindow"); + + Assert.IsTrue(opened); + Assert.AreEqual(1, completionWindow.CompletionList.CompletionData.Count); + Assert.AreEqual(1, completionWindow.StartOffset); + Assert.AreEqual(3, completionWindow.EndOffset); + } + finally + { + WpfTestHelper.GetPrivateField(editor, "_completionWindow").Close(); + hostWindow.Close(); + } + }); + } +} diff --git a/TombLib/TombLib.Test/GlobalUsings.LuaLanguageServer.cs b/TombLib/TombLib.Test/GlobalUsings.LuaLanguageServer.cs new file mode 100644 index 0000000000..144beb6b29 --- /dev/null +++ b/TombLib/TombLib.Test/GlobalUsings.LuaLanguageServer.cs @@ -0,0 +1 @@ +global using TombLib.Scripting.Lua.LanguageServer; \ No newline at end of file diff --git a/TombLib/TombLib.Test/LuaCompletionDataTests.cs b/TombLib/TombLib.Test/LuaCompletionDataTests.cs new file mode 100644 index 0000000000..51709af244 --- /dev/null +++ b/TombLib/TombLib.Test/LuaCompletionDataTests.cs @@ -0,0 +1,74 @@ +using ICSharpCode.AvalonEdit.Document; +using System.Reflection; +using TombLib.Scripting.Lua.Objects; + +namespace TombLib.Test; + +[TestClass] +public class LuaCompletionDataTests +{ + [TestMethod] + public void ResolveCompletionSegment_UsesInsertRangeByDefault() + { + (int offset, int length) = InvokeResolveCompletionSegment( + new TextDocument("abcdef"), + fallbackOffset: 1, + fallbackLength: 2, + new LuaCompletionTextEdit( + new LuaCompletionRange(new LuaCompletionPosition(0, 2), new LuaCompletionPosition(0, 3)), + new LuaCompletionRange(new LuaCompletionPosition(0, 2), new LuaCompletionPosition(0, 5))), + useReplaceRange: false); + + Assert.AreEqual(2, offset); + Assert.AreEqual(1, length); + } + + [TestMethod] + public void ResolveCompletionSegment_UsesReplaceRangeWhenRequested() + { + (int offset, int length) = InvokeResolveCompletionSegment( + new TextDocument("abcdef"), + fallbackOffset: 1, + fallbackLength: 2, + new LuaCompletionTextEdit( + new LuaCompletionRange(new LuaCompletionPosition(0, 2), new LuaCompletionPosition(0, 3)), + new LuaCompletionRange(new LuaCompletionPosition(0, 2), new LuaCompletionPosition(0, 5))), + useReplaceRange: true); + + Assert.AreEqual(2, offset); + Assert.AreEqual(3, length); + } + + [TestMethod] + public void ResolveCompletionSegment_FallsBackWhenTextEditRangeIsInvalid() + { + (int offset, int length) = InvokeResolveCompletionSegment( + new TextDocument("abc"), + fallbackOffset: 1, + fallbackLength: 2, + new LuaCompletionTextEdit( + new LuaCompletionRange(new LuaCompletionPosition(4, 0), new LuaCompletionPosition(4, 1))), + useReplaceRange: false); + + Assert.AreEqual(1, offset); + Assert.AreEqual(2, length); + } + + private static (int Offset, int Length) InvokeResolveCompletionSegment(TextDocument document, + int fallbackOffset, + int fallbackLength, + LuaCompletionTextEdit? textEdit, + bool useReplaceRange) + { + MethodInfo method = typeof(LuaCompletionData).GetMethod( + "ResolveCompletionSegment", + BindingFlags.NonPublic | BindingFlags.Static, + binder: null, + [typeof(TextDocument), typeof(int), typeof(int), typeof(LuaCompletionTextEdit?), typeof(bool)], + modifiers: null) + ?? throw new InvalidOperationException("Private static method 'ResolveCompletionSegment' was not found."); + + return ((int Offset, int Length))(method.Invoke(null, [document, fallbackOffset, fallbackLength, textEdit, useReplaceRange]) + ?? throw new InvalidOperationException("Private static method 'ResolveCompletionSegment' returned null.")); + } +} \ No newline at end of file diff --git a/TombLib/TombLib.Test/LuaCompletionItemTests.cs b/TombLib/TombLib.Test/LuaCompletionItemTests.cs new file mode 100644 index 0000000000..80509ee937 --- /dev/null +++ b/TombLib/TombLib.Test/LuaCompletionItemTests.cs @@ -0,0 +1,50 @@ +using TombLib.Scripting.Lua.Objects; + +namespace TombLib.Test; + +[TestClass] +public class LuaCompletionItemTests +{ + [TestMethod] + public async Task WithRequestContext_PreservesRequestMetadataAcrossResolve() + { + var item = new LuaCompletionItem( + "spawn", + insertText: "spawn", + resolveAsync: _ => Task.FromResult(new LuaCompletionItem("spawn", detail: "function", insertCaretOffset: 2)), + insertCaretOffset: 2) + .WithRequestContext(4, 7); + + LuaCompletionItem resolvedItem = await item.ResolveAsync(); + + Assert.AreEqual(4, resolvedItem.RequestDocumentVersion); + Assert.AreEqual(7, resolvedItem.RequestGeneration); + Assert.AreEqual("function", resolvedItem.Detail); + Assert.AreEqual(2, resolvedItem.InsertCaretOffset); + } + + [TestMethod] + public async Task WithFilteredCommitContext_DropsTextEditAndPreservesResolveMetadata() + { + LuaCompletionTextEdit textEdit = new( + new LuaCompletionRange(new LuaCompletionPosition(0, 2), new LuaCompletionPosition(0, 5))); + + var item = new LuaCompletionItem( + "Color", + insertText: "Color", + textEdit: textEdit, + resolveAsync: _ => Task.FromResult(new LuaCompletionItem("Color", detail: "enum", textEdit: textEdit))) + .WithFilteredCommitContext(6, 2); + + Assert.AreEqual(6, item.RequestDocumentVersion); + Assert.AreEqual(2, item.RequestGeneration); + Assert.IsNull(item.TextEdit); + + LuaCompletionItem resolvedItem = await item.ResolveAsync(); + + Assert.AreEqual("enum", resolvedItem.Detail); + Assert.IsNull(resolvedItem.TextEdit); + Assert.AreEqual(6, resolvedItem.RequestDocumentVersion); + Assert.AreEqual(2, resolvedItem.RequestGeneration); + } +} \ No newline at end of file diff --git a/TombLib/TombLib.Test/LuaEditorCompletionWindowTests.cs b/TombLib/TombLib.Test/LuaEditorCompletionWindowTests.cs new file mode 100644 index 0000000000..5ed548f227 --- /dev/null +++ b/TombLib/TombLib.Test/LuaEditorCompletionWindowTests.cs @@ -0,0 +1,392 @@ +using ICSharpCode.AvalonEdit.CodeCompletion; +using System.Reflection; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Threading; +using TombLib.Scripting.Lua; +using TombLib.Scripting.Lua.Objects; +using TombLib.Scripting.Lua.Services; +using TombLib.Scripting.Objects; +using static TombLib.Test.WpfTestHelper; + +namespace TombLib.Test; + +[TestClass] +public class LuaEditorCompletionWindowTests +{ + [TestMethod] + public void RequestCompletionAsync_OpensCompletionWindowWithCurrentItemsAndOffsets() + { + RunInSta(() => + { + var provider = new FakeLuaCompletionProvider(); + provider.EnqueueCompletionResponse( + [ + new LuaCompletionItem("spawn_room", detail: "local variable") + ]); + + var editor = CreateEditor(provider, "spa"); + Window hostWindow = ShowInHostWindow(editor); + + try + { + InvokePrivateTask(editor, "RequestCompletionAsync", [typeof(int), typeof(char?)], 3, null).GetAwaiter().GetResult(); + PumpDispatcher(editor.Dispatcher, DispatcherPriority.ContextIdle); + + CompletionWindow completionWindow = GetPrivateField(editor, "_completionWindow"); + + Assert.AreEqual(1, completionWindow.CompletionList.CompletionData.Count); + Assert.AreEqual(0, completionWindow.StartOffset); + Assert.AreEqual(3, completionWindow.EndOffset); + Assert.IsNotNull(completionWindow.CompletionList.ListBox.SelectedItem); + Assert.AreEqual(1, provider.CompletionRequests.Count); + Assert.AreEqual(0, provider.CompletionRequests[0].Line); + Assert.AreEqual(3, provider.CompletionRequests[0].Column); + } + finally + { + CloseCompletionWindow(editor); + hostWindow.Close(); + } + }); + } + + [TestMethod] + public void RequestCompletionAsync_RefreshClosesPreviousTooltipAndRecreatesWindow() + { + RunInSta(() => + { + var provider = new FakeLuaCompletionProvider(); + provider.EnqueueCompletionResponse( + [ + new LuaCompletionItem("spawn_room", detail: "local variable") + ]); + provider.EnqueueCompletionResponse( + [ + new LuaCompletionItem("spell_room", detail: "global variable") + ]); + + var editor = CreateEditor(provider, "spa"); + Window hostWindow = ShowInHostWindow(editor); + + try + { + InvokePrivateTask(editor, "RequestCompletionAsync", [typeof(int), typeof(char?)], 3, null).GetAwaiter().GetResult(); + PumpDispatcher(editor.Dispatcher, DispatcherPriority.ContextIdle); + + CompletionWindow firstWindow = GetPrivateField(editor, "_completionWindow"); + ToolTip firstToolTip = GetCompletionToolTip(firstWindow); + firstToolTip.Content = new TextBlock { Text = "old tooltip" }; + firstToolTip.IsOpen = true; + + editor.Text = "spe"; + editor.CaretOffset = 3; + + InvokePrivateTask(editor, "RequestCompletionAsync", [typeof(int), typeof(char?)], 3, null).GetAwaiter().GetResult(); + PumpDispatcher(editor.Dispatcher, DispatcherPriority.ContextIdle); + + CompletionWindow refreshedWindow = GetPrivateField(editor, "_completionWindow"); + var refreshedItem = (LuaCompletionData)refreshedWindow.CompletionList.CompletionData[0]; + + Assert.AreNotSame(firstWindow, refreshedWindow); + Assert.IsFalse(firstToolTip.IsOpen); + Assert.AreEqual("spell_room", refreshedItem.DisplayText); + } + finally + { + CloseCompletionWindow(editor); + hostWindow.Close(); + } + }); + } + + [TestMethod] + public void RequestCompletionAsync_EmptyResults_CloseExistingWindow() + { + RunInSta(() => + { + var provider = new FakeLuaCompletionProvider(); + provider.EnqueueCompletionResponse( + [ + new LuaCompletionItem("spawn_room", detail: "local variable") + ]); + provider.EnqueueCompletionResponse([]); + + var editor = CreateEditor(provider, "spa"); + Window hostWindow = ShowInHostWindow(editor); + + try + { + InvokePrivateTask(editor, "RequestCompletionAsync", [typeof(int), typeof(char?)], 3, null).GetAwaiter().GetResult(); + PumpDispatcher(editor.Dispatcher, DispatcherPriority.ContextIdle); + + InvokePrivateTask(editor, "RequestCompletionAsync", [typeof(int), typeof(char?)], 3, null).GetAwaiter().GetResult(); + PumpDispatcher(editor.Dispatcher, DispatcherPriority.ContextIdle); + + FieldInfo completionWindowField = FindInstanceField(editor.GetType(), "_completionWindow") + ?? throw new InvalidOperationException("Private field '_completionWindow' was not found."); + + Assert.IsNull(completionWindowField.GetValue(editor)); + } + finally + { + CloseCompletionWindow(editor); + hostWindow.Close(); + } + }); + } + + [TestMethod] + public void RequestCompletionAsync_DismissesSignatureHelpBeforeOpeningWindow() + { + RunInSta(() => + { + var provider = new FakeLuaCompletionProvider(); + provider.EnqueueCompletionResponse( + [ + new LuaCompletionItem("spawn_room", detail: "local variable") + ]); + + var editor = CreateEditor(provider, "spa"); + Window hostWindow = ShowInHostWindow(editor); + + try + { + Popup signaturePopup = GetSignatureHelpField(editor, "_signaturePopup"); + ContentPresenter signaturePresenter = GetSignatureHelpField(editor, "_signaturePopupPresenter"); + + signaturePresenter.Content = new TextBlock { Text = "signature" }; + signaturePopup.IsOpen = true; + SetSignatureHelpField(editor, "_signatureRequestToken", 4); + + InvokePrivateTask(editor, "RequestCompletionAsync", [typeof(int), typeof(char?)], 3, null).GetAwaiter().GetResult(); + PumpDispatcher(editor.Dispatcher, DispatcherPriority.ContextIdle); + + Assert.IsFalse(signaturePopup.IsOpen); + Assert.IsNull(signaturePresenter.Content); + Assert.AreEqual(5, GetSignatureHelpField(editor, "_signatureRequestToken")); + Assert.IsNotNull(GetPrivateField(editor, "_completionWindow")); + } + finally + { + CloseCompletionWindow(editor); + hostWindow.Close(); + } + }); + } + + [TestMethod] + public void UpdateCompletionTooltipAsync_ResolvesSelectedCompletionItem() + { + RunInSta(() => + { + int resolveCallCount = 0; + var provider = new FakeLuaCompletionProvider(); + provider.EnqueueCompletionResponse( + [ + new LuaCompletionItem( + "spawn_room", + resolveAsync: _ => + { + resolveCallCount++; + return Task.FromResult(new LuaCompletionItem("spawn_room", detail: "resolved detail")); + }) + ]); + + var editor = CreateEditor(provider, "spa"); + Window hostWindow = ShowInHostWindow(editor); + + try + { + InvokePrivateTask(editor, "RequestCompletionAsync", [typeof(int), typeof(char?)], 3, null).GetAwaiter().GetResult(); + PumpDispatcher(editor.Dispatcher, DispatcherPriority.ContextIdle); + + CompletionWindow completionWindow = GetPrivateField(editor, "_completionWindow"); + ToolTip toolTip = GetCompletionToolTip(completionWindow); + completionWindow.CompletionList.ListBox.SelectedItem = completionWindow.CompletionList.CompletionData[0]; + + int updateToken = GetPrivateField(GetCompletionController(editor), "_completionToolTipUpdateToken"); + InvokeControllerTask(editor, "_completionController", "UpdateTooltipAsync", [typeof(ToolTip), typeof(int)], toolTip, updateToken).GetAwaiter().GetResult(); + + Assert.AreEqual(1, resolveCallCount); + Assert.IsTrue(toolTip.IsOpen); + Assert.IsInstanceOfType(toolTip.Content, typeof(Border)); + + var contentBorder = (Border)toolTip.Content!; + var contentPanel = (StackPanel)(contentBorder.Child + ?? throw new AssertFailedException("Expected tooltip content panel.")); + var detailBlock = (TextBlock)contentPanel.Children[0]; + + Assert.AreEqual("resolved detail", detailBlock.Text); + } + finally + { + CloseCompletionWindow(editor); + hostWindow.Close(); + } + }); + } + + [TestMethod] + public void UpdateCompletionTooltipAsync_HidesTooltipWhenSelectionIsCleared() + { + RunInSta(() => + { + var provider = new FakeLuaCompletionProvider(); + provider.EnqueueCompletionResponse( + [ + new LuaCompletionItem("spawn_room", detail: "local variable") + ]); + + var editor = CreateEditor(provider, "spa"); + Window hostWindow = ShowInHostWindow(editor); + + try + { + InvokePrivateTask(editor, "RequestCompletionAsync", [typeof(int), typeof(char?)], 3, null).GetAwaiter().GetResult(); + PumpDispatcher(editor.Dispatcher, DispatcherPriority.ContextIdle); + + CompletionWindow completionWindow = GetPrivateField(editor, "_completionWindow"); + ToolTip toolTip = GetCompletionToolTip(completionWindow); + toolTip.Content = new TextBlock { Text = "stale tooltip" }; + toolTip.IsOpen = true; + completionWindow.CompletionList.ListBox.SelectedItem = null; + + int updateToken = GetPrivateField(GetCompletionController(editor), "_completionToolTipUpdateToken"); + InvokeControllerTask(editor, "_completionController", "UpdateTooltipAsync", [typeof(ToolTip), typeof(int)], toolTip, updateToken).GetAwaiter().GetResult(); + + Assert.IsFalse(toolTip.IsOpen); + } + finally + { + CloseCompletionWindow(editor); + hostWindow.Close(); + } + }); + } + + private static LuaEditor CreateEditor(ILuaIntellisenseProvider provider, string text) + => new(new Version(1, 0)) + { + FilePath = @"C:\Workspace\Scripts\test.lua", + Text = text, + IntellisenseProvider = provider + }; + + private static Task InvokePrivateTask(object instance, string methodName, Type[] parameterTypes, params object?[] arguments) + => (Task)(InvokeInstanceMethod(instance, methodName, parameterTypes, arguments) + ?? throw new InvalidOperationException($"Private instance method '{methodName}' returned null.")); + + private static Task InvokeControllerTask(LuaEditor editor, string controllerFieldName, string methodName, Type[] parameterTypes, params object?[] arguments) + => (Task)(InvokeInstanceMethod(GetPrivateField(editor, controllerFieldName), methodName, parameterTypes, arguments) + ?? throw new InvalidOperationException($"Controller method '{methodName}' returned null.")); + + private static void CloseCompletionWindow(LuaEditor editor) + => InvokeInstanceMethod(editor, "CloseCompletionWindow", Type.EmptyTypes); + + private static object GetCompletionController(LuaEditor editor) + => GetPrivateField(editor, "_completionController"); + + private static T GetSignatureHelpField(LuaEditor editor, string fieldName) + => GetPrivateField(GetPrivateField(editor, "_signatureHelpController"), fieldName); + + private static void SetSignatureHelpField(LuaEditor editor, string fieldName, object value) + => SetPrivateField(GetPrivateField(editor, "_signatureHelpController"), fieldName, value); + + private static ToolTip GetCompletionToolTip(CompletionWindow completionWindow) + { + FieldInfo field = typeof(CompletionWindow).GetField("toolTip", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("CompletionWindow private field 'toolTip' was not found."); + + return (ToolTip)(field.GetValue(completionWindow) + ?? throw new InvalidOperationException("CompletionWindow private field 'toolTip' returned null.")); + } + + private readonly record struct CompletionRequest(string FilePath, string Content, int Line, int Column, char? TriggerCharacter); + + private sealed class FakeLuaCompletionProvider : ILuaIntellisenseProvider + { + private readonly Queue> _completionResponses = []; + + public bool IsAvailable { get; set; } = true; + public bool SupportsReferences => false; + public bool SupportsRename => false; + public bool SupportsFormatting => false; + + public List CompletionRequests { get; } = []; + + public event Action>? DiagnosticsUpdated + { + add { } + remove { } + } + + public event Action>? SemanticTokensUpdated + { + add { } + remove { } + } + + public void EnqueueCompletionResponse(IReadOnlyList items) + => _completionResponses.Enqueue(items); + + public IReadOnlyList GetDiagnostics(string filePath) + => []; + + public IReadOnlyList GetSemanticTokens(string filePath) + => []; + + public void OpenDocument(string filePath, string content) + { + } + + public void UpdateDocument(string filePath, string content) + { + } + + public void CloseDocument(string filePath) + { + } + + public void RenameDocument(string oldFilePath, string newFilePath, string content) + { + } + + public Task> GetCompletionItemsAsync(string filePath, string content, + int line, int column, char? triggerCharacter = null, CancellationToken cancellationToken = default) + { + CompletionRequests.Add(new CompletionRequest(filePath, content, line, column, triggerCharacter)); + IReadOnlyList response = _completionResponses.Count > 0 ? _completionResponses.Dequeue() : []; + return Task.FromResult(response); + } + + public Task GetHoverAsync(string filePath, string content, + int line, int column, CancellationToken cancellationToken = default) + => Task.FromResult(null); + + public Task GetDefinitionAsync(string filePath, string content, + int line, int column, CancellationToken cancellationToken = default) + => Task.FromResult(null); + + public Task> GetReferencesAsync(string filePath, string content, + int line, int column, CancellationToken cancellationToken = default) + => Task.FromResult>([]); + + public Task RenameSymbolAsync(string filePath, string content, + int line, int column, string newName, CancellationToken cancellationToken = default) + => Task.FromResult(null); + + public Task> FormatDocumentAsync(string filePath, string content, + LuaFormattingOptions options, CancellationToken cancellationToken = default) + => Task.FromResult>([]); + + public Task GetSignatureHelpAsync(string filePath, string content, + int line, int column, CancellationToken cancellationToken = default) + => Task.FromResult(null); + + public void Dispose() + { + } + } +} \ No newline at end of file diff --git a/TombLib/TombLib.Test/LuaEditorIntellisenseStateTests.cs b/TombLib/TombLib.Test/LuaEditorIntellisenseStateTests.cs new file mode 100644 index 0000000000..9792a83b62 --- /dev/null +++ b/TombLib/TombLib.Test/LuaEditorIntellisenseStateTests.cs @@ -0,0 +1,961 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Threading; +using TombLib.Scripting.Lua; +using TombLib.Scripting.Lua.Objects; +using TombLib.Scripting.Lua.Services; +using TombLib.Scripting.Objects; +using static TombLib.Test.WpfTestHelper; + +namespace TombLib.Test; + +[TestClass] +public class LuaEditorIntellisenseStateTests +{ + [TestMethod] + public void ShouldRefreshSignatureHelpAfterTextInput_ReturnsTrueWhenSignatureHelpIsActiveOrPending() + { + bool shouldRefresh = InvokePrivateStaticBooleanMethod( + "ShouldRefreshSignatureHelpAfterTextInput", + [typeof(string), typeof(bool)], + "a", + true); + + Assert.IsTrue(shouldRefresh); + } + + [TestMethod] + public void ShouldRefreshSignatureHelpAfterTextInput_ReturnsFalseWhenSignatureHelpIsInactive() + { + bool shouldRefresh = InvokePrivateStaticBooleanMethod( + "ShouldRefreshSignatureHelpAfterTextInput", + [typeof(string), typeof(bool)], + "a", + false); + + Assert.IsFalse(shouldRefresh); + } + + [TestMethod] + public void ShouldDismissSignatureHelpOnAutoClosingSkip_ReturnsTrueOnlyForMatchingParenthesis() + { + bool shouldDismissMatchingParenthesis = InvokePrivateStaticBooleanMethod( + "ShouldDismissSignatureHelpOnAutoClosingSkip", + [typeof(string), typeof(string)], + ")", + ")"); + + bool shouldDismissOtherElement = InvokePrivateStaticBooleanMethod( + "ShouldDismissSignatureHelpOnAutoClosingSkip", + [typeof(string), typeof(string)], + "]", + ")"); + + Assert.IsTrue(shouldDismissMatchingParenthesis); + Assert.IsFalse(shouldDismissOtherElement); + } + + [TestMethod] + public void ScheduleSignatureHelpRefresh_StoresCaretOffsetAndStartsTimer() + { + RunInSta(() => + { + var editor = new LuaEditor(new Version(1, 0)) + { + Text = "spawn(room)" + }; + + editor.CaretOffset = 6; + InvokeControllerInstanceMethod(editor, "_signatureHelpController", "ScheduleRefresh"); + + Assert.IsTrue(GetSignatureHelpField(editor, "_signatureRefreshPending")); + Assert.AreEqual(6, GetSignatureHelpField(editor, "_pendingSignatureHelpOffset")); + Assert.IsTrue(GetSignatureHelpField(editor, "_signatureRefreshTimer").IsEnabled); + }); + } + + [TestMethod] + public void CancelPendingSignatureHelpRefresh_ClearsPendingStateAndStopsTimer() + { + RunInSta(() => + { + var editor = new LuaEditor(new Version(1, 0)) + { + Text = "spawn(room)" + }; + + editor.CaretOffset = 6; + InvokeControllerInstanceMethod(editor, "_signatureHelpController", "ScheduleRefresh"); + InvokeControllerInstanceMethod(editor, "_signatureHelpController", "CancelPendingRefresh"); + + Assert.IsFalse(GetSignatureHelpField(editor, "_signatureRefreshPending")); + Assert.AreEqual(-1, GetSignatureHelpField(editor, "_pendingSignatureHelpOffset")); + Assert.IsFalse(GetSignatureHelpField(editor, "_signatureRefreshTimer").IsEnabled); + }); + } + + [TestMethod] + public void DismissSignatureHelp_ClearsPendingStateAndInvalidatesOutstandingRequests() + { + RunInSta(() => + { + var editor = new LuaEditor(new Version(1, 0)); + ContentPresenter presenter = GetSignatureHelpField(editor, "_signaturePopupPresenter"); + Popup popup = GetSignatureHelpField(editor, "_signaturePopup"); + + presenter.Content = new TextBlock { Text = "signature" }; + SetSignatureHelpField(editor, "_signatureRequestToken", 5); + SetSignatureHelpField(editor, "_signatureRequestInFlight", true); + SetSignatureHelpField(editor, "_signatureRefreshPending", true); + SetSignatureHelpField(editor, "_pendingSignatureHelpOffset", 9); + + InvokeInstanceMethod(editor, "DismissSignatureHelp", Type.EmptyTypes); + + Assert.IsFalse(GetSignatureHelpField(editor, "_signatureRequestInFlight")); + Assert.AreEqual(6, GetSignatureHelpField(editor, "_signatureRequestToken")); + Assert.IsFalse(GetSignatureHelpField(editor, "_signatureRefreshPending")); + Assert.AreEqual(-1, GetSignatureHelpField(editor, "_pendingSignatureHelpOffset")); + Assert.IsNull(presenter.Content); + Assert.IsFalse(popup.IsOpen); + }); + } + + [TestMethod] + public void TryGetCompletionTrigger_ReturnsExplicitAndImplicitTriggers() + { + Assert.IsTrue(InvokeTryGetCompletionTrigger(".", out char? dotTrigger)); + Assert.AreEqual('.', dotTrigger); + + Assert.IsTrue(InvokeTryGetCompletionTrigger(":", out char? colonTrigger)); + Assert.AreEqual(':', colonTrigger); + + Assert.IsTrue(InvokeTryGetCompletionTrigger("a", out char? identifierTrigger)); + Assert.IsNull(identifierTrigger); + } + + [TestMethod] + public void TryGetCompletionTrigger_RejectsEmptyMultiCharacterAndNonIdentifierInput() + { + Assert.IsFalse(InvokeTryGetCompletionTrigger(null, out _)); + Assert.IsFalse(InvokeTryGetCompletionTrigger(string.Empty, out _)); + Assert.IsFalse(InvokeTryGetCompletionTrigger("ab", out _)); + Assert.IsFalse(InvokeTryGetCompletionTrigger(" ", out _)); + } + + [TestMethod] + public void ShouldKeepCompletionWindowOpen_ReturnsTrueOnlyForIdentifierCharacters() + { + Assert.IsTrue(InvokePrivateStaticBooleanMethod( + "ShouldKeepCompletionWindowOpen", + [typeof(string)], + "a")); + + Assert.IsTrue(InvokePrivateStaticBooleanMethod( + "ShouldKeepCompletionWindowOpen", + [typeof(string)], + "_")); + + Assert.IsFalse(InvokePrivateStaticBooleanMethod( + "ShouldKeepCompletionWindowOpen", + [typeof(string)], + new object?[] { null })); + + Assert.IsFalse(InvokePrivateStaticBooleanMethod( + "ShouldKeepCompletionWindowOpen", + [typeof(string)], + ".")); + } + + [TestMethod] + public void ScheduleCompletionRequest_StartsTimerAndCancelPendingCompletionRequest_StopsIt() + { + RunInSta(() => + { + var editor = new LuaEditor(new Version(1, 0)); + object completionController = GetCompletionController(editor); + + InvokeControllerInstanceMethod(editor, "_completionController", "ScheduleRequest"); + Assert.IsTrue(GetPrivateField(completionController, "_completionRequestTimer").IsEnabled); + + InvokeControllerInstanceMethod(editor, "_completionController", "CancelPendingRequest"); + Assert.IsFalse(GetPrivateField(completionController, "_completionRequestTimer").IsEnabled); + }); + } + + [TestMethod] + public void IsAsyncEditorResultCurrent_ReturnsTrueForCurrentLoadedAvailableRequest() + { + bool isCurrent = InvokePrivateStaticBooleanMethod( + "IsAsyncEditorResultCurrent", + [typeof(bool), typeof(int), typeof(int), typeof(int), typeof(int), typeof(int), typeof(int), typeof(bool), typeof(bool)], + false, + 3, + 3, + 8, + 8, + 2, + 2, + true, + true); + + Assert.IsTrue(isCurrent); + } + + [TestMethod] + public void IsAsyncEditorResultCurrent_RejectsCanceledOrStaleResults() + { + Assert.IsFalse(InvokePrivateStaticBooleanMethod( + "IsAsyncEditorResultCurrent", + [typeof(bool), typeof(int), typeof(int), typeof(int), typeof(int), typeof(int), typeof(int), typeof(bool), typeof(bool)], + true, + 3, + 3, + 8, + 8, + 2, + 2, + true, + true)); + + Assert.IsFalse(InvokePrivateStaticBooleanMethod( + "IsAsyncEditorResultCurrent", + [typeof(bool), typeof(int), typeof(int), typeof(int), typeof(int), typeof(int), typeof(int), typeof(bool), typeof(bool)], + false, + 3, + 4, + 8, + 8, + 2, + 2, + true, + true)); + + Assert.IsFalse(InvokePrivateStaticBooleanMethod( + "IsAsyncEditorResultCurrent", + [typeof(bool), typeof(int), typeof(int), typeof(int), typeof(int), typeof(int), typeof(int), typeof(bool), typeof(bool)], + false, + 3, + 3, + 8, + 9, + 2, + 2, + true, + true)); + + Assert.IsFalse(InvokePrivateStaticBooleanMethod( + "IsAsyncEditorResultCurrent", + [typeof(bool), typeof(int), typeof(int), typeof(int), typeof(int), typeof(int), typeof(int), typeof(bool), typeof(bool)], + false, + 3, + 3, + 8, + 8, + 2, + 3, + true, + true)); + + Assert.IsFalse(InvokePrivateStaticBooleanMethod( + "IsAsyncEditorResultCurrent", + [typeof(bool), typeof(int), typeof(int), typeof(int), typeof(int), typeof(int), typeof(int), typeof(bool), typeof(bool)], + false, + 3, + 3, + 8, + 8, + 2, + 2, + false, + true)); + + Assert.IsFalse(InvokePrivateStaticBooleanMethod( + "IsAsyncEditorResultCurrent", + [typeof(bool), typeof(int), typeof(int), typeof(int), typeof(int), typeof(int), typeof(int), typeof(bool), typeof(bool)], + false, + 3, + 3, + 8, + 8, + 2, + 2, + true, + false)); + } + + [TestMethod] + public void IsCompletionItemCurrent_ReturnsTrueForMatchingMetadata() + { + bool isCurrent = InvokePrivateStaticBooleanMethod( + "IsCompletionItemCurrent", + [typeof(int?), typeof(int), typeof(int?), typeof(int), typeof(bool), typeof(bool)], + 8, + 8, + 2, + 2, + true, + true); + + Assert.IsTrue(isCurrent); + } + + [TestMethod] + public void IsCompletionItemCurrent_AllowsUnstampedItemsButRejectsStaleOrIncompleteMetadata() + { + Assert.IsTrue(InvokePrivateStaticBooleanMethod( + "IsCompletionItemCurrent", + [typeof(int?), typeof(int), typeof(int?), typeof(int), typeof(bool), typeof(bool)], + null, + 8, + null, + 2, + true, + true)); + + Assert.IsFalse(InvokePrivateStaticBooleanMethod( + "IsCompletionItemCurrent", + [typeof(int?), typeof(int), typeof(int?), typeof(int), typeof(bool), typeof(bool)], + 8, + 9, + 2, + 2, + true, + true)); + + Assert.IsFalse(InvokePrivateStaticBooleanMethod( + "IsCompletionItemCurrent", + [typeof(int?), typeof(int), typeof(int?), typeof(int), typeof(bool), typeof(bool)], + 8, + 8, + null, + 2, + true, + true)); + + Assert.IsFalse(InvokePrivateStaticBooleanMethod( + "IsCompletionItemCurrent", + [typeof(int?), typeof(int), typeof(int?), typeof(int), typeof(bool), typeof(bool)], + 8, + 8, + 2, + 2, + false, + true)); + + Assert.IsFalse(InvokePrivateStaticBooleanMethod( + "IsCompletionItemCurrent", + [typeof(int?), typeof(int), typeof(int?), typeof(int), typeof(bool), typeof(bool)], + 8, + 8, + 2, + 2, + true, + false)); + } + + [TestMethod] + public void CloseCompletionWindow_InvalidatesPendingRequests_ButRefreshCloseDoesNot() + { + RunInSta(() => + { + var editor = new LuaEditor(new Version(1, 0)); + object completionController = GetCompletionController(editor); + + SetPrivateField(completionController, "_completionRequestToken", 5); + InvokeInstanceMethod(editor, "CloseCompletionWindow", Type.EmptyTypes); + Assert.AreEqual(6, GetPrivateField(completionController, "_completionRequestToken")); + + InvokeControllerInstanceMethod(editor, "_completionController", "CloseWindowForRefresh"); + Assert.AreEqual(6, GetPrivateField(completionController, "_completionRequestToken")); + }); + } + + [TestMethod] + public void TryGetHoverRequestOffset_ReturnsIdentifierOffsetWhenEligible() + { + RunInSta(() => + { + var editor = new LuaEditor(new Version(1, 0)) + { + Text = "spawn(room)" + }; + + bool result = InvokeTryGetHoverRequestOffset(editor, 2, out int hoverOffset); + + Assert.IsTrue(result); + Assert.AreEqual(2, hoverOffset); + }); + } + + [TestMethod] + public void TryGetHoverRequestOffset_BlocksRequestsWhenCompletionWindowIsOpen() + { + RunInSta(() => + { + var editor = new LuaEditor(new Version(1, 0)) + { + Text = "spawn(room)" + }; + + editor.InitializeCompletionWindow(); + + bool result = InvokeTryGetHoverRequestOffset(editor, 2, out _); + + Assert.IsFalse(result); + }); + } + + [TestMethod] + public void ShowBestHoverToolTip_ShowsCombinedTooltipWhenHoverAndDiagnosticAreAvailable() + { + RunInSta(() => + { + var editor = new LuaEditor(new Version(1, 0)); + + InvokeControllerInstanceMethod( + editor, + "_hoverController", + "ShowBestToolTip", + [typeof(LuaHoverInfo), typeof(bool), typeof(string), typeof(TextEditorDiagnosticSeverity)], + new LuaHoverInfo("Hover docs.", false), + true, + "Warning message.", + TextEditorDiagnosticSeverity.Warning); + + Popup popup = GetPrivateField(editor, "_specialToolTip"); + ContentPresenter presenter = GetPrivateField(editor, "_specialToolTipPresenter"); + + Assert.IsTrue(popup.IsOpen); + Assert.IsInstanceOfType(presenter.Content, typeof(StackPanel)); + + var panel = (StackPanel)presenter.Content!; + Assert.AreEqual(2, panel.Children.Count); + }); + } + + [TestMethod] + public void ShowBestHoverToolTip_ShowsHoverTooltipWhenOnlyHoverIsAvailable() + { + RunInSta(() => + { + var editor = new LuaEditor(new Version(1, 0)); + + InvokeControllerInstanceMethod( + editor, + "_hoverController", + "ShowBestToolTip", + [typeof(LuaHoverInfo), typeof(bool), typeof(string), typeof(TextEditorDiagnosticSeverity)], + new LuaHoverInfo("Hover docs.", false), + false, + string.Empty, + TextEditorDiagnosticSeverity.Warning); + + Popup popup = GetPrivateField(editor, "_specialToolTip"); + ContentPresenter presenter = GetPrivateField(editor, "_specialToolTipPresenter"); + + Assert.IsTrue(popup.IsOpen); + Assert.IsNotNull(presenter.Content); + Assert.IsNotInstanceOfType(presenter.Content, typeof(StackPanel)); + }); + } + + [TestMethod] + public void ShowBestHoverToolTip_ShowsDiagnosticTooltipWhenOnlyDiagnosticIsAvailable() + { + RunInSta(() => + { + var editor = new LuaEditor(new Version(1, 0)); + + InvokeControllerInstanceMethod( + editor, + "_hoverController", + "ShowBestToolTip", + [typeof(LuaHoverInfo), typeof(bool), typeof(string), typeof(TextEditorDiagnosticSeverity)], + null, + true, + "Warning message.", + TextEditorDiagnosticSeverity.Warning); + + Popup popup = GetPrivateField(editor, "_specialToolTip"); + ContentPresenter presenter = GetPrivateField(editor, "_specialToolTipPresenter"); + + Assert.IsTrue(popup.IsOpen); + Assert.IsNotNull(presenter.Content); + Assert.IsNotInstanceOfType(presenter.Content, typeof(StackPanel)); + }); + } + + [TestMethod] + public void ShowBestHoverToolTip_SuppressesTooltipWhenCompletionWindowIsOpen() + { + RunInSta(() => + { + var editor = new LuaEditor(new Version(1, 0)); + editor.InitializeCompletionWindow(); + + InvokeControllerInstanceMethod( + editor, + "_hoverController", + "ShowBestToolTip", + [typeof(LuaHoverInfo), typeof(bool), typeof(string), typeof(TextEditorDiagnosticSeverity)], + new LuaHoverInfo("Hover docs.", false), + true, + "Warning message.", + TextEditorDiagnosticSeverity.Warning); + + Popup popup = GetPrivateField(editor, "_specialToolTip"); + ContentPresenter presenter = GetPrivateField(editor, "_specialToolTipPresenter"); + + Assert.IsFalse(popup.IsOpen); + Assert.IsNull(presenter.Content); + }); + } + + [TestMethod] + public void DismissTransientToolTips_CancelsHoverAndClearsTransientUi() + { + RunInSta(() => + { + var editor = new LuaEditor(new Version(1, 0)); + var hoverCancellationTokenSource = new CancellationTokenSource(); + ContentPresenter signaturePresenter = GetSignatureHelpField(editor, "_signaturePopupPresenter"); + + SetHoverField(editor, "_hoverCancellationTokenSource", hoverCancellationTokenSource); + SetHoverField(editor, "_hoverRequestToken", 4); + SetSignatureHelpField(editor, "_signatureRequestToken", 2); + SetSignatureHelpField(editor, "_signatureRequestInFlight", true); + SetSignatureHelpField(editor, "_signatureRefreshPending", true); + SetSignatureHelpField(editor, "_pendingSignatureHelpOffset", 7); + signaturePresenter.Content = new TextBlock { Text = "signature" }; + + editor.InitializeCompletionWindow(); + editor.ShowToolTip("Hover docs."); + + InvokeInstanceMethod(editor, "DismissTransientToolTips", Type.EmptyTypes); + + Assert.IsTrue(hoverCancellationTokenSource.IsCancellationRequested); + Assert.IsNull(GetHoverFieldValue(editor, "_hoverCancellationTokenSource")); + Assert.AreEqual(5, GetHoverField(editor, "_hoverRequestToken")); + Assert.IsNotNull(GetPrivateFieldValue(editor, "_completionWindow")); + Assert.AreEqual(3, GetSignatureHelpField(editor, "_signatureRequestToken")); + Assert.IsFalse(GetSignatureHelpField(editor, "_signatureRequestInFlight")); + Assert.IsFalse(GetSignatureHelpField(editor, "_signatureRefreshPending")); + Assert.AreEqual(-1, GetSignatureHelpField(editor, "_pendingSignatureHelpOffset")); + Assert.IsNull(signaturePresenter.Content); + Assert.IsFalse(GetPrivateField(editor, "_specialToolTip").IsOpen); + }); + } + + [TestMethod] + public void NavigateToDefinitionAtCaretAsync_RaisesDefinitionNavigationRequestedForResolvedLocation() + { + RunInSta(() => + { + var provider = new FakeLuaIntellisenseProvider + { + DefinitionResponse = new LuaDefinitionLocation(@"C:\Workspace\Definitions\spawn.lua", 4, 2) + }; + + var editor = new LuaEditor(new Version(1, 0)) + { + FilePath = @"C:\Workspace\Scripts\test.lua", + Text = "spawn()", + IntellisenseProvider = provider + }; + + editor.CaretOffset = 2; + LuaDefinitionLocation? navigatedLocation = null; + + editor.DefinitionNavigationRequested += location => navigatedLocation = location; + + Window window = ShowInHostWindow(editor); + + try + { + editor.NavigateToDefinitionAtCaretAsync().GetAwaiter().GetResult(); + } + finally + { + window.Close(); + } + + Assert.IsNotNull(navigatedLocation); + Assert.AreEqual(provider.DefinitionResponse!.FilePath, navigatedLocation.FilePath); + Assert.AreEqual(provider.DefinitionResponse.LineNumber, navigatedLocation.LineNumber); + Assert.AreEqual(provider.DefinitionResponse.ColumnNumber, navigatedLocation.ColumnNumber); + Assert.AreEqual(1, provider.DefinitionRequests.Count); + Assert.AreEqual(0, provider.DefinitionRequests[0].Line); + Assert.AreEqual(0, provider.DefinitionRequests[0].Column); + }); + } + + [TestMethod] + public void NavigateToDefinitionAtCaretAsync_DoesNotRaiseEventWhenProviderReturnsNoDefinition() + { + RunInSta(() => + { + var provider = new FakeLuaIntellisenseProvider(); + + var editor = new LuaEditor(new Version(1, 0)) + { + FilePath = @"C:\Workspace\Scripts\test.lua", + Text = "spawn()", + IntellisenseProvider = provider + }; + + editor.CaretOffset = 2; + int navigationRequestCount = 0; + + editor.DefinitionNavigationRequested += _ => navigationRequestCount++; + + Window window = ShowInHostWindow(editor); + + try + { + editor.NavigateToDefinitionAtCaretAsync().GetAwaiter().GetResult(); + } + finally + { + window.Close(); + } + + Assert.AreEqual(0, navigationRequestCount); + Assert.AreEqual(1, provider.DefinitionRequests.Count); + }); + } + + [TestMethod] + public void RequestSignatureHelpAsync_DismissesExistingPopupWhenProviderReturnsNoSignature() + { + RunInSta(() => + { + var provider = new FakeLuaIntellisenseProvider(); + + var editor = new LuaEditor(new Version(1, 0)) + { + FilePath = @"C:\Workspace\Scripts\test.lua", + Text = "spawn(", + IntellisenseProvider = provider + }; + + Window window = ShowInHostWindow(editor); + + try + { + Popup popup = GetSignatureHelpField(editor, "_signaturePopup"); + ContentPresenter presenter = GetSignatureHelpField(editor, "_signaturePopupPresenter"); + + presenter.Content = new TextBlock { Text = "signature" }; + popup.IsOpen = true; + + Task requestTask = (Task)(InvokeInstanceMethod(editor, "RequestSignatureHelpAsync", [typeof(int)], 6) + ?? throw new InvalidOperationException("Private instance method 'RequestSignatureHelpAsync' returned null.")); + + requestTask.GetAwaiter().GetResult(); + + Assert.IsFalse(popup.IsOpen); + Assert.IsNull(presenter.Content); + Assert.AreEqual(1, provider.SignatureRequests.Count); + } + finally + { + window.Close(); + } + }); + } + + [TestMethod] + public void RequestSignatureHelpAsync_WhenRequestIsInFlight_DefersRefreshToLatestOffset() + { + RunInSta(() => + { + var firstResponse = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + LuaSignatureInfo secondSignature = new( + "spawn(room, objectName)", + "Spawns an object.", + [new LuaParameterInfo("room", "Room id."), new LuaParameterInfo("objectName", "Object name.")], + 1); + int servedResponses = 0; + + var provider = new FakeLuaIntellisenseProvider + { + SignatureHelpHandler = (_, _) => + { + servedResponses++; + + return servedResponses == 1 + ? firstResponse.Task + : Task.FromResult(secondSignature); + } + }; + + var editor = new LuaEditor(new Version(1, 0)) + { + FilePath = @"C:\Workspace\Scripts\test.lua", + Text = "spawn(room)", + IntellisenseProvider = provider + }; + + Window window = ShowInHostWindow(editor); + + try + { + Task firstRequestTask = (Task)(InvokeInstanceMethod(editor, "RequestSignatureHelpAsync", [typeof(int)], 6) + ?? throw new InvalidOperationException("Private instance method 'RequestSignatureHelpAsync' returned null.")); + + Task deferredRequestTask = (Task)(InvokeInstanceMethod(editor, "RequestSignatureHelpAsync", [typeof(int)], 11) + ?? throw new InvalidOperationException("Private instance method 'RequestSignatureHelpAsync' returned null.")); + + deferredRequestTask.GetAwaiter().GetResult(); + + Assert.IsTrue(GetSignatureHelpField(editor, "_signatureRequestInFlight")); + Assert.IsTrue(GetSignatureHelpField(editor, "_signatureRefreshPending")); + Assert.AreEqual(11, GetSignatureHelpField(editor, "_pendingSignatureHelpOffset")); + Assert.AreEqual(1, provider.SignatureRequests.Count); + + firstResponse.SetResult(new LuaSignatureInfo( + "spawn(room)", + "Spawns an object.", + [new LuaParameterInfo("room", "Room id.")], + 0)); + + firstRequestTask.GetAwaiter().GetResult(); + Assert.IsTrue(GetSignatureHelpField(editor, "_signatureRefreshTimer").IsEnabled); + + InvokeControllerInstanceMethod(editor, "_signatureHelpController", "HandleRefreshTimerTick", [typeof(object), typeof(EventArgs)], null, EventArgs.Empty); + window.Dispatcher.Invoke(DispatcherPriority.Background, new Action(() => { })); + + Assert.AreEqual(2, provider.SignatureRequests.Count); + Assert.AreEqual(11, provider.SignatureRequests[1].Column); + Assert.IsFalse(GetSignatureHelpField(editor, "_signatureRefreshPending")); + Assert.AreEqual(11, GetSignatureHelpField(editor, "_pendingSignatureHelpOffset")); + Assert.IsTrue(GetSignatureHelpField(editor, "_signaturePopup").IsOpen); + } + finally + { + window.Close(); + } + }); + } + + [TestMethod] + public void RequestSignatureHelpAsync_ShowsSignaturePopupForResolvedSignature() + { + RunInSta(() => + { + var provider = new FakeLuaIntellisenseProvider + { + SignatureResponse = new LuaSignatureInfo( + "spawn(room)", + "Spawns an object.", + [new LuaParameterInfo("room", "Room id.")], + 0) + }; + + var editor = new LuaEditor(new Version(1, 0)) + { + FilePath = @"C:\Workspace\Scripts\test.lua", + Text = "spawn(", + IntellisenseProvider = provider + }; + + Window window = ShowInHostWindow(editor); + + try + { + Task requestTask = (Task)(InvokeInstanceMethod(editor, "RequestSignatureHelpAsync", [typeof(int)], 6) + ?? throw new InvalidOperationException("Private instance method 'RequestSignatureHelpAsync' returned null.")); + + requestTask.GetAwaiter().GetResult(); + } + finally + { + window.Close(); + } + + Popup popup = GetSignatureHelpField(editor, "_signaturePopup"); + ContentPresenter presenter = GetSignatureHelpField(editor, "_signaturePopupPresenter"); + + Assert.IsTrue(popup.IsOpen); + Assert.IsNotNull(presenter.Content); + Assert.AreEqual(1, provider.SignatureRequests.Count); + Assert.AreEqual(0, provider.SignatureRequests[0].Line); + Assert.AreEqual(6, provider.SignatureRequests[0].Column); + }); + } + + private static bool InvokePrivateStaticBooleanMethod(string methodName, Type[] parameterTypes, params object?[] arguments) + { + return (bool)(InvokeStaticMethod(typeof(LuaEditor), methodName, parameterTypes, arguments) + ?? throw new InvalidOperationException($"Private static method '{methodName}' returned null.")); + } + + private static bool InvokeTryGetCompletionTrigger(string? inputText, out char? triggerCharacter) + { + MethodInfo method = typeof(LuaEditor).GetMethod( + "TryGetCompletionTrigger", + BindingFlags.Static | BindingFlags.NonPublic, + binder: null, + [typeof(string), typeof(char?).MakeByRefType()], + modifiers: null) + ?? throw new InvalidOperationException("Private static method 'TryGetCompletionTrigger' was not found."); + + object?[] arguments = [inputText, null]; + bool result = (bool)(method.Invoke(null, arguments) + ?? throw new InvalidOperationException("Private static method 'TryGetCompletionTrigger' returned null.")); + + triggerCharacter = arguments[1] as char?; + return result; + } + + private static bool InvokeTryGetHoverRequestOffset(LuaEditor editor, int hoveredOffset, out int hoverOffset) + { + MethodInfo method = GetHoverController(editor).GetType().GetMethod( + "TryGetRequestOffset", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + binder: null, + [typeof(int), typeof(int).MakeByRefType()], + modifiers: null) + ?? throw new InvalidOperationException("Hover controller method 'TryGetRequestOffset' was not found."); + + object?[] arguments = [hoveredOffset, 0]; + bool result = (bool)(method.Invoke(GetHoverController(editor), arguments) + ?? throw new InvalidOperationException("Hover controller method 'TryGetRequestOffset' returned null.")); + + hoverOffset = (int)arguments[1]!; + return result; + } + + private static void InvokeControllerInstanceMethod(LuaEditor editor, string controllerFieldName, string methodName) + => InvokeInstanceMethod(GetPrivateField(editor, controllerFieldName), methodName, Type.EmptyTypes); + + private static object? InvokeControllerInstanceMethod(LuaEditor editor, string controllerFieldName, string methodName, Type[] parameterTypes, params object?[] arguments) + => InvokeInstanceMethod(GetPrivateField(editor, controllerFieldName), methodName, parameterTypes, arguments); + + private static object GetCompletionController(LuaEditor editor) + => GetPrivateField(editor, "_completionController"); + + private static T GetSignatureHelpField(LuaEditor editor, string fieldName) + => GetPrivateField(GetSignatureHelpController(editor), fieldName); + + private static T GetHoverField(LuaEditor editor, string fieldName) + => GetPrivateField(GetHoverController(editor), fieldName); + + private static object GetHoverController(LuaEditor editor) + => GetPrivateField(editor, "_hoverController"); + + private static object? GetHoverFieldValue(LuaEditor editor, string fieldName) + => GetPrivateFieldValue(GetHoverController(editor), fieldName); + + private static object GetSignatureHelpController(LuaEditor editor) + => GetPrivateField(editor, "_signatureHelpController"); + + private static void SetHoverField(LuaEditor editor, string fieldName, object value) + => SetPrivateField(GetHoverController(editor), fieldName, value); + + private static void SetSignatureHelpField(LuaEditor editor, string fieldName, object value) + => SetPrivateField(GetSignatureHelpController(editor), fieldName, value); + + private readonly record struct ProviderRequest(string FilePath, string Content, int Line, int Column); + + private sealed class FakeLuaIntellisenseProvider : ILuaIntellisenseProvider + { + public bool IsAvailable { get; set; } = true; + public bool SupportsReferences => false; + public bool SupportsRename => false; + public bool SupportsFormatting => false; + + public LuaHoverInfo? HoverResponse { get; set; } + + public LuaDefinitionLocation? DefinitionResponse { get; set; } + + public LuaSignatureInfo? SignatureResponse { get; set; } + + public Func>? SignatureHelpHandler { get; set; } + + public IReadOnlyList CompletionItems { get; set; } = []; + + public List DefinitionRequests { get; } = []; + + public List SignatureRequests { get; } = []; + + public event Action>? DiagnosticsUpdated + { + add { } + remove { } + } + + public event Action>? SemanticTokensUpdated + { + add { } + remove { } + } + + public IReadOnlyList GetDiagnostics(string filePath) + => []; + + public IReadOnlyList GetSemanticTokens(string filePath) + => []; + + public void OpenDocument(string filePath, string content) + { } + + public void UpdateDocument(string filePath, string content) + { } + + public void CloseDocument(string filePath) + { } + + public void RenameDocument(string oldFilePath, string newFilePath, string content) + { } + + public Task> GetCompletionItemsAsync(string filePath, string content, + int line, int column, char? triggerCharacter = null, CancellationToken cancellationToken = default) + => Task.FromResult(CompletionItems); + + public Task GetHoverAsync(string filePath, string content, + int line, int column, CancellationToken cancellationToken = default) + => Task.FromResult(HoverResponse); + + public Task GetDefinitionAsync(string filePath, string content, + int line, int column, CancellationToken cancellationToken = default) + { + DefinitionRequests.Add(new ProviderRequest(filePath, content, line, column)); + return Task.FromResult(DefinitionResponse); + } + + public Task> GetReferencesAsync(string filePath, string content, + int line, int column, CancellationToken cancellationToken = default) + => Task.FromResult>([]); + + public Task RenameSymbolAsync(string filePath, string content, + int line, int column, string newName, CancellationToken cancellationToken = default) + => Task.FromResult(null); + + public Task> FormatDocumentAsync(string filePath, string content, + LuaFormattingOptions options, CancellationToken cancellationToken = default) + => Task.FromResult>([]); + + public Task GetSignatureHelpAsync(string filePath, string content, + int line, int column, CancellationToken cancellationToken = default) + { + var request = new ProviderRequest(filePath, content, line, column); + SignatureRequests.Add(request); + + if (SignatureHelpHandler is not null) + return SignatureHelpHandler(request, cancellationToken); + + return Task.FromResult(SignatureResponse); + } + + public void Dispose() + { + } + } +} \ No newline at end of file diff --git a/TombLib/TombLib.Test/LuaEditorInteractionRulesTests.cs b/TombLib/TombLib.Test/LuaEditorInteractionRulesTests.cs new file mode 100644 index 0000000000..5d58562c11 --- /dev/null +++ b/TombLib/TombLib.Test/LuaEditorInteractionRulesTests.cs @@ -0,0 +1,235 @@ +using ICSharpCode.AvalonEdit.Document; +using TombLib.Scripting.Lua.Utils; + +namespace TombLib.Test; + +[TestClass] +public class LuaEditorInteractionRulesTests +{ + [TestMethod] + public void IsValidAutocompleteContext_AllowsMemberTriggerInCode() + { + var document = CreateDocument("player."); + + bool result = LuaEditorInteractionRules.IsValidAutocompleteContext(document, document.TextLength, '.'); + + Assert.IsTrue(result); + } + + [TestMethod] + public void IsValidAutocompleteContext_BlocksIdentifierImmediatelyAfterDot() + { + var document = CreateDocument("player.a"); + + bool result = LuaEditorInteractionRules.IsValidAutocompleteContext(document, document.TextLength, null); + + Assert.IsFalse(result); + } + + [TestMethod] + public void IsValidAutocompleteContext_BlocksLongStringContinuationOnFollowingLine() + { + var document = CreateDocument( + "value = [[long string", + "player"); + + bool result = LuaEditorInteractionRules.IsValidAutocompleteContext(document, document.TextLength, null); + + Assert.IsFalse(result); + } + + [TestMethod] + public void IsValidManualCompletionContext_BlocksCommentText() + { + var document = CreateDocument("-- player"); + + bool result = LuaEditorInteractionRules.IsValidManualCompletionContext(document, document.TextLength); + + Assert.IsFalse(result); + } + + [TestMethod] + public void IsValidManualCompletionContext_BlocksOpenString() + { + var document = CreateDocument("print(\"player"); + + bool result = LuaEditorInteractionRules.IsValidManualCompletionContext(document, document.TextLength); + + Assert.IsFalse(result); + } + + [TestMethod] + public void IsValidManualCompletionContext_BlocksOpenLongComment() + { + var document = CreateDocument("--[[ player"); + + bool result = LuaEditorInteractionRules.IsValidManualCompletionContext(document, document.TextLength); + + Assert.IsFalse(result); + } + + [TestMethod] + public void IsValidManualCompletionContext_BlocksOpenLongString() + { + var document = CreateDocument("value = [[player"); + + bool result = LuaEditorInteractionRules.IsValidManualCompletionContext(document, document.TextLength); + + Assert.IsFalse(result); + } + + [TestMethod] + public void CanRequestHover_ReturnsFalseWhenCompletionWindowIsOpen() + { + bool result = LuaEditorInteractionRules.CanRequestHover(true, false); + + Assert.IsFalse(result); + } + + [TestMethod] + public void CanRequestHover_ReturnsFalseWhenSignatureHelpIsOpen() + { + bool result = LuaEditorInteractionRules.CanRequestHover(false, true); + + Assert.IsFalse(result); + } + + [TestMethod] + public void IsValidManualCompletionContext_BlocksLongCommentContinuationOnFollowingLine() + { + var document = CreateDocument( + "--[[ comment", + "player"); + + bool result = LuaEditorInteractionRules.IsValidManualCompletionContext(document, document.TextLength); + + Assert.IsFalse(result); + } + + [TestMethod] + public void TryGetHoverOffset_ReturnsOffsetWhenPointerIsOnIdentifierText() + { + const string identifier = "targetValue"; + const string text = "return " + identifier; + + var document = CreateDocument(text); + int identifierStart = text.IndexOf(identifier, StringComparison.Ordinal); + int probeOffset = identifierStart + 2; + + bool result = LuaEditorInteractionRules.TryGetHoverOffset(document, probeOffset, out int hoverOffset); + + Assert.IsTrue(result); + Assert.AreEqual(probeOffset, hoverOffset); + } + + [TestMethod] + public void TryGetHoverOffset_BlocksTrailingWhitespaceAfterIdentifier() + { + const string identifier = "targetValue"; + const string text = "return " + identifier; + + var document = CreateDocument(text); + int probeOffset = document.TextLength; + + bool result = LuaEditorInteractionRules.TryGetHoverOffset(document, probeOffset, out _); + + Assert.IsFalse(result); + } + + [TestMethod] + public void TryGetHoverOffset_BlocksTextInsideLongCommentContinuation() + { + var document = CreateDocument( + "--[[ comment", + "targetValue"); + + int probeOffset = document.Text.IndexOf("targetValue", StringComparison.Ordinal) + 2; + + bool result = LuaEditorInteractionRules.TryGetHoverOffset(document, probeOffset, out _); + + Assert.IsFalse(result); + } + + [TestMethod] + public void TryGetHoverOffset_RefreshesLongCommentStateAfterDocumentEdit() + { + var document = CreateDocument( + "--[[ comment", + "targetValue"); + + int initialProbeOffset = document.Text.IndexOf("targetValue", StringComparison.Ordinal) + 2; + + bool initialResult = LuaEditorInteractionRules.TryGetHoverOffset(document, initialProbeOffset, out _); + + document.Text = string.Join(Environment.NewLine, + "--[[ comment ]]", + "targetValue"); + + int updatedProbeOffset = document.Text.IndexOf("targetValue", StringComparison.Ordinal) + 2; + + bool updatedResult = LuaEditorInteractionRules.TryGetHoverOffset(document, updatedProbeOffset, out int hoverOffset); + + Assert.IsFalse(initialResult); + Assert.IsTrue(updatedResult); + Assert.AreEqual(updatedProbeOffset, hoverOffset); + } + + [TestMethod] + public void TryGetDefinitionStartOffset_ReturnsWordStartFromInsideIdentifier() + { + const string identifier = "targetValue"; + const string text = "local " + identifier + " = 1"; + + var document = CreateDocument(text); + int identifierStart = text.IndexOf(identifier, StringComparison.Ordinal); + int probeOffset = identifierStart + 4; + + bool result = LuaEditorInteractionRules.TryGetDefinitionStartOffset(document, probeOffset, out int definitionOffset); + + Assert.IsTrue(result); + Assert.AreEqual(identifierStart, definitionOffset); + } + + [TestMethod] + public void TryGetDefinitionStartOffset_ReturnsWordStartWhenCaretIsAfterIdentifier() + { + const string identifier = "targetValue"; + const string text = "return " + identifier; + + var document = CreateDocument(text); + int identifierStart = text.IndexOf(identifier, StringComparison.Ordinal); + int probeOffset = identifierStart + identifier.Length; + + bool result = LuaEditorInteractionRules.TryGetDefinitionStartOffset(document, probeOffset, out int definitionOffset); + + Assert.IsTrue(result); + Assert.AreEqual(identifierStart, definitionOffset); + } + + [TestMethod] + public void TryGetDefinitionStartOffset_BlocksCommentText() + { + var document = CreateDocument("-- targetValue"); + + bool result = LuaEditorInteractionRules.TryGetDefinitionStartOffset(document, document.TextLength, out _); + + Assert.IsFalse(result); + } + + [TestMethod] + public void TryGetDefinitionStartOffset_BlocksLongStringContinuationOnFollowingLine() + { + var document = CreateDocument( + "value = [[long string", + "targetValue"); + + int probeOffset = document.Text.IndexOf("targetValue", StringComparison.Ordinal) + 3; + + bool result = LuaEditorInteractionRules.TryGetDefinitionStartOffset(document, probeOffset, out _); + + Assert.IsFalse(result); + } + + private static TextDocument CreateDocument(params string[] lines) + => new(string.Join(Environment.NewLine, lines)); +} diff --git a/TombLib/TombLib.Test/LuaIncrementalEditCalculatorTests.cs b/TombLib/TombLib.Test/LuaIncrementalEditCalculatorTests.cs new file mode 100644 index 0000000000..427bd47295 --- /dev/null +++ b/TombLib/TombLib.Test/LuaIncrementalEditCalculatorTests.cs @@ -0,0 +1,89 @@ +using TombIDE.ScriptingStudio.Services.LuaIntellisense; + +namespace TombLib.Test; + +[TestClass] +public class LuaIncrementalEditCalculatorTests +{ + [TestMethod] + public void Compute_CollapsesUnchangedPrefixAndSuffixIntoMinimalRangeEdit() + { + const string oldText = "local foo = 1\nlocal bar = 2\n"; + const string newText = "local foo = 1\nlocal baz = 2\n"; + + LuaDocumentChangeRange range = LuaIncrementalEditCalculator.Compute(oldText, newText, LuaDocumentLineOffsets.Build(oldText)); + + Assert.AreEqual(1, range.StartLine); + Assert.AreEqual(8, range.StartCharacter); + Assert.AreEqual(1, range.EndLine); + Assert.AreEqual(9, range.EndCharacter); + Assert.AreEqual("z", range.Text); + } + + [TestMethod] + public void Compute_HandlesPureInsertionAtEndOfFile() + { + const string oldText = "local foo = 1\n"; + const string newText = "local foo = 1\nlocal bar = 2\n"; + + LuaDocumentChangeRange range = LuaIncrementalEditCalculator.Compute(oldText, newText, LuaDocumentLineOffsets.Build(oldText)); + + Assert.AreEqual(1, range.StartLine); + Assert.AreEqual(0, range.StartCharacter); + Assert.AreEqual(1, range.EndLine); + Assert.AreEqual(0, range.EndCharacter); + Assert.AreEqual("local bar = 2\n", range.Text); + } + + [TestMethod] + public void Compute_NoChange_ProducesEmptyRange() + { + const string text = "print('hi')\n"; + + LuaDocumentChangeRange range = LuaIncrementalEditCalculator.Compute(text, text, LuaDocumentLineOffsets.Build(text)); + + Assert.AreEqual(string.Empty, range.Text); + Assert.AreEqual(range.StartLine, range.EndLine); + Assert.AreEqual(range.StartCharacter, range.EndCharacter); + } +} + +[TestClass] +public class LuaLanguageServerSemanticTokensDeltaParserTests +{ + [TestMethod] + public void ApplyEdits_ReplacesContiguousRangeAndPreservesSurroundingData() + { + int[] previous = [0, 0, 5, 1, 0, 0, 6, 3, 2, 0, 1, 0, 4, 1, 0]; + LuaSemanticTokensEdit edit = new(Start: 5, DeleteCount: 5, Data: [0, 6, 4, 2, 0]); + + int[]? result = LuaLanguageServerSemanticTokensDeltaParser.ApplyEdits(previous, [edit]); + + Assert.IsNotNull(result); + CollectionAssert.AreEqual(new[] { 0, 0, 5, 1, 0, 0, 6, 4, 2, 0, 1, 0, 4, 1, 0 }, result); + } + + [TestMethod] + public void ApplyEdits_ReturnsNullWhenEditOutOfRange() + { + int[] previous = [0, 0, 1, 0, 0]; + LuaSemanticTokensEdit edit = new(Start: 4, DeleteCount: 5, Data: []); + + int[]? result = LuaLanguageServerSemanticTokensDeltaParser.ApplyEdits(previous, [edit]); + + Assert.IsNull(result); + } + + [TestMethod] + public void ApplyEdits_AppliesMultipleEditsInAscendingOrder() + { + int[] previous = [10, 11, 12, 13, 14]; + LuaSemanticTokensEdit insertHead = new(Start: 0, DeleteCount: 0, Data: [99]); + LuaSemanticTokensEdit replaceTail = new(Start: 4, DeleteCount: 1, Data: [44, 45]); + + int[]? result = LuaLanguageServerSemanticTokensDeltaParser.ApplyEdits(previous, [replaceTail, insertHead]); + + Assert.IsNotNull(result); + CollectionAssert.AreEqual(new[] { 99, 10, 11, 12, 13, 44, 45 }, result); + } +} diff --git a/TombLib/TombLib.Test/LuaIndentationStrategyTests.cs b/TombLib/TombLib.Test/LuaIndentationStrategyTests.cs new file mode 100644 index 0000000000..3bdc976718 --- /dev/null +++ b/TombLib/TombLib.Test/LuaIndentationStrategyTests.cs @@ -0,0 +1,84 @@ +using ICSharpCode.AvalonEdit; +using ICSharpCode.AvalonEdit.Document; +using TombLib.Scripting.Lua.Utils; + +namespace TombLib.Test; + +[TestClass] +public class LuaIndentationStrategyTests +{ + [TestMethod] + public void IndentLine_AfterThen_AddsIndentToNewLine() + { + var strategy = new LuaAutoIndentationStrategy(new TextEditorOptions + { + ConvertTabsToSpaces = true, + IndentationSize = 4 + }); + var document = new TextDocument("if value then\r\n"); + + strategy.IndentLine(document, document.GetLineByNumber(2)); + + Assert.AreEqual("if value then\r\n ", document.Text); + } + + [TestMethod] + public void IndentLine_BeforeEnd_DedentsCurrentLineWithoutExtraInsertion() + { + var strategy = new LuaAutoIndentationStrategy(new TextEditorOptions + { + ConvertTabsToSpaces = true, + IndentationSize = 4 + }); + var document = new TextDocument("if value then\r\n end"); + + strategy.IndentLine(document, document.GetLineByNumber(2)); + + Assert.AreEqual("if value then\r\nend", document.Text); + } + + [TestMethod] + public void NormalizeCompletionInsertion_DedentsEndLineAndPreservesCaretAndCrLf() + { + LuaCompletionNormalizationResult result = LuaIndentationStrategy.NormalizeCompletionInsertion( + "if condition then\r\n\t\r\nend", + "if condition then\r\n\t".Length, + " ", + " "); + + Assert.AreEqual("if condition then\r\n \r\n end", result.Text); + Assert.AreEqual("if condition then\r\n ".Length, result.CaretOffset); + } + + [TestMethod] + public void BuildEnterInsertion_BeforeDedent_SplitsLineAndRemovesExistingIndentation() + { + LuaEnterInsertionResult result = LuaIndentationStrategy.BuildEnterInsertion( + "if condition then", + " end", + string.Empty, + " ", + "\r\n", + useSmartIndent: true); + + Assert.AreEqual("\r\n \r\n", result.Text); + Assert.AreEqual("\r\n ".Length, result.CaretOffset); + Assert.AreEqual(4, result.RemoveFollowingWhitespaceLength); + } + + [TestMethod] + public void BuildEnterInsertion_WithoutDedentSplit_InsertsIndentedNewLineOnly() + { + LuaEnterInsertionResult result = LuaIndentationStrategy.BuildEnterInsertion( + "if condition then", + "value = 1", + string.Empty, + " ", + "\r\n", + useSmartIndent: true); + + Assert.AreEqual("\r\n ", result.Text); + Assert.AreEqual("\r\n ".Length, result.CaretOffset); + Assert.AreEqual(0, result.RemoveFollowingWhitespaceLength); + } +} \ No newline at end of file diff --git a/TombLib/TombLib.Test/LuaIntellisenseDocumentManagerTests.cs b/TombLib/TombLib.Test/LuaIntellisenseDocumentManagerTests.cs new file mode 100644 index 0000000000..62a2be4e95 --- /dev/null +++ b/TombLib/TombLib.Test/LuaIntellisenseDocumentManagerTests.cs @@ -0,0 +1,48 @@ +using TombIDE.ScriptingStudio.Services.LuaIntellisense; + +namespace TombLib.Test; + +[TestClass] +public class LuaIntellisenseDocumentManagerTests +{ + [TestMethod] + public void Rename_ReturnsNullAndPreservesTrackedDocuments_WhenDestinationIsAlreadyTracked() + { + var manager = new LuaIntellisenseDocumentManager(); + const string oldFilePath = @"C:\Workspace\Scripts\source.lua"; + const string newFilePath = @"C:\Workspace\Scripts\target.lua"; + + manager.Synchronize(oldFilePath, "return 1", acquireOpenReference: true); + manager.Synchronize(newFilePath, "return 2", acquireOpenReference: true); + + LuaDocumentRenameRequest? renameRequest = manager.Rename(oldFilePath, newFilePath, "return 1"); + + Assert.IsNull(renameRequest); + Assert.IsNotNull(manager.GetDocumentSnapshot(oldFilePath)); + + LuaDocumentSnapshot? destinationDocument = manager.GetDocumentSnapshot(newFilePath); + + Assert.IsNotNull(destinationDocument); + Assert.AreEqual("return 2", destinationDocument.Content); + Assert.AreEqual(1, destinationDocument.Version); + Assert.IsTrue(manager.TryClose(oldFilePath, out _)); + Assert.IsTrue(manager.TryClose(newFilePath, out LuaDocumentSnapshot? closedDestinationDocument)); + Assert.IsNotNull(closedDestinationDocument); + Assert.AreEqual("return 2", closedDestinationDocument.Content); + } + + [TestMethod] + public void TryClose_RemovesTrackedDocumentWhileRestartReplayIsPending() + { + var manager = new LuaIntellisenseDocumentManager(); + const string filePath = @"C:\Workspace\Scripts\pending.lua"; + + manager.Synchronize(filePath, "return 1", acquireOpenReference: true); + IReadOnlyList documentsToReopen = manager.PrepareForRestart(); + + Assert.AreEqual(1, documentsToReopen.Count); + Assert.IsTrue(manager.TryClose(filePath, out LuaDocumentSnapshot? closingDocument)); + Assert.IsNull(closingDocument); + Assert.IsNull(manager.GetDocumentSnapshot(filePath)); + } +} \ No newline at end of file diff --git a/TombLib/TombLib.Test/LuaLanguageServerClientTests.cs b/TombLib/TombLib.Test/LuaLanguageServerClientTests.cs new file mode 100644 index 0000000000..a07961af7d --- /dev/null +++ b/TombLib/TombLib.Test/LuaLanguageServerClientTests.cs @@ -0,0 +1,499 @@ +using System.Diagnostics; +using System.IO; +using System.Reflection; +using System.Text; +using System.Text.Json; +using StreamJsonRpc; +using TombIDE.ScriptingStudio.Services.LuaIntellisense; + +namespace TombLib.Test; + +[TestClass] +public class LuaLanguageServerClientTests +{ + [TestMethod] + public void CaptureServerCapabilities_UsesFullTextSyncWhenServerAdvertisesFullSync() + { + using var client = new LuaLanguageServerClient(@"C:\Workspace", "lua-language-server.exe", static () => new { }); + + InvokePrivateMethod(client, "CaptureServerCapabilities", DeserializeInitializeResponse( + """ + { + "capabilities": { + "textDocumentSync": { + "change": 1 + } + } + } + """)); + + Assert.AreEqual(LuaTextDocumentSyncKind.Full, client.TextDocumentSyncKind); + } + + [TestMethod] + public void CaptureServerCapabilities_RejectsMissingDocumentChangeSupport() + { + using var client = new LuaLanguageServerClient(@"C:\Workspace", "lua-language-server.exe", static () => new { }); + + TargetInvocationException exception = Assert.ThrowsException(() => + InvokePrivateMethod(client, "CaptureServerCapabilities", DeserializeInitializeResponse( + """ + { + "capabilities": { + "textDocumentSync": {} + } + } + """))); + + Assert.IsInstanceOfType(exception.InnerException, typeof(NotSupportedException)); + } + + [TestMethod] + public void CaptureServerCapabilities_RecognizesReferenceRenameAndFormattingProviders() + { + using var client = new LuaLanguageServerClient(@"C:\Workspace", "lua-language-server.exe", static () => new { }); + + InvokePrivateMethod(client, "CaptureServerCapabilities", DeserializeInitializeResponse( + """ + { + "capabilities": { + "referencesProvider": {}, + "renameProvider": { "prepareProvider": true }, + "documentFormattingProvider": true + } + } + """)); + + Assert.IsTrue(client.SupportsReferences); + Assert.IsTrue(client.SupportsRename); + Assert.IsTrue(client.SupportsFormatting); + } + + [TestMethod] + public void CaptureServerCapabilities_RecognizesSemanticTokensLegendAndDeltaSupport() + { + using var client = new LuaLanguageServerClient(@"C:\Workspace", "lua-language-server.exe", static () => new { }); + + InvokePrivateMethod(client, "CaptureServerCapabilities", DeserializeInitializeResponse( + """ + { + "capabilities": { + "semanticTokensProvider": { + "full": { + "delta": true + }, + "legend": { + "tokenTypes": ["function", "variable"], + "tokenModifiers": ["declaration"] + } + } + } + } + """)); + + Assert.IsTrue(client.SupportsSemanticTokensDelta); + CollectionAssert.AreEqual(new[] { "function", "variable" }, client.SemanticTokenTypes.ToArray()); + CollectionAssert.AreEqual(new[] { "declaration" }, client.SemanticTokenModifiers.ToArray()); + } + + [TestMethod] + public void Dispose_WritesGracefulShutdownMessages() + { + using Process process = StartDisposableProcess(); + using var inputStream = new PendingReadStream(); + using var outputStream = new RecordingStream(); + using var client = new LuaLanguageServerClient(@"C:\Workspace", process.StartInfo.FileName, static () => new { }); + object session = CreateTransportSession(client, 1, process, inputStream, outputStream, startListening: true); + + SetActiveSession(client, session); + + client.Dispose(); + + string writtenPayload = outputStream.GetWrittenText(); + + Assert.IsTrue(writtenPayload.Contains("\"method\":\"shutdown\"", StringComparison.Ordinal), writtenPayload); + Assert.IsTrue(writtenPayload.Contains("\"method\":\"exit\"", StringComparison.Ordinal), writtenPayload); + } + + [TestMethod] + public async Task HandleSemanticTokensRefreshRequestAsync_RaisesEventAndReturnsNull() + { + using var client = new LuaLanguageServerClient(@"C:\Workspace", "lua-language-server.exe", static () => new { }); + bool refreshRequested = false; + + object rpcTarget = CreateRpcTarget(client); + + client.SemanticTokensRefreshRequested += () => refreshRequested = true; + + object? result = await InvokePrivateTaskAsync(rpcTarget, "RefreshSemanticTokensAsync").ConfigureAwait(false); + + Assert.IsTrue(refreshRequested); + Assert.IsNull(result); + } + + [TestMethod] + public async Task PumpDiagnosticsAsync_CoalescesQueuedDiagnosticsByFile() + { + using var client = new LuaLanguageServerClient(@"C:\Workspace", "lua-language-server.exe", static () => new { }); + int publishedCount = 0; + string? lastMessage = null; + + client.DiagnosticsPublished += parameters => + { + publishedCount++; + lastMessage = parameters.Diagnostics?[0].Message; + CancelLifetime(client); + }; + + InvokePrivateMethod(client, "RaiseDiagnosticsPublished", 0L, CreateDiagnosticsParameters("file:///C:/Workspace/test.lua", "Stale warning.")); + InvokePrivateMethod(client, "RaiseDiagnosticsPublished", 0L, CreateDiagnosticsParameters("file:///C:/Workspace/test.lua", "Current warning.")); + + await InvokePrivateTaskAsync(client, "PumpDiagnosticsAsync").ConfigureAwait(false); + + Assert.AreEqual(1, publishedCount); + Assert.AreEqual("Current warning.", lastMessage); + } + + [TestMethod] + public async Task PumpDiagnosticsAsync_DropsQueuedDiagnosticsFromInactiveTransportGeneration() + { + using var client = new LuaLanguageServerClient(@"C:\Workspace", "lua-language-server.exe", static () => new { }); + object oldSession = CreateTransportSession(client, 1, process: null, Stream.Null, Stream.Null); + object newSession = CreateTransportSession(client, 2, process: null, Stream.Null, Stream.Null); + int publishedCount = 0; + string? lastMessage = null; + + SetActiveSession(client, newSession); + + client.DiagnosticsPublished += parameters => + { + publishedCount++; + lastMessage = parameters.Diagnostics?[0].Message; + CancelLifetime(client); + }; + + InvokePrivateMethod(client, "RaiseDiagnosticsPublished", GetTransportGeneration(oldSession), + CreateDiagnosticsParameters("file:///C:/Workspace/stale.lua", "Stale warning.")); + InvokePrivateMethod(client, "RaiseDiagnosticsPublished", GetTransportGeneration(newSession), + CreateDiagnosticsParameters("file:///C:/Workspace/current.lua", "Current warning.")); + + await InvokePrivateTaskAsync(client, "PumpDiagnosticsAsync").ConfigureAwait(false); + + Assert.AreEqual(1, publishedCount); + Assert.AreEqual("Current warning.", lastMessage); + } + + [TestMethod] + public void BuildConfigurationResponse_ReturnsRequestedLuaSections() + { + using var client = new LuaLanguageServerClient(@"C:\Workspace", "lua-language-server.exe", static () => new + { + Lua = new + { + runtime = new + { + version = "Lua 5.4" + } + } + }); + + object[] response = (object[])InvokePrivateMethodWithReturn(client, "BuildConfigurationResponse", + new LuaWorkspaceConfigurationParams( + [ + new LuaWorkspaceConfigurationItem("Lua"), + new LuaWorkspaceConfigurationItem("Lua.runtime") + ])); + + Assert.AreEqual(2, response.Length); + Assert.AreEqual("Lua 5.4", JsonSerializer.SerializeToElement(response[1]).GetProperty("version").GetString()); + } + + [TestMethod] + public void JsonRpc_Disconnected_OldTransportGenerationDoesNotAffectActiveSession() + { + using var client = new LuaLanguageServerClient(@"C:\Workspace", "lua-language-server.exe", static () => new { }); + object oldSession = CreateTransportSession(client, 1, process: null, Stream.Null, Stream.Null); + object newSession = CreateTransportSession(client, 2, process: null, Stream.Null, Stream.Null); + + SetActiveSession(client, newSession); + SetPrivateField(client, "_isReady", true); + + InvokePrivateMethod(client, "JsonRpc_Disconnected", + oldSession, + new JsonRpcDisconnectedEventArgs("old transport closed", DisconnectedReason.LocallyDisposed)); + + Assert.IsTrue(client.IsReady); + + InvokePrivateMethod(client, "JsonRpc_Disconnected", + newSession, + new JsonRpcDisconnectedEventArgs("active transport closed", DisconnectedReason.LocallyDisposed)); + + Assert.IsFalse(client.IsReady); + } + + private static Process StartDisposableProcess() + { + var startInfo = new ProcessStartInfo + { + FileName = Path.Combine(Environment.SystemDirectory, "cmd.exe"), + Arguments = "/c ping 127.0.0.1 -n 10 > nul", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + return Process.Start(startInfo) + ?? throw new InvalidOperationException("Unable to start the disposable test process."); + } + + private static void SetPrivateField(object instance, string fieldName, object? value) + { + FieldInfo field = instance.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Private field '{fieldName}' was not found."); + + field.SetValue(instance, value); + } + + private static object CreateTransportSession(LuaLanguageServerClient client, long generation, Process? process, Stream inputStream, Stream outputStream, bool startListening = false) + { + Type sessionType = typeof(LuaLanguageServerClient).GetNestedType("LuaLanguageServerTransportSession", BindingFlags.NonPublic) + ?? throw new InvalidOperationException("Nested type 'LuaLanguageServerTransportSession' was not found."); + + ConstructorInfo constructor = sessionType.GetConstructor( + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + binder: null, + [typeof(long), typeof(Process), typeof(Stream), typeof(Stream)], + modifiers: null) + ?? throw new InvalidOperationException("Lua transport session constructor was not found."); + + object session = constructor.Invoke([generation, process, inputStream, outputStream]); + object messageHandler = InvokePrivateStaticMethodWithReturn(typeof(LuaLanguageServerClient), "CreateMessageHandler", outputStream, inputStream); + object rpcTarget = CreateRpcTarget(client); + + SetSessionProperty(session, "MessageHandler", messageHandler); + SetSessionProperty(session, "RpcTarget", rpcTarget); + object jsonRpc = InvokePrivateMethodWithReturn(client, "CreateJsonRpc", session); + SetSessionProperty(session, "JsonRpc", jsonRpc); + SetSessionProperty(session, "RpcCompletionTask", GetPropertyValue(jsonRpc, "Completion")); + + if (startListening) + ((JsonRpc)jsonRpc).StartListening(); + + return session; + } + + private static object CreateRpcTarget(LuaLanguageServerClient client, long generation = 0) + { + Type targetType = typeof(LuaLanguageServerClient).GetNestedType("LuaLanguageServerClientRpcTarget", BindingFlags.NonPublic) + ?? throw new InvalidOperationException("Nested type 'LuaLanguageServerClientRpcTarget' was not found."); + + ConstructorInfo constructor = targetType.GetConstructor( + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + binder: null, + [typeof(LuaLanguageServerClient), typeof(long)], + modifiers: null) + ?? throw new InvalidOperationException("Lua RPC target constructor was not found."); + + return constructor.Invoke([client, generation]); + } + + private static object GetPropertyValue(object instance, string propertyName) + { + PropertyInfo property = instance.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Property '{propertyName}' was not found."); + + return property.GetValue(instance) + ?? throw new InvalidOperationException($"Property '{propertyName}' returned null."); + } + + private static void SetSessionProperty(object session, string propertyName, object? value) + { + PropertyInfo property = session.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Transport session property '{propertyName}' was not found."); + + property.SetValue(session, value); + } + + private static void SetActiveSession(LuaLanguageServerClient client, object session) + { + SetPrivateField(client, "_activeSession", session); + SetPrivateField(client, "_activeTransportGeneration", GetTransportGeneration(session)); + } + + private static long GetTransportGeneration(object session) + { + PropertyInfo property = session.GetType().GetProperty("Generation", BindingFlags.Instance | BindingFlags.Public) + ?? throw new InvalidOperationException("Transport session property 'Generation' was not found."); + + return (long)(property.GetValue(session) + ?? throw new InvalidOperationException("Transport session generation value was null.")); + } + + private static void InvokePrivateMethod(object instance, string methodName, params object?[] parameters) + { + MethodInfo method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Private method '{methodName}' was not found."); + + method.Invoke(instance, parameters); + } + + private static object InvokePrivateMethodWithReturn(object instance, string methodName, params object?[] parameters) + { + MethodInfo method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Private method '{methodName}' was not found."); + + return method.Invoke(instance, parameters) + ?? throw new InvalidOperationException($"Private method '{methodName}' returned null."); + } + + private static object InvokePrivateStaticMethodWithReturn(Type type, string methodName, params object?[] parameters) + { + MethodInfo method = type.GetMethod(methodName, BindingFlags.Static | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Private static method '{methodName}' was not found."); + + return method.Invoke(obj: null, parameters) + ?? throw new InvalidOperationException($"Private static method '{methodName}' returned null."); + } + + private static async Task InvokePrivateTaskAsync(object instance, string methodName, params object?[] parameters) + { + MethodInfo method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Method '{methodName}' was not found."); + + Task task = (Task)(method.Invoke(instance, parameters) + ?? throw new InvalidOperationException($"Method '{methodName}' returned null instead of a Task.")); + + await task.ConfigureAwait(false); + } + + private static async Task InvokePrivateTaskAsync(object instance, string methodName, params object?[] parameters) + { + MethodInfo method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Method '{methodName}' was not found."); + + Task task = (Task)(method.Invoke(instance, parameters) + ?? throw new InvalidOperationException($"Method '{methodName}' returned null instead of a Task.")); + + return await task.ConfigureAwait(false); + } + + private static LuaInitializeResponse DeserializeInitializeResponse(string json) + => JsonSerializer.Deserialize(json) + ?? throw new InvalidOperationException("Failed to deserialize the Lua initialize response test payload."); + + private static LuaPublishDiagnosticsParams CreateDiagnosticsParameters(string uri, string message) + => new( + uri, + Version: null, + Diagnostics: + [ + new LuaDiagnosticPayload( + new LuaProtocolRangePayload( + new LuaProtocolNullablePosition(0, 0), + new LuaProtocolNullablePosition(0, 1)), + Severity: null, + Message: message, + Source: null, + Code: null) + ]); + + private static void CancelLifetime(LuaLanguageServerClient client) + { + FieldInfo field = typeof(LuaLanguageServerClient).GetField("_lifetimeCts", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("Private field '_lifetimeCts' was not found."); + + ((CancellationTokenSource)field.GetValue(client)!).Cancel(); + } + + private sealed class RecordingStream : Stream + { + private readonly MemoryStream _innerStream = new(); + + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => _innerStream.Length; + + public override long Position + { + get => _innerStream.Position; + set => _innerStream.Position = value; + } + + public string GetWrittenText() + => Encoding.UTF8.GetString(_innerStream.ToArray()); + + public override void Flush() + => _innerStream.Flush(); + + public override int Read(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); + + public override long Seek(long offset, SeekOrigin origin) + => throw new NotSupportedException(); + + public override void SetLength(long value) + => _innerStream.SetLength(value); + + public override void Write(byte[] buffer, int offset, int count) + => _innerStream.Write(buffer, offset, count); + + public override void Write(ReadOnlySpan buffer) + => _innerStream.Write(buffer); + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + => _innerStream.WriteAsync(buffer, cancellationToken); + + protected override void Dispose(bool disposing) + { + if (disposing) + _innerStream.Flush(); + + base.Dispose(disposing); + } + } + + private sealed class PendingReadStream : Stream + { + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override int Read(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { } + + return 0; + } + + public override void Flush() + { } + + public override long Seek(long offset, SeekOrigin origin) + => throw new NotSupportedException(); + + public override void SetLength(long value) + => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); + } + +} diff --git a/TombLib/TombLib.Test/LuaLanguageServerDiagnosticsParserTests.cs b/TombLib/TombLib.Test/LuaLanguageServerDiagnosticsParserTests.cs new file mode 100644 index 0000000000..dcbe04dc3f --- /dev/null +++ b/TombLib/TombLib.Test/LuaLanguageServerDiagnosticsParserTests.cs @@ -0,0 +1,63 @@ +using TombIDE.ScriptingStudio.Services.LuaIntellisense; + +namespace TombLib.Test; + +[TestClass] +public class LuaLanguageServerDiagnosticsParserTests +{ + [TestMethod] + public void TryParse_PreservesZeroWidthDiagnosticOnEmptyLineByAnchoringToNextVisibleCharacter() + { + const string filePath = @"C:\Workspace\test.lua"; + const string content = "local value = 1\n\nnextLine = 2"; + + bool parsed = LuaLanguageServerDiagnosticsParser.TryParse( + CreateDiagnostics(line: 1, startCharacter: 0, endLine: 1, endCharacter: 0), + filePath, + content, + documentVersion: 1, + out LuaPublishedDiagnostics? publishedDiagnostics); + + Assert.IsTrue(parsed); + Assert.IsNotNull(publishedDiagnostics); + Assert.AreEqual(1, publishedDiagnostics.Diagnostics.Count); + Assert.AreEqual(content.IndexOf("nextLine", StringComparison.Ordinal), publishedDiagnostics.Diagnostics[0].StartOffset); + Assert.AreEqual(publishedDiagnostics.Diagnostics[0].StartOffset + 1, publishedDiagnostics.Diagnostics[0].EndOffset); + } + + [TestMethod] + public void TryParse_PreservesZeroWidthDiagnosticOnTrailingEmptyLineByAnchoringToPreviousVisibleCharacter() + { + const string filePath = @"C:\Workspace\test.lua"; + const string content = "return value\n"; + + bool parsed = LuaLanguageServerDiagnosticsParser.TryParse( + CreateDiagnostics(line: 1, startCharacter: 0, endLine: 1, endCharacter: 0), + filePath, + content, + documentVersion: 1, + out LuaPublishedDiagnostics? publishedDiagnostics); + + Assert.IsTrue(parsed); + Assert.IsNotNull(publishedDiagnostics); + Assert.AreEqual(1, publishedDiagnostics.Diagnostics.Count); + Assert.AreEqual('e', content[publishedDiagnostics.Diagnostics[0].StartOffset]); + Assert.AreEqual(publishedDiagnostics.Diagnostics[0].StartOffset + 1, publishedDiagnostics.Diagnostics[0].EndOffset); + } + + private static LuaPublishDiagnosticsParams CreateDiagnostics(int line, int startCharacter, int endLine, int endCharacter) + => new( + Uri: null, + Version: 1, + Diagnostics: + [ + new LuaDiagnosticPayload( + new LuaProtocolRangePayload( + new LuaProtocolNullablePosition(line, startCharacter), + new LuaProtocolNullablePosition(endLine, endCharacter)), + 1, + "Syntax error.", + null, + null) + ]); +} diff --git a/TombLib/TombLib.Test/LuaLanguageServerIntellisenseProviderTests.cs b/TombLib/TombLib.Test/LuaLanguageServerIntellisenseProviderTests.cs new file mode 100644 index 0000000000..58392fda1a --- /dev/null +++ b/TombLib/TombLib.Test/LuaLanguageServerIntellisenseProviderTests.cs @@ -0,0 +1,1577 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Text.Json; +using TombIDE.ScriptingStudio.Services.LuaIntellisense; +using TombLib.Scripting.Lua.Objects; +using TombLib.Scripting.Objects; + +namespace TombLib.Test; + +[TestClass] +public class LuaLanguageServerIntellisenseProviderTests +{ + [TestMethod] + public async Task OpenDocument_SendsDidOpenPayloadWithLuaLanguageAndVersion() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + const string content = "local value = 1"; + + using var client = new FakeLuaLanguageServerClient(); + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + provider.OpenDocument(filePath, content); + + Assert.IsTrue(await client.WaitForMethodCountAsync("textDocument/didOpen", 1, TimeSpan.FromSeconds(1))); + + JsonElement parameters = client.GetLastNotificationParameters("textDocument/didOpen"); + JsonElement textDocument = parameters.GetProperty("textDocument"); + + Assert.AreEqual(new Uri(filePath).AbsoluteUri, textDocument.GetProperty("uri").GetString()); + Assert.AreEqual("lua", textDocument.GetProperty("languageId").GetString()); + Assert.AreEqual(1, textDocument.GetProperty("version").GetInt32()); + Assert.AreEqual(content, textDocument.GetProperty("text").GetString()); + } + + [TestMethod] + public async Task GetCompletionItemsAsync_ResolvesCompletionItemDetailsWhenServerSupportsResolve() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + + using var client = new FakeLuaLanguageServerClient + { + SupportsCompletionResolve = true, + CompletionResponse = JsonSerializer.SerializeToElement(new + { + items = new object[] + { + new + { + label = "spawn", + kind = 3, + insertText = "spawn" + } + } + }), + CompletionResolveResponse = JsonSerializer.SerializeToElement(new + { + label = "spawn", + kind = 3, + insertText = "spawn", + detail = "function", + documentation = "Spawn docs." + }) + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + IReadOnlyList items = await provider.GetCompletionItemsAsync(filePath, "spa", 0, 3); + LuaCompletionItem resolvedItem = await items[0].ResolveAsync(); + + Assert.AreEqual(1, items.Count); + Assert.IsTrue(items[0].CanResolve); + Assert.AreEqual("function", resolvedItem.Detail); + Assert.AreEqual("Spawn docs.", resolvedItem.Description); + + CollectionAssert.AreEqual( + new[] { "textDocument/didOpen", "textDocument/completion", "completionItem/resolve" }, + client.GetSentMethodNames()); + } + + [TestMethod] + public async Task GetCompletionItemsAsync_ResolvePreservesOriginalInsertionMetadata() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + + using var client = new FakeLuaLanguageServerClient + { + SupportsCompletionResolve = true, + CompletionResponse = JsonSerializer.SerializeToElement(new + { + items = new object[] + { + new + { + label = "spawn", + kind = 3, + textEdit = new + { + newText = "spawn", + range = new + { + start = new { line = 0, character = 0 }, + end = new { line = 0, character = 3 } + } + } + } + } + }), + CompletionResolveResponse = JsonSerializer.SerializeToElement(new + { + label = "spawn", + kind = 3, + insertText = "shouldNotReplaceOriginalInsertText", + detail = "function", + documentation = "Spawn docs." + }) + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + IReadOnlyList items = await provider.GetCompletionItemsAsync(filePath, "spa", 0, 3); + LuaCompletionItem resolvedItem = await items[0].ResolveAsync(); + + Assert.AreEqual("spawn", resolvedItem.InsertText); + Assert.AreEqual(items[0].TextEdit, resolvedItem.TextEdit); + Assert.AreEqual("function", resolvedItem.Detail); + Assert.AreEqual("Spawn docs.", resolvedItem.Description); + } + + [TestMethod] + public async Task GetCompletionItemsAsync_WithTriggerCharacter_PassesCompletionContext() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + + using var client = new FakeLuaLanguageServerClient + { + CompletionResponse = JsonSerializer.SerializeToElement(new { items = Array.Empty() }) + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + IReadOnlyList items = await provider.GetCompletionItemsAsync(filePath, "spawn.", 0, 6, '.'); + JsonElement parameters = client.GetLastRequestParameters("textDocument/completion"); + + Assert.AreEqual(0, items.Count); + Assert.AreEqual(new Uri(filePath).AbsoluteUri, parameters.GetProperty("textDocument").GetProperty("uri").GetString()); + Assert.AreEqual(0, parameters.GetProperty("position").GetProperty("line").GetInt32()); + Assert.AreEqual(6, parameters.GetProperty("position").GetProperty("character").GetInt32()); + Assert.AreEqual(2, parameters.GetProperty("context").GetProperty("triggerKind").GetInt32()); + Assert.AreEqual(".", parameters.GetProperty("context").GetProperty("triggerCharacter").GetString()); + } + + [TestMethod] + public async Task FormatDocumentAsync_ReturnsFormattingEditsAndPassesEditorOptions() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + const string content = "local value=1"; + + using var client = new FakeLuaLanguageServerClient + { + SupportsFormatting = true, + FormattingResponse = JsonSerializer.SerializeToElement(new object[] + { + new + { + range = new + { + start = new { line = 0, character = 0 }, + end = new { line = 0, character = 0 } + }, + newText = "local value = 1\r\n" + } + }) + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + IReadOnlyList edits = await provider.FormatDocumentAsync(filePath, content, + new LuaFormattingOptions(tabSize: 3, insertSpaces: false)); + + Assert.AreEqual(1, edits.Count); + Assert.AreEqual("local value = 1\r\n", edits[0].NewText); + + JsonElement parameters = client.GetLastRequestParameters("textDocument/formatting"); + + Assert.AreEqual(new Uri(filePath).AbsoluteUri, parameters.GetProperty("textDocument").GetProperty("uri").GetString()); + Assert.AreEqual(3, parameters.GetProperty("options").GetProperty("tabSize").GetInt32()); + Assert.IsFalse(parameters.GetProperty("options").GetProperty("insertSpaces").GetBoolean()); + + CollectionAssert.AreEqual( + new[] { "textDocument/didOpen", "textDocument/formatting" }, + client.GetSentMethodNames()); + } + + [TestMethod] + public async Task RenameSymbolAsync_ReturnsWorkspaceEditFromTypedResponse() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + const string content = "local value = target\r\nprint(target)"; + string secondPath = Path.GetFullPath(@"C:\Workspace\Scripts\other.lua"); + + using var client = new FakeLuaLanguageServerClient + { + SupportsRename = true, + RenameResponse = JsonSerializer.SerializeToElement(new + { + changes = new Dictionary + { + [new Uri(filePath).AbsoluteUri] = + [ + new + { + range = new + { + start = new { line = 0, character = 14 }, + end = new { line = 0, character = 20 } + }, + newText = "renamed" + } + ] + }, + documentChanges = new object[] + { + new + { + textDocument = new { uri = new Uri(secondPath).AbsoluteUri }, + edits = new object[] + { + new + { + range = new + { + start = new { line = 1, character = 6 }, + end = new { line = 1, character = 12 } + }, + newText = "renamed" + } + } + } + } + }) + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + LuaWorkspaceEdit? workspaceEdit = await provider.RenameSymbolAsync(filePath, content, 0, 14, "renamed"); + + Assert.IsNotNull(workspaceEdit); + Assert.AreEqual(2, workspaceEdit.DocumentEdits.Count); + Assert.AreEqual(filePath, workspaceEdit.DocumentEdits[0].FilePath); + Assert.AreEqual("renamed", workspaceEdit.DocumentEdits[0].TextEdits[0].NewText); + Assert.AreEqual(secondPath, workspaceEdit.DocumentEdits[1].FilePath); + Assert.AreEqual("renamed", workspaceEdit.DocumentEdits[1].TextEdits[0].NewText); + + JsonElement parameters = client.GetLastRequestParameters("textDocument/rename"); + + Assert.AreEqual(new Uri(filePath).AbsoluteUri, parameters.GetProperty("textDocument").GetProperty("uri").GetString()); + Assert.AreEqual("renamed", parameters.GetProperty("newName").GetString()); + + CollectionAssert.AreEqual( + new[] { "textDocument/didOpen", "textDocument/rename" }, + client.GetSentMethodNames()); + } + + [TestMethod] + public async Task FormatDocumentAsync_ReturnsEmptyWhenFormattingIsUnsupported() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + + using var client = new FakeLuaLanguageServerClient + { + SupportsFormatting = false + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + IReadOnlyList edits = await provider.FormatDocumentAsync(filePath, "local value=1", + new LuaFormattingOptions(tabSize: 4, insertSpaces: true)); + + Assert.AreEqual(0, edits.Count); + CollectionAssert.AreEqual( + new[] { "textDocument/didOpen" }, + client.GetSentMethodNames()); + } + + [TestMethod] + public async Task DispatchWorkspaceFileChangesAsync_RefreshesConfigurationWhenApiLibraryChanges() + { + string workspaceRoot = Path.Combine(Path.GetTempPath(), "LuaConfigRefresh_" + Guid.NewGuid().ToString("N")); + string apiDirectory = Path.Combine(workspaceRoot, ".API"); + string apiFilePath = Path.Combine(apiDirectory, "Generated.lua"); + + try + { + Directory.CreateDirectory(apiDirectory); + File.WriteAllText(apiFilePath, "return {}"); + + using var client = new FakeLuaLanguageServerClient(); + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + var batch = new FileChangeBatch(); + + batch.Add(apiFilePath, FileChangeKind.Changed); + + await InvokePrivateTaskAsync(provider, "DispatchWorkspaceFileChangesAsync", batch, CancellationToken.None); + + CollectionAssert.AreEqual( + new[] { "workspace/didChangeConfiguration", "workspace/didChangeWatchedFiles" }, + client.GetSentMethodNames()); + + JsonElement settings = client.GetLastNotificationParameters("workspace/didChangeConfiguration") + .GetProperty("settings") + .GetProperty("Lua") + .GetProperty("workspace") + .GetProperty("library"); + + Assert.AreEqual(1, settings.GetArrayLength()); + Assert.AreEqual(apiDirectory, settings[0].GetString()); + } + finally + { + if (Directory.Exists(workspaceRoot)) + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [TestMethod] + public async Task GetHoverAsync_RaisesTransientAndPermanentStartupFailuresOnceEach() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + const string content = "local value = 1"; + + using var client = new FakeLuaLanguageServerClient + { + IsReady = false, + StartResult = false + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + var failures = new List(); + + provider.StartupFailed += failure => failures.Add(failure); + + await provider.GetHoverAsync(filePath, content, 0, 0); + await provider.GetHoverAsync(filePath, content, 0, 0); + await provider.GetHoverAsync(filePath, content, 0, 0); + await provider.GetHoverAsync(filePath, content, 0, 0); + + Assert.AreEqual(2, failures.Count); + Assert.IsFalse(failures[0].IsPersistent); + Assert.IsTrue(failures[1].IsPersistent); + Assert.AreEqual(3, client.StartCallCount); + } + + [TestMethod] + public async Task GetHoverAsync_ReturnsParsedHoverInfoFromTypedResponse() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + const string content = "local value = 1"; + + using var client = new FakeLuaLanguageServerClient + { + HoverResponse = JsonSerializer.SerializeToElement(new + { + contents = new + { + kind = "markdown", + value = "Hover docs." + } + }) + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + LuaHoverInfo? hover = await provider.GetHoverAsync(filePath, content, 0, 0); + + Assert.IsNotNull(hover); + Assert.AreEqual("Hover docs.", hover.Content); + Assert.IsTrue(hover.IsMarkdown); + + CollectionAssert.AreEqual( + new[] { "textDocument/didOpen", "textDocument/hover" }, + client.GetSentMethodNames()); + } + + [TestMethod] + public async Task GetDefinitionAsync_ReturnsParsedDefinitionLocationFromTypedResponse() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + string targetPath = Path.GetFullPath(@"C:\Workspace\Scripts\definitions.lua"); + + using var client = new FakeLuaLanguageServerClient + { + DefinitionResponse = JsonSerializer.SerializeToElement(new + { + uri = new Uri(targetPath).AbsoluteUri, + range = new + { + start = new { line = 4, character = 2 }, + end = new { line = 4, character = 7 } + } + }) + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + LuaDefinitionLocation? definition = await provider.GetDefinitionAsync(filePath, "value", 0, 0); + + Assert.IsNotNull(definition); + Assert.AreEqual(targetPath, definition.FilePath); + Assert.AreEqual(5, definition.LineNumber); + Assert.AreEqual(3, definition.ColumnNumber); + + CollectionAssert.AreEqual( + new[] { "textDocument/didOpen", "textDocument/definition" }, + client.GetSentMethodNames()); + } + + [TestMethod] + public async Task GetReferencesAsync_ReturnsParsedReferenceLocationsFromTypedResponse() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + string targetPath = Path.GetFullPath(@"C:\Workspace\Scripts\references.lua"); + + using var client = new FakeLuaLanguageServerClient + { + ReferencesResponse = JsonSerializer.SerializeToElement(new object[] + { + new + { + uri = new Uri(targetPath).AbsoluteUri, + range = new + { + start = new { line = 2, character = 4 }, + end = new { line = 2, character = 9 } + } + }, + new + { + uri = "https://example.com/not-a-file.lua", + range = new + { + start = new { line = 0, character = 0 }, + end = new { line = 0, character = 1 } + } + } + }) + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + IReadOnlyList references = await provider.GetReferencesAsync(filePath, "value", 0, 0); + + Assert.AreEqual(1, references.Count); + Assert.AreEqual(targetPath, references[0].FilePath); + Assert.AreEqual(3, references[0].Range.StartLineNumber); + Assert.AreEqual(5, references[0].Range.StartColumnNumber); + + CollectionAssert.AreEqual( + new[] { "textDocument/didOpen", "textDocument/references" }, + client.GetSentMethodNames()); + } + + [TestMethod] + public async Task GetSignatureHelpAsync_ReturnsParsedSignatureHelpFromTypedResponse() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + + using var client = new FakeLuaLanguageServerClient + { + SignatureHelpResponse = JsonSerializer.SerializeToElement(new + { + activeSignature = 0, + activeParameter = 1, + signatures = new[] + { + new + { + label = "spawn(room, objectName)", + documentation = new + { + kind = "markdown", + value = "Spawns an object." + }, + parameters = new object[] + { + new + { + label = new[] { 6, 10 }, + documentation = "Room id." + }, + new + { + label = new[] { 12, 22 }, + documentation = "Object name." + } + } + } + } + }) + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + LuaSignatureInfo? signature = await provider.GetSignatureHelpAsync(filePath, "spawn(", 0, 6); + + Assert.IsNotNull(signature); + Assert.AreEqual("spawn(room, objectName)", signature.Label); + Assert.AreEqual("Spawns an object.", signature.Documentation); + Assert.AreEqual(1, signature.ActiveParameter); + Assert.AreEqual(2, signature.Parameters.Count); + Assert.AreEqual("objectName", signature.Parameters[1].Label); + Assert.AreEqual("Object name.", signature.Parameters[1].Documentation); + + CollectionAssert.AreEqual( + new[] { "textDocument/didOpen", "textDocument/signatureHelp" }, + client.GetSentMethodNames()); + } + + [TestMethod] + public async Task RenameDocument_MovesDiagnosticsAndSemanticTokensToNewPath() + { + const string workspaceRoot = @"C:\Workspace"; + const string oldFilePath = @"C:\Workspace\Scripts\test.lua"; + const string newFilePath = @"C:\Workspace\Scripts\renamed.lua"; + const string content = "local value = 1"; + + using var client = new FakeLuaLanguageServerClient + { + SemanticTokenTypes = ["variable"] + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + var semanticTokensUpdated = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + + provider.SemanticTokensUpdated += (filePath, tokens) => + { + if (string.Equals(filePath, oldFilePath, StringComparison.OrdinalIgnoreCase)) + semanticTokensUpdated.TrySetResult(tokens); + }; + + provider.OpenDocument(oldFilePath, content); + + Task completedTask = await Task.WhenAny(semanticTokensUpdated.Task, Task.Delay(TimeSpan.FromSeconds(1))).ConfigureAwait(false); + Assert.AreSame(semanticTokensUpdated.Task, completedTask); + + client.PublishDiagnostics(CreateDiagnostics(oldFilePath, 1, 6, 11, "Current warning.")); + + Assert.AreEqual(1, provider.GetDiagnostics(oldFilePath).Count); + Assert.AreEqual(1, provider.GetSemanticTokens(oldFilePath).Count); + + provider.RenameDocument(oldFilePath, newFilePath, content); + + Assert.IsTrue(await client.WaitForMethodCountAsync("textDocument/didClose", 1, TimeSpan.FromSeconds(1))); + Assert.IsTrue(await client.WaitForMethodCountAsync("textDocument/didOpen", 2, TimeSpan.FromSeconds(1))); + + Assert.AreEqual(0, provider.GetDiagnostics(oldFilePath).Count); + Assert.AreEqual(0, provider.GetSemanticTokens(oldFilePath).Count); + Assert.AreEqual(1, provider.GetDiagnostics(newFilePath).Count); + Assert.AreEqual(1, provider.GetSemanticTokens(newFilePath).Count); + + CollectionAssert.AreEqual( + new[] + { + "textDocument/didOpen", + "textDocument/semanticTokens/full", + "textDocument/didClose", + "textDocument/didOpen" + }, + client.GetSentMethodNames()); + } + + [TestMethod] + public async Task RenameDocument_PreservesOpenReferenceCountsAcrossMultipleTabs() + { + const string workspaceRoot = @"C:\Workspace"; + const string oldFilePath = @"C:\Workspace\Scripts\test.lua"; + const string newFilePath = @"C:\Workspace\Scripts\renamed.lua"; + const string content = "local value = 1"; + + using var client = new FakeLuaLanguageServerClient(); + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + provider.OpenDocument(oldFilePath, content); + provider.OpenDocument(oldFilePath, content); + + Assert.IsTrue(await client.WaitForMethodCountAsync("textDocument/didOpen", 1, TimeSpan.FromSeconds(1))); + + provider.RenameDocument(oldFilePath, newFilePath, content); + + Assert.IsTrue(await client.WaitForMethodCountAsync("textDocument/didClose", 1, TimeSpan.FromSeconds(1))); + Assert.IsTrue(await client.WaitForMethodCountAsync("textDocument/didOpen", 2, TimeSpan.FromSeconds(1))); + + provider.CloseDocument(newFilePath); + + Assert.IsFalse(await client.WaitForMethodCountAsync("textDocument/didClose", 2, TimeSpan.FromMilliseconds(250))); + + provider.CloseDocument(newFilePath); + + Assert.IsTrue(await client.WaitForMethodCountAsync("textDocument/didClose", 2, TimeSpan.FromSeconds(1))); + } + + [TestMethod] + public async Task GetHoverAsync_RestartsAfterConsecutiveTimeoutsOnSameTransportGeneration() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + const string content = "local value = 1"; + + using var client = new FakeLuaLanguageServerClient + { + TimedOutHoverRequestsRemaining = 2, + HoverResponse = JsonSerializer.SerializeToElement(new + { + contents = new + { + kind = "markdown", + value = "Hover docs." + } + }) + }; + + using var provider = new LuaLanguageServerIntellisenseProvider( + workspaceRoot, + client, + requestTimeout: TimeSpan.FromMilliseconds(50), + requestTimeoutRestartThreshold: 2); + + LuaHoverInfo? firstHover = await provider.GetHoverAsync(filePath, content, 0, 0); + LuaHoverInfo? secondHover = await provider.GetHoverAsync(filePath, content, 0, 0); + LuaHoverInfo? thirdHover = await provider.GetHoverAsync(filePath, content, 0, 0); + + Assert.IsNull(firstHover); + Assert.IsNull(secondHover); + Assert.IsNotNull(thirdHover); + Assert.AreEqual("Hover docs.", thirdHover.Content); + Assert.AreEqual(1, client.MarkTransportUnhealthyCallCount); + Assert.AreEqual(2, client.StartCallCount); + + client.TimedOutHoverRequestsRemaining = 1; + + LuaHoverInfo? fourthHover = await provider.GetHoverAsync(filePath, content, 0, 0); + + Assert.IsNull(fourthHover); + Assert.AreEqual(1, client.MarkTransportUnhealthyCallCount); + + CollectionAssert.AreEqual( + new[] + { + "textDocument/didOpen", + "textDocument/hover", + "textDocument/hover", + "textDocument/didOpen", + "textDocument/hover", + "textDocument/hover" + }, + client.GetSentMethodNames()); + } + + [TestMethod] + public async Task GetHoverAsync_ShieldsRestartFromRequestCancellationAfterConnectionDrop() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + const string content = "local value = 1"; + + using var client = new FakeLuaLanguageServerClient + { + FailStartWhenCancellationRequested = true, + HoverResponse = JsonSerializer.SerializeToElement(new + { + contents = new + { + kind = "markdown", + value = "Hover docs." + } + }) + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + await provider.GetHoverAsync(filePath, content, 0, 0); + + client.IsReady = false; + + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + await provider.GetHoverAsync(filePath, content, 0, 0, cancellationTokenSource.Token); + + Assert.AreEqual(2, client.StartCallCount); + Assert.AreEqual(2, client.StartCancellationTokenCanBeCanceled.Count); + Assert.IsFalse(client.StartCancellationTokenCanBeCanceled[1]); + } + + [TestMethod] + public async Task UpdateDocument_SendsFullTextChangeWhenServerAdvertisesFullSync() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + + using var client = new FakeLuaLanguageServerClient + { + TextDocumentSyncKind = LuaTextDocumentSyncKind.Full + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + provider.OpenDocument(filePath, "local value = 1"); + provider.UpdateDocument(filePath, "local value = 2"); + + Assert.IsTrue(await client.WaitForNotificationAsync("textDocument/didChange", TimeSpan.FromSeconds(1))); + + JsonElement parameters = client.GetLastNotificationParameters("textDocument/didChange"); + JsonElement change = parameters.GetProperty("contentChanges")[0]; + + Assert.AreEqual("local value = 2", change.GetProperty("text").GetString()); + Assert.IsFalse(change.TryGetProperty("range", out _)); + } + + [TestMethod] + public async Task UpdateDocument_WithUnchangedContent_DoesNotSendDidChange() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + const string content = "local value = 1"; + + using var client = new FakeLuaLanguageServerClient(); + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + provider.OpenDocument(filePath, content); + + Assert.IsTrue(await client.WaitForMethodCountAsync("textDocument/didOpen", 1, TimeSpan.FromSeconds(1))); + + provider.UpdateDocument(filePath, content); + + Assert.IsFalse(await client.WaitForNotificationAsync("textDocument/didChange", TimeSpan.FromMilliseconds(250))); + CollectionAssert.AreEqual( + new[] { "textDocument/didOpen" }, + client.GetSentMethodNames()); + } + + [TestMethod] + public async Task UpdateDocument_WithUnchangedContentAfterTransportFailure_ReopensWithFullSemanticTokensRefresh() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + + using var client = new FakeLuaLanguageServerClient + { + SemanticTokenTypes = ["variable"], + SupportsSemanticTokensDelta = true + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + provider.OpenDocument(filePath, "local value = 1"); + + Assert.IsTrue(await client.WaitForMethodCountAsync("textDocument/semanticTokens/full", 1, TimeSpan.FromSeconds(1))); + + client.ThrowIOExceptionOnNextDidChange = true; + + provider.UpdateDocument(filePath, "local value = 2"); + + Assert.IsTrue(await client.WaitForNotificationAsync("textDocument/didChange", TimeSpan.FromSeconds(1))); + + provider.UpdateDocument(filePath, "local value = 2"); + + Assert.IsTrue(await client.WaitForMethodCountAsync("textDocument/didOpen", 2, TimeSpan.FromSeconds(1))); + Assert.IsTrue(await client.WaitForMethodCountAsync("textDocument/semanticTokens/full", 2, TimeSpan.FromSeconds(1))); + + CollectionAssert.AreEqual( + new[] + { + "textDocument/didOpen", + "textDocument/semanticTokens/full", + "textDocument/didChange", + "textDocument/didOpen", + "textDocument/semanticTokens/full" + }, + client.GetSentMethodNames()); + } + + [TestMethod] + public async Task GetHoverAsync_ReplaysTrackedDocumentsAfterLanguageServerRestart() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + const string content = "local value = 1"; + + using var client = new FakeLuaLanguageServerClient(); + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + await provider.GetHoverAsync(filePath, content, 0, 0); + + CollectionAssert.AreEqual( + new[] { "textDocument/didOpen", "textDocument/hover" }, + client.GetSentMethodNames()); + + Assert.AreEqual(1, client.StartCallCount); + + client.IsReady = false; + + await provider.GetHoverAsync(filePath, content, 0, 0); + + CollectionAssert.AreEqual( + new[] + { + "textDocument/didOpen", + "textDocument/hover", + "textDocument/didOpen", + "textDocument/hover" + }, + client.GetSentMethodNames()); + + Assert.AreEqual(2, client.StartCallCount); + } + + [TestMethod] + public async Task GetHoverAsync_ReplaysUntouchedTrackedDocumentsAfterFailedRestartRetry() + { + const string workspaceRoot = @"C:\Workspace"; + const string firstFilePath = @"C:\Workspace\Scripts\first.lua"; + const string secondFilePath = @"C:\Workspace\Scripts\second.lua"; + const string firstContent = "local first = 1"; + const string secondContent = "local second = 2"; + + using var client = new FakeLuaLanguageServerClient + { + HoverResponse = JsonSerializer.SerializeToElement(new + { + contents = new + { + kind = "markdown", + value = "Hover docs." + } + }) + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + provider.OpenDocument(firstFilePath, firstContent); + provider.OpenDocument(secondFilePath, secondContent); + + Assert.IsTrue(await client.WaitForMethodCountAsync("textDocument/didOpen", 2, TimeSpan.FromSeconds(1))); + Assert.AreEqual(1, client.StartCallCount); + + client.IsReady = false; + client.StartResult = false; + + LuaHoverInfo? failedHover = await provider.GetHoverAsync(firstFilePath, firstContent, 0, 0); + + Assert.IsNull(failedHover); + Assert.AreEqual(2, client.StartCallCount); + + client.StartResult = true; + + LuaHoverInfo? recoveredHover = await provider.GetHoverAsync(firstFilePath, firstContent, 0, 0); + + Assert.IsNotNull(recoveredHover); + Assert.IsTrue(await client.WaitForMethodCountAsync("textDocument/didOpen", 4, TimeSpan.FromSeconds(1))); + Assert.AreEqual(3, client.StartCallCount); + + CollectionAssert.AreEqual( + new[] + { + "textDocument/didOpen", + "textDocument/didOpen", + "textDocument/didOpen", + "textDocument/didOpen", + "textDocument/hover" + }, + client.GetSentMethodNames()); + } + + [TestMethod] + public async Task DiagnosticsPublished_IgnoresVersionMismatchAndStoresMatchingVersion() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + const string firstContent = "local value = 1"; + const string secondContent = "local second = 2"; + + using var client = new FakeLuaLanguageServerClient(); + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + int diagnosticsUpdatedCount = 0; + provider.DiagnosticsUpdated += (_, _) => diagnosticsUpdatedCount++; + + await provider.GetHoverAsync(filePath, firstContent, 0, 0); + await provider.GetHoverAsync(filePath, secondContent, 0, 0); + + client.PublishDiagnostics(CreateDiagnostics(filePath, 1, 6, 12, "Stale warning.")); + Assert.AreEqual(0, diagnosticsUpdatedCount); + Assert.AreEqual(0, provider.GetDiagnostics(filePath).Count); + + client.PublishDiagnostics(CreateDiagnostics(filePath, 3, 6, 12, "Future warning.")); + Assert.AreEqual(0, diagnosticsUpdatedCount); + Assert.AreEqual(0, provider.GetDiagnostics(filePath).Count); + + client.PublishDiagnostics(CreateDiagnostics(filePath, 2, 6, 12, "Current warning.")); + + IReadOnlyList diagnostics = provider.GetDiagnostics(filePath); + + Assert.AreEqual(1, diagnosticsUpdatedCount); + Assert.AreEqual(1, diagnostics.Count); + Assert.AreEqual(6, diagnostics[0].StartOffset); + Assert.AreEqual(12, diagnostics[0].EndOffset); + } + + [TestMethod] + public async Task DiagnosticsPublished_OneSubscriberExceptionDoesNotSuppressLaterSubscribers() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + const string content = "local value = 1"; + + using var client = new FakeLuaLanguageServerClient(); + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + int notifiedSubscribers = 0; + + provider.DiagnosticsUpdated += (_, _) => + { + notifiedSubscribers++; + throw new InvalidOperationException("Simulated diagnostics subscriber failure."); + }; + + provider.DiagnosticsUpdated += (_, _) => notifiedSubscribers++; + + await provider.GetHoverAsync(filePath, content, 0, 0); + client.PublishDiagnostics(CreateDiagnostics(filePath, 1, 6, 12, "Current warning.")); + + Assert.AreEqual(2, notifiedSubscribers); + } + + [TestMethod] + public async Task UpdateDocument_WaitsForEarlierOpenNotificationToFinish() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + + using var client = new FakeLuaLanguageServerClient(); + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + client.BlockNextOpenNotification(); + + provider.OpenDocument(filePath, "local value = 1"); + provider.UpdateDocument(filePath, "local value = 2"); + + Assert.IsFalse(await client.WaitForNotificationAsync("textDocument/didChange", TimeSpan.FromMilliseconds(250))); + + client.ReleaseOpenNotification(); + + Assert.IsTrue(await client.WaitForNotificationAsync("textDocument/didChange", TimeSpan.FromSeconds(1))); + + CollectionAssert.AreEqual( + new[] { "textDocument/didOpen", "textDocument/didChange" }, + client.GetSentMethodNames()); + } + + [TestMethod] + public async Task GetHoverAsync_ReopensDocumentAfterIncrementalChangeTransportFailure() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + + using var client = new FakeLuaLanguageServerClient + { + ThrowIOExceptionOnNextDidChange = true + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + await provider.GetHoverAsync(filePath, "local value = 1", 0, 0); + provider.UpdateDocument(filePath, "local value = 2"); + + Assert.IsTrue(await client.WaitForNotificationAsync("textDocument/didChange", TimeSpan.FromSeconds(1))); + + await provider.GetHoverAsync(filePath, "local value = 2", 0, 0); + + CollectionAssert.AreEqual( + new[] + { + "textDocument/didOpen", + "textDocument/hover", + "textDocument/didChange", + "textDocument/didOpen", + "textDocument/hover" + }, + client.GetSentMethodNames()); + } + + [TestMethod] + public async Task CloseDocument_WaitsForQueuedOpenNotificationToFinish() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + + using var client = new FakeLuaLanguageServerClient(); + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + client.BlockNextOpenNotification(); + + provider.OpenDocument(filePath, "local value = 1"); + provider.CloseDocument(filePath); + + Assert.IsFalse(await client.WaitForNotificationAsync("textDocument/didClose", TimeSpan.FromMilliseconds(250))); + + client.ReleaseOpenNotification(); + + Assert.IsTrue(await client.WaitForNotificationAsync("textDocument/didClose", TimeSpan.FromSeconds(1))); + + CollectionAssert.AreEqual( + new[] { "textDocument/didOpen", "textDocument/didClose" }, + client.GetSentMethodNames()); + } + + [TestMethod] + public async Task CloseDocument_SendsDidClosePayloadWithDocumentUri() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + + using var client = new FakeLuaLanguageServerClient(); + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + provider.OpenDocument(filePath, "local value = 1"); + Assert.IsTrue(await client.WaitForMethodCountAsync("textDocument/didOpen", 1, TimeSpan.FromSeconds(1))); + + provider.CloseDocument(filePath); + Assert.IsTrue(await client.WaitForMethodCountAsync("textDocument/didClose", 1, TimeSpan.FromSeconds(1))); + + JsonElement parameters = client.GetLastNotificationParameters("textDocument/didClose"); + Assert.AreEqual(new Uri(filePath).AbsoluteUri, parameters.GetProperty("textDocument").GetProperty("uri").GetString()); + } + + [TestMethod] + public async Task GetHoverAsync_RetriesWorkspaceWatcherStartAfterWorkspaceDirectoryAppears() + { + string workspaceRoot = Path.Combine(Path.GetTempPath(), "LuaWatcherRetry_" + Guid.NewGuid().ToString("N")); + string filePath = Path.Combine(workspaceRoot, "Scripts", "test.lua"); + const string content = "local value = 1"; + + try + { + using var client = new FakeLuaLanguageServerClient(); + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + await provider.GetHoverAsync(filePath, content, 0, 0); + + Assert.IsNull(GetWorkspaceWatcher(provider)); + + Directory.CreateDirectory(workspaceRoot); + + await provider.GetHoverAsync(filePath, content, 0, 0); + + Assert.IsNotNull(GetWorkspaceWatcher(provider)); + } + finally + { + if (Directory.Exists(workspaceRoot)) + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [TestMethod] + public async Task WorkspaceWatcherFailure_IsRaisedOnceAndStopsWatching() + { + string workspaceRoot = Path.Combine(Path.GetTempPath(), "LuaWatcherFailure_" + Guid.NewGuid().ToString("N")); + string filePath = Path.Combine(workspaceRoot, "Scripts", "test.lua"); + const string content = "local value = 1"; + + try + { + Directory.CreateDirectory(workspaceRoot); + + using var client = new FakeLuaLanguageServerClient(); + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + var failures = new List(); + + provider.WorkspaceWatcherFailed += failure => failures.Add(failure); + + await provider.GetHoverAsync(filePath, content, 0, 0); + + LuaWorkspaceFileWatcher watcher = GetWorkspaceWatcher(provider) + ?? throw new AssertFailedException("Expected the workspace watcher to start."); + + Assert.IsTrue(watcher.HasActiveWatchers); + + watcher.ReportErrorForTest(new IOException("Simulated watcher failure.")); + watcher.ReportErrorForTest(new IOException("Simulated watcher failure.")); + + Assert.AreEqual(1, failures.Count); + Assert.IsFalse(watcher.HasActiveWatchers); + Assert.IsTrue(failures[0].Message.Contains("disabled", StringComparison.OrdinalIgnoreCase)); + } + finally + { + if (Directory.Exists(workspaceRoot)) + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [TestMethod] + public async Task SemanticTokensRefreshRequested_RefreshesTrackedDocuments() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + const string content = "local value = 1"; + + using var client = new FakeLuaLanguageServerClient + { + SemanticTokenTypes = ["variable"] + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + var semanticTokensUpdated = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + + provider.SemanticTokensUpdated += (updatedFilePath, tokens) => + { + if (string.Equals(updatedFilePath, filePath, StringComparison.OrdinalIgnoreCase)) + semanticTokensUpdated.TrySetResult(tokens); + }; + + await provider.GetHoverAsync(filePath, content, 0, 0); + + client.PublishSemanticTokensRefreshRequested(); + + Task completedTask = await Task.WhenAny(semanticTokensUpdated.Task, Task.Delay(TimeSpan.FromSeconds(1))).ConfigureAwait(false); + Assert.AreSame(semanticTokensUpdated.Task, completedTask); + + IReadOnlyList semanticTokens = await semanticTokensUpdated.Task.ConfigureAwait(false); + + Assert.AreEqual(1, semanticTokens.Count); + Assert.AreEqual("variable", semanticTokens[0].Type); + + CollectionAssert.AreEqual( + new[] { "textDocument/didOpen", "textDocument/hover", "textDocument/semanticTokens/full" }, + client.GetSentMethodNames()); + } + + [TestMethod] + public async Task OpenDocument_SemanticTokensFullRequest_OmitsPreviousResultId() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + const string content = "local value = 1"; + + using var client = new FakeLuaLanguageServerClient + { + SemanticTokenTypes = ["variable"] + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + provider.OpenDocument(filePath, content); + + Assert.IsTrue(await client.WaitForMethodCountAsync("textDocument/semanticTokens/full", 1, TimeSpan.FromSeconds(1))); + + JsonElement parameters = client.GetLastRequestParameters("textDocument/semanticTokens/full"); + + Assert.AreEqual(new Uri(filePath).AbsoluteUri, parameters.GetProperty("textDocument").GetProperty("uri").GetString()); + Assert.IsFalse(parameters.TryGetProperty("previousResultId", out _)); + } + + [TestMethod] + public async Task SemanticTokensRefreshRequested_FallsBackToFullRefreshAfterInvalidDelta() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + const string content = "local value = 1"; + + using var client = new FakeLuaLanguageServerClient + { + SemanticTokenTypes = ["variable"], + SupportsSemanticTokensDelta = true + }; + + client.EnqueueSemanticTokensFullResponse(JsonSerializer.SerializeToElement(new + { + data = new[] { 0, 6, 5, 0, 0 }, + resultId = "tokens-1" + })); + + client.EnqueueSemanticTokensDeltaResponse(JsonSerializer.SerializeToElement(new + { + resultId = "tokens-2" + })); + + client.EnqueueSemanticTokensFullResponse(JsonSerializer.SerializeToElement(new + { + data = new[] { 0, 6, 5, 0, 0 }, + resultId = "tokens-3" + })); + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + var firstRefresh = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + var secondRefresh = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + + provider.SemanticTokensUpdated += (updatedFilePath, tokens) => + { + if (!string.Equals(updatedFilePath, filePath, StringComparison.OrdinalIgnoreCase)) + return; + + if (!firstRefresh.Task.IsCompleted) + firstRefresh.TrySetResult(tokens); + else + secondRefresh.TrySetResult(tokens); + }; + + await provider.GetHoverAsync(filePath, content, 0, 0); + + client.PublishSemanticTokensRefreshRequested(); + + Task firstCompletedTask = await Task.WhenAny(firstRefresh.Task, Task.Delay(TimeSpan.FromSeconds(1))).ConfigureAwait(false); + Assert.AreSame(firstRefresh.Task, firstCompletedTask); + + client.PublishSemanticTokensRefreshRequested(); + + Task secondCompletedTask = await Task.WhenAny(secondRefresh.Task, Task.Delay(TimeSpan.FromSeconds(1))).ConfigureAwait(false); + Assert.AreSame(secondRefresh.Task, secondCompletedTask); + + IReadOnlyList semanticTokens = await secondRefresh.Task.ConfigureAwait(false); + + Assert.AreEqual(1, semanticTokens.Count); + CollectionAssert.AreEqual( + new[] + { + "textDocument/didOpen", + "textDocument/hover", + "textDocument/semanticTokens/full", + "textDocument/semanticTokens/full/delta", + "textDocument/semanticTokens/full" + }, + client.GetSentMethodNames()); + } + + private static LuaPublishDiagnosticsParams CreateDiagnostics(string filePath, int version, int startCharacter, int endCharacter, string message) + => new( + new Uri(filePath).AbsoluteUri, + version, + [ + new LuaDiagnosticPayload( + new LuaProtocolRangePayload( + new LuaProtocolNullablePosition(0, startCharacter), + new LuaProtocolNullablePosition(0, endCharacter)), + 2, + message, + null, + null) + ]); + + private static LuaWorkspaceFileWatcher? GetWorkspaceWatcher(LuaLanguageServerIntellisenseProvider provider) + { + FieldInfo field = typeof(LuaLanguageServerIntellisenseProvider).GetField("_workspaceFileWatcher", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("Private field '_workspaceFileWatcher' was not found."); + + return field.GetValue(provider) as LuaWorkspaceFileWatcher; + } + + private static async Task InvokePrivateTaskAsync(object instance, string methodName, params object?[] parameters) + { + MethodInfo method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Private method '{methodName}' was not found."); + + Task task = (Task)(method.Invoke(instance, parameters) + ?? throw new InvalidOperationException($"Private method '{methodName}' returned null instead of a Task.")); + + await task.ConfigureAwait(false); + } + + private sealed class FakeLuaLanguageServerClient : ILuaLanguageServerClient + { + private readonly object _syncRoot = new(); + private readonly List<(string Method, JsonElement Parameters)> _sentNotifications = []; + private readonly List<(string Method, JsonElement Parameters)> _sentRequests = []; + private readonly List _sentMethodNames = []; + private readonly Queue _semanticTokensDeltaResponses = []; + private readonly Queue _semanticTokensFullResponses = []; + private TaskCompletionSource? _openNotificationGate; + private readonly TaskCompletionSource _changeNotificationObserved = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _closeNotificationObserved = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public bool IsReady { get; set; } = true; + public long TransportGeneration { get; private set; } + public bool StartResult { get; set; } = true; + public JsonElement CompletionResponse { get; set; } + public JsonElement CompletionResolveResponse { get; set; } + public JsonElement DefinitionResponse { get; set; } + public JsonElement FormattingResponse { get; set; } + public JsonElement HoverResponse { get; set; } + public JsonElement ReferencesResponse { get; set; } + public JsonElement RenameResponse { get; set; } + public JsonElement SignatureHelpResponse { get; set; } + public LuaTextDocumentSyncKind TextDocumentSyncKind { get; set; } = LuaTextDocumentSyncKind.Incremental; + public IReadOnlyList SemanticTokenTypes { get; set; } = []; + public IReadOnlyList SemanticTokenModifiers { get; set; } = []; + public bool SupportsCompletionResolve { get; set; } + public bool SupportsReferences { get; set; } = true; + public bool SupportsRename { get; set; } = true; + public bool SupportsFormatting { get; set; } = true; + public bool SupportsSemanticTokensDelta { get; set; } + public bool FailStartWhenCancellationRequested { get; set; } + public int StartCallCount { get; private set; } + public int MarkTransportUnhealthyCallCount { get; private set; } + public int TimedOutHoverRequestsRemaining { get; set; } + public bool ThrowIOExceptionOnNextDidChange { get; set; } + public List StartCancellationTokenCanBeCanceled { get; } = []; + + public event Action? DiagnosticsPublished; + + public event Action? SemanticTokensRefreshRequested; + + public Task StartAsync(CancellationToken cancellationToken) + { + StartCallCount++; + StartCancellationTokenCanBeCanceled.Add(cancellationToken.CanBeCanceled); + + if (FailStartWhenCancellationRequested && cancellationToken.IsCancellationRequested) + throw new OperationCanceledException(cancellationToken); + + IsReady = StartResult; + + if (StartResult) + TransportGeneration++; + + return Task.FromResult(StartResult); + } + + public void MarkTransportUnhealthy() + { + MarkTransportUnhealthyCallCount++; + IsReady = false; + } + + public Task SendNotificationAsync(string method, object parameters, CancellationToken cancellationToken) + { + lock (_syncRoot) + { + _sentMethodNames.Add(method); + _sentNotifications.Add((method, JsonSerializer.SerializeToElement(parameters))); + } + + if (method == "textDocument/didChange") + { + _changeNotificationObserved.TrySetResult(true); + + if (ThrowIOExceptionOnNextDidChange) + { + ThrowIOExceptionOnNextDidChange = false; + throw new IOException("Simulated didChange transport failure."); + } + } + + if (method == "textDocument/didClose") + _closeNotificationObserved.TrySetResult(true); + + if (method == "textDocument/didOpen" && _openNotificationGate is not null) + return _openNotificationGate.Task; + + return Task.CompletedTask; + } + + public Task SendRequestAsync(string method, object parameters, CancellationToken cancellationToken) + { + RecordRequest(method, parameters); + + if (method == "textDocument/hover") + { + if (TimedOutHoverRequestsRemaining > 0) + { + TimedOutHoverRequestsRemaining--; + return WaitForCancellationAsync(cancellationToken); + } + + if (HoverResponse.ValueKind != JsonValueKind.Undefined) + return DeserializeResponseAsync(HoverResponse); + } + + if (method == "textDocument/completion" && CompletionResponse.ValueKind != JsonValueKind.Undefined) + return DeserializeResponseAsync(CompletionResponse); + + if (method == "completionItem/resolve" && CompletionResolveResponse.ValueKind != JsonValueKind.Undefined) + return DeserializeResponseAsync(CompletionResolveResponse); + + if (method == "textDocument/definition" && DefinitionResponse.ValueKind != JsonValueKind.Undefined) + return DeserializeResponseAsync(DefinitionResponse); + + if (method == "textDocument/references" && ReferencesResponse.ValueKind != JsonValueKind.Undefined) + return DeserializeResponseAsync(ReferencesResponse); + + if (method == "textDocument/rename" && RenameResponse.ValueKind != JsonValueKind.Undefined) + return DeserializeResponseAsync(RenameResponse); + + if (method == "textDocument/formatting" && FormattingResponse.ValueKind != JsonValueKind.Undefined) + return DeserializeResponseAsync(FormattingResponse); + + if (method == "textDocument/semanticTokens/full/delta") + { + if (_semanticTokensDeltaResponses.Count > 0) + return DeserializeResponseAsync(_semanticTokensDeltaResponses.Dequeue()); + + return DeserializeResponseAsync(JsonSerializer.SerializeToElement(new + { + edits = Array.Empty(), + resultId = "tokens-delta" + })); + } + + if (method == "textDocument/semanticTokens/full") + { + if (_semanticTokensFullResponses.Count > 0) + return DeserializeResponseAsync(_semanticTokensFullResponses.Dequeue()); + + return DeserializeResponseAsync(JsonSerializer.SerializeToElement(new + { + data = new[] { 0, 6, 5, 0, 0 }, + resultId = "tokens-1" + })); + } + + if (method == "textDocument/signatureHelp" && SignatureHelpResponse.ValueKind != JsonValueKind.Undefined) + return DeserializeResponseAsync(SignatureHelpResponse); + + if (typeof(TResult) == typeof(JsonElement)) + return Task.FromResult((TResult)(object)JsonSerializer.SerializeToElement(new { })); + + return Task.FromResult(CreateDefaultResponse()); + } + + public string[] GetSentMethodNames() + { + lock (_syncRoot) + return [.. _sentMethodNames]; + } + + public JsonElement GetLastNotificationParameters(string method) + { + lock (_syncRoot) + { + for (int i = _sentNotifications.Count - 1; i >= 0; i--) + { + if (string.Equals(_sentNotifications[i].Method, method, StringComparison.Ordinal)) + return _sentNotifications[i].Parameters; + } + } + + throw new InvalidOperationException($"Notification '{method}' was not observed."); + } + + public JsonElement GetLastRequestParameters(string method) + { + lock (_syncRoot) + { + for (int i = _sentRequests.Count - 1; i >= 0; i--) + { + if (string.Equals(_sentRequests[i].Method, method, StringComparison.Ordinal)) + return _sentRequests[i].Parameters; + } + } + + throw new InvalidOperationException($"Request '{method}' was not observed."); + } + + public void BlockNextOpenNotification() + => _openNotificationGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + public void ReleaseOpenNotification() + => _openNotificationGate?.TrySetResult(true); + + public async Task WaitForNotificationAsync(string method, TimeSpan timeout) + { + Task observedNotification = method switch + { + "textDocument/didChange" => _changeNotificationObserved.Task, + "textDocument/didClose" => _closeNotificationObserved.Task, + _ => Task.CompletedTask + }; + + Task completedTask = await Task.WhenAny(observedNotification, Task.Delay(timeout)).ConfigureAwait(false); + return ReferenceEquals(completedTask, observedNotification); + } + + public async Task WaitForMethodCountAsync(string method, int expectedCount, TimeSpan timeout) + { + DateTime deadline = DateTime.UtcNow + timeout; + + while (DateTime.UtcNow < deadline) + { + if (GetSentMethodCount(method) >= expectedCount) + return true; + + await Task.Delay(10).ConfigureAwait(false); + } + + return GetSentMethodCount(method) >= expectedCount; + } + + public void PublishDiagnostics(LuaPublishDiagnosticsParams parameters) + => DiagnosticsPublished?.Invoke(parameters); + + public void PublishSemanticTokensRefreshRequested() + => SemanticTokensRefreshRequested?.Invoke(); + + public void EnqueueSemanticTokensFullResponse(JsonElement response) + => _semanticTokensFullResponses.Enqueue(response); + + public void EnqueueSemanticTokensDeltaResponse(JsonElement response) + => _semanticTokensDeltaResponses.Enqueue(response); + + private int GetSentMethodCount(string method) + { + int count = 0; + + lock (_syncRoot) + { + for (int i = 0; i < _sentMethodNames.Count; i++) + { + if (string.Equals(_sentMethodNames[i], method, StringComparison.Ordinal)) + count++; + } + } + + return count; + } + + private void RecordRequest(string method, object parameters) + { + lock (_syncRoot) + { + _sentMethodNames.Add(method); + _sentRequests.Add((method, JsonSerializer.SerializeToElement(parameters))); + } + } + + private static Task DeserializeResponseAsync(JsonElement response) + { + if (typeof(TResult) == typeof(JsonElement)) + return Task.FromResult((TResult)(object)response); + + return Task.FromResult(DeserializeResponse(response)); + } + + [return: MaybeNull] + private static TResult DeserializeResponse(JsonElement response) + => JsonSerializer.Deserialize(response.GetRawText()); + + [return: MaybeNull] + private static TResult CreateDefaultResponse() + => default; + + private static async Task WaitForCancellationAsync(CancellationToken cancellationToken) + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); + return default; + } + + private static async Task WaitForCancellationAsync(CancellationToken cancellationToken) + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); + return CreateDefaultResponse(); + } + + public void Dispose() + { } + } +} diff --git a/TombLib/TombLib.Test/LuaLanguageServerRealIntegrationTests.cs b/TombLib/TombLib.Test/LuaLanguageServerRealIntegrationTests.cs new file mode 100644 index 0000000000..56974439df --- /dev/null +++ b/TombLib/TombLib.Test/LuaLanguageServerRealIntegrationTests.cs @@ -0,0 +1,87 @@ +using System.IO.Compression; +using TombIDE.ScriptingStudio.Services.LuaIntellisense; +using TombLib.Scripting.Lua.Objects; + +namespace TombLib.Test; + +[TestClass] +public class LuaLanguageServerRealIntegrationTests +{ + [TestMethod] + [TestCategory("Integration")] + public async Task GetCompletionItemsAsync_WithBundledLuaLanguageServer_ReturnsLocalVariableCompletion() + { + string archivePath = TryFindRepositoryFile(Path.Combine("TombIDE", "TombIDE.Shared", "TIDE", "LuaLS.zip")) + ?? throw new AssertInconclusiveException("Could not locate the bundled LuaLS archive from the test output directory."); + + string extractionRoot = Path.Combine(Path.GetTempPath(), "LuaLsExtract_" + Guid.NewGuid().ToString("N")); + string workspaceRoot = Path.Combine(Path.GetTempPath(), "LuaLsWorkspace_" + Guid.NewGuid().ToString("N")); + + try + { + ZipFile.ExtractToDirectory(archivePath, extractionRoot); + string executablePath = Path.Combine(extractionRoot, "bin", "lua-language-server.exe"); + + Assert.IsTrue(File.Exists(executablePath), "The extracted LuaLS executable was not found."); + + string filePath = Path.Combine(workspaceRoot, "Scripts", "test.lua"); + Directory.CreateDirectory(Path.GetDirectoryName(filePath) ?? workspaceRoot); + const string content = "local spawn_room = 1\r\nsp"; +; + File.WriteAllText(filePath, content); + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, executablePath); + + IReadOnlyList items = []; + + for (int attempt = 0; attempt < 4; attempt++) + { + items = await provider.GetCompletionItemsAsync(filePath, content, 1, 2).ConfigureAwait(false); + + if (items.Any(item => string.Equals(item.Label, "spawn_room", StringComparison.Ordinal))) + break; + + await Task.Delay(250).ConfigureAwait(false); + } + + Assert.IsTrue( + items.Any(item => string.Equals(item.Label, "spawn_room", StringComparison.Ordinal)), + "The real LuaLS server did not return the expected local variable completion."); + } + finally + { + TryDeleteDirectory(extractionRoot); + TryDeleteDirectory(workspaceRoot); + } + } + + private static string? TryFindRepositoryFile(string relativePath) + { + for (DirectoryInfo? current = new(AppContext.BaseDirectory); current is not null; current = current.Parent) + { + string candidatePath = Path.Combine(current.FullName, relativePath); + + if (File.Exists(candidatePath)) + return candidatePath; + } + + return null; + } + + private static void TryDeleteDirectory(string path) + { + if (!Directory.Exists(path)) + return; + + try + { + Directory.Delete(path, recursive: true); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } +} \ No newline at end of file diff --git a/TombLib/TombLib.Test/LuaLanguageServerResponseParserTests.cs b/TombLib/TombLib.Test/LuaLanguageServerResponseParserTests.cs new file mode 100644 index 0000000000..1d38cf07db --- /dev/null +++ b/TombLib/TombLib.Test/LuaLanguageServerResponseParserTests.cs @@ -0,0 +1,429 @@ +using System.Text.Json; +using TombIDE.ScriptingStudio.Services.LuaIntellisense; +using TombLib.Scripting.Lua.Objects; + +namespace TombLib.Test; + +[TestClass] +public class LuaLanguageServerResponseParserTests +{ + [TestMethod] + public void ParseCompletionItem_AddsLocalAndUpvaluePriorityBonuses() + { + LuaCompletionItemPayload baselineElement = CreateCompletionItem("baseline", kind: 6, detail: "variable", documentation: "plain text"); + LuaCompletionItemPayload boostedElement = CreateCompletionItem("boosted", kind: 6, detail: "local variable", documentation: "upvalue"); + + LuaCompletionItem? baselineItem = LuaLanguageServerResponseParser.ParseCompletionItem(baselineElement, 0); + LuaCompletionItem? boostedItem = LuaLanguageServerResponseParser.ParseCompletionItem(boostedElement, 0); + + Assert.IsNotNull(baselineItem); + Assert.IsNotNull(boostedItem); + Assert.AreEqual(35000.0, boostedItem.Priority - baselineItem.Priority); + } + + [TestMethod] + public void ParseCompletionItem_UsesParameterIconWhenDetailContainsParameter() + { + LuaCompletionItemPayload itemElement = CreateCompletionItem("arg", kind: 6, detail: "parameter", documentation: null); + + LuaCompletionItem? item = LuaLanguageServerResponseParser.ParseCompletionItem(itemElement, 0); + + Assert.IsNotNull(item); + Assert.AreEqual(LuaCompletionIconKind.Parameter, item.IconKind); + } + + [TestMethod] + public void ParseCompletionItem_ParsesTextEditRange() + { + LuaCompletionItemPayload itemElement = DeserializeCompletionItemPayload(new + { + label = "print", + kind = 3, + textEdit = new + { + newText = "print", + range = new + { + start = new { line = 1, character = 2 }, + end = new { line = 1, character = 5 } + } + } + }); + + LuaCompletionItem? item = LuaLanguageServerResponseParser.ParseCompletionItem(itemElement, 0); + + Assert.IsNotNull(item); + Assert.AreEqual("print", item.InsertText); + Assert.IsNotNull(item.TextEdit); + Assert.AreEqual(new LuaCompletionPosition(1, 2), item.TextEdit.Value.InsertRange.Start); + Assert.AreEqual(new LuaCompletionPosition(1, 5), item.TextEdit.Value.InsertRange.End); + Assert.IsNull(item.TextEdit.Value.ReplaceRange); + } + + [TestMethod] + public void ParseCompletionItem_ParsesInsertReplaceEditRanges() + { + LuaCompletionItemPayload itemElement = DeserializeCompletionItemPayload(new + { + label = "print", + kind = 3, + textEdit = new + { + newText = "print", + insert = new + { + start = new { line = 0, character = 1 }, + end = new { line = 0, character = 3 } + }, + replace = new + { + start = new { line = 0, character = 1 }, + end = new { line = 0, character = 6 } + } + } + }); + + LuaCompletionItem? item = LuaLanguageServerResponseParser.ParseCompletionItem(itemElement, 0); + + Assert.IsNotNull(item); + Assert.IsNotNull(item.TextEdit); + Assert.AreEqual(new LuaCompletionPosition(0, 1), item.TextEdit.Value.InsertRange.Start); + Assert.AreEqual(new LuaCompletionPosition(0, 3), item.TextEdit.Value.InsertRange.End); + Assert.AreEqual(new LuaCompletionPosition(0, 1), item.TextEdit.Value.ReplaceRange!.Value.Start); + Assert.AreEqual(new LuaCompletionPosition(0, 6), item.TextEdit.Value.ReplaceRange!.Value.End); + Assert.AreEqual(new LuaCompletionPosition(0, 6), item.TextEdit.Value.ReplacementRange.End); + } + + [TestMethod] + public void ParseCompletionItem_StripsSnippetAndPreservesFinalCaretOffset() + { + LuaCompletionItemPayload itemElement = DeserializeCompletionItemPayload(new + { + label = "if", + kind = 15, + insertText = "if ${1:condition} then\r\n\t$0\r\nend", + insertTextFormat = 2 + }); + + LuaCompletionItem? item = LuaLanguageServerResponseParser.ParseCompletionItem(itemElement, 0); + + Assert.IsNotNull(item); + Assert.AreEqual("if condition then\r\n\t\r\nend", item.InsertText); + Assert.AreEqual("if condition then\r\n\t".Length, item.InsertCaretOffset); + } + + [TestMethod] + public void ParseCompletionItem_PreservesUnknownSnippetPlaceholdersAndPlacesCaretAfterDefaultText() + { + LuaCompletionItemPayload itemElement = DeserializeCompletionItemPayload(new + { + label = "call", + kind = 3, + insertText = "call(${name}, ${0:done})", + insertTextFormat = 2 + }); + + LuaCompletionItem? item = LuaLanguageServerResponseParser.ParseCompletionItem(itemElement, 0); + + Assert.IsNotNull(item); + Assert.AreEqual("call(${name}, done)", item.InsertText); + Assert.AreEqual("call(${name}, done".Length, item.InsertCaretOffset); + } + + [TestMethod] + public void ParseCompletionItems_DeduplicatesLabelAndInsertTextCaseSensitively() + { + IReadOnlyList items = LuaLanguageServerResponseParser.ParseCompletionItems( + [ + CreateCompletionItem("Value", kind: 6, detail: "variable", documentation: null, insertText: "Value"), + CreateCompletionItem("value", kind: 6, detail: "variable", documentation: null, insertText: "value"), + CreateCompletionItem("Value", kind: 6, detail: "variable", documentation: null, insertText: "Value") + ]); + + // Lua is case-sensitive: "Value" and "value" are distinct symbols, but the duplicate "Value" + // must still collapse so the popup does not show the same entry twice. + Assert.AreEqual(2, items.Count); + Assert.AreEqual("Value", items[0].Label); + Assert.AreEqual("value", items[1].Label); + } + + [TestMethod] + public void ParseDefinitionLocation_UsesFirstEntryFromMultiLocationResponse() + { + string firstPath = Path.GetFullPath(@"C:\Workspace\Scripts\first.lua"); + string secondPath = Path.GetFullPath(@"C:\Workspace\Scripts\second.lua"); + + LuaDefinitionLocation? location = LuaLanguageServerResponseParser.ParseDefinitionLocation( + DeserializeDefinitionResponse(new object[] + { + new + { + uri = new Uri(firstPath).AbsoluteUri, + range = new + { + start = new { line = 2, character = 4 }, + end = new { line = 2, character = 10 } + } + }, + new + { + uri = new Uri(secondPath).AbsoluteUri, + range = new + { + start = new { line = 8, character = 1 }, + end = new { line = 8, character = 5 } + } + } + })); + + Assert.IsNotNull(location); + Assert.AreEqual(firstPath, location.FilePath); + Assert.AreEqual(3, location.LineNumber); + Assert.AreEqual(5, location.ColumnNumber); + } + + [TestMethod] + public void ParseDefinitionLocation_UsesTargetSelectionRangeFromLocationLink() + { + string targetPath = Path.GetFullPath(@"C:\Workspace\Scripts\linked.lua"); + + LuaDefinitionLocation? location = LuaLanguageServerResponseParser.ParseDefinitionLocation( + DeserializeDefinitionResponse(new + { + targetUri = new Uri(targetPath).AbsoluteUri, + targetSelectionRange = new + { + start = new { line = 4, character = 2 }, + end = new { line = 4, character = 9 } + } + })); + + Assert.IsNotNull(location); + Assert.AreEqual(targetPath, location.FilePath); + Assert.AreEqual(5, location.LineNumber); + Assert.AreEqual(3, location.ColumnNumber); + } + + [TestMethod] + public void ParseReferenceLocations_ParsesFileReferenceRanges() + { + string targetPath = Path.GetFullPath(@"C:\Workspace\Scripts\references.lua"); + + IReadOnlyList locations = LuaLanguageServerResponseParser.ParseReferenceLocations( + DeserializeReferenceResponse(new object[] + { + new + { + uri = new Uri(targetPath).AbsoluteUri, + range = new + { + start = new { line = 2, character = 4 }, + end = new { line = 2, character = 9 } + } + }, + new + { + uri = "https://example.com/not-a-file.lua", + range = new + { + start = new { line = 0, character = 0 }, + end = new { line = 0, character = 1 } + } + } + })); + + Assert.AreEqual(1, locations.Count); + Assert.AreEqual(targetPath, locations[0].FilePath); + Assert.AreEqual(3, locations[0].Range.StartLineNumber); + Assert.AreEqual(5, locations[0].Range.StartColumnNumber); + Assert.AreEqual(3, locations[0].Range.EndLineNumber); + Assert.AreEqual(10, locations[0].Range.EndColumnNumber); + } + + [TestMethod] + public void ParseWorkspaceEdit_MergesChangeMapAndDocumentChanges() + { + string firstPath = Path.GetFullPath(@"C:\Workspace\Scripts\first.lua"); + string secondPath = Path.GetFullPath(@"C:\Workspace\Scripts\second.lua"); + + LuaWorkspaceEdit? workspaceEdit = LuaLanguageServerResponseParser.ParseWorkspaceEdit( + DeserializeWorkspaceEditResponse(new + { + changes = new Dictionary + { + [new Uri(firstPath).AbsoluteUri] = + [ + new + { + range = new + { + start = new { line = 0, character = 0 }, + end = new { line = 0, character = 5 } + }, + newText = "local" + } + ] + }, + documentChanges = new object[] + { + new + { + textDocument = new { uri = new Uri(secondPath).AbsoluteUri }, + edits = new object[] + { + new + { + range = new + { + start = new { line = 3, character = 1 }, + end = new { line = 3, character = 4 } + }, + newText = "name" + } + } + } + } + })); + + Assert.IsNotNull(workspaceEdit); + Assert.AreEqual(2, workspaceEdit.DocumentEdits.Count); + Assert.AreEqual(firstPath, workspaceEdit.DocumentEdits[0].FilePath); + Assert.AreEqual("local", workspaceEdit.DocumentEdits[0].TextEdits[0].NewText); + Assert.AreEqual(secondPath, workspaceEdit.DocumentEdits[1].FilePath); + Assert.AreEqual("name", workspaceEdit.DocumentEdits[1].TextEdits[0].NewText); + } + + [TestMethod] + public void ParseDocumentFormattingEdits_ParsesFormattingTextEdits() + { + IReadOnlyList textEdits = LuaLanguageServerResponseParser.ParseDocumentFormattingEdits( + DeserializeTextEdits(new object[] + { + new + { + range = new + { + start = new { line = 0, character = 0 }, + end = new { line = 0, character = 0 } + }, + newText = "local value = 1\r\n" + }, + new + { + range = new + { + start = new { line = 1, character = 0 }, + end = new { line = 1, character = 4 } + }, + newText = " " + } + })); + + Assert.AreEqual(2, textEdits.Count); + Assert.AreEqual("local value = 1\r\n", textEdits[0].NewText); + Assert.AreEqual(1, textEdits[0].Range.StartLineNumber); + Assert.AreEqual(1, textEdits[0].Range.StartColumnNumber); + Assert.AreEqual(2, textEdits[1].Range.StartLineNumber); + Assert.AreEqual(5, textEdits[1].Range.EndColumnNumber); + } + + [TestMethod] + public void ParseSignatureHelp_UsesParameterLabelOffsetsAndActiveParameter() + { + LuaSignatureInfo? signatureInfo = LuaLanguageServerResponseParser.ParseSignatureHelp( + DeserializeSignatureHelpResponse(new + { + activeSignature = 0, + activeParameter = 1, + signatures = new[] + { + new + { + label = "spawn(room, objectName)", + documentation = new + { + kind = "markdown", + value = "Spawns an object." + }, + parameters = new object[] + { + new + { + label = new[] { 6, 10 }, + documentation = "Room id." + }, + new + { + label = new[] { 12, 22 }, + documentation = "Object name." + } + } + } + } + })); + + Assert.IsNotNull(signatureInfo); + Assert.AreEqual("spawn(room, objectName)", signatureInfo.Label); + Assert.AreEqual("Spawns an object.", signatureInfo.Documentation); + Assert.AreEqual(1, signatureInfo.ActiveParameter); + Assert.AreEqual(2, signatureInfo.Parameters.Count); + Assert.AreEqual("objectName", signatureInfo.Parameters[1].Label); + Assert.AreEqual("Object name.", signatureInfo.Parameters[1].Documentation); + } + + [TestMethod] + public void ParseSignatureHelp_UsesSignatureLevelActiveParameterWhenResponseOmitsIt() + { + LuaSignatureInfo? signatureInfo = LuaLanguageServerResponseParser.ParseSignatureHelp( + DeserializeSignatureHelpResponse(new + { + signatures = new[] + { + new + { + label = "move(x, y)", + activeParameter = 1, + parameters = new object[] + { + new { label = "x" }, + new { label = "y" } + } + } + } + })); + + Assert.IsNotNull(signatureInfo); + Assert.AreEqual(1, signatureInfo.ActiveParameter); + Assert.AreEqual("y", signatureInfo.Parameters[1].Label); + } + + private static LuaCompletionItemPayload CreateCompletionItem(string label, int kind, string? detail, string? documentation, string? insertText = null) + => DeserializeCompletionItemPayload(new Dictionary + { + ["label"] = label, + ["kind"] = kind, + ["detail"] = detail, + ["documentation"] = documentation, + ["insertText"] = insertText ?? label, + ["filterText"] = label + }); + + private static LuaCompletionItemPayload DeserializeCompletionItemPayload(object payload) + => JsonSerializer.Deserialize(JsonSerializer.Serialize(payload)) + ?? throw new InvalidOperationException("Failed to deserialize the Lua completion-item test payload."); + + private static LuaSignatureHelpResponse? DeserializeSignatureHelpResponse(object payload) + => JsonSerializer.Deserialize(JsonSerializer.Serialize(payload)); + + private static LuaWorkspaceEditResponse? DeserializeWorkspaceEditResponse(object payload) + => JsonSerializer.Deserialize(JsonSerializer.Serialize(payload)); + + private static LuaTextEditPayload[]? DeserializeTextEdits(object payload) + => JsonSerializer.Deserialize(JsonSerializer.Serialize(payload)); + + private static LuaDefinitionResponse DeserializeDefinitionResponse(object payload) + => JsonSerializer.Deserialize(JsonSerializer.Serialize(payload)); + + private static LuaReferenceResponse[]? DeserializeReferenceResponse(object payload) + => JsonSerializer.Deserialize(JsonSerializer.Serialize(payload)); +} diff --git a/TombLib/TombLib.Test/LuaLineParserTests.cs b/TombLib/TombLib.Test/LuaLineParserTests.cs new file mode 100644 index 0000000000..d451d56da6 --- /dev/null +++ b/TombLib/TombLib.Test/LuaLineParserTests.cs @@ -0,0 +1,43 @@ +using TombLib.Scripting.Lua.Utils; + +namespace TombLib.Test; + +[TestClass] +public class LuaLineParserTests +{ + [TestMethod] + public void IsInsideCommentOrString_ReturnsTrueInsideLongComment() + { + bool result = LuaLineParser.IsInsideCommentOrString("--[[ comment"); + Assert.IsTrue(result); + } + + [TestMethod] + public void IsInsideCommentOrString_ReturnsTrueInsideLongString() + { + bool result = LuaLineParser.IsInsideCommentOrString("value = [[comment"); + Assert.IsTrue(result); + } + + [TestMethod] + public void StripLineComment_RemovesInlineLongCommentAndKeepsCodeAfterIt() + { + string result = LuaLineParser.StripLineComment("value = 1 --[[ remove this ]] + 2"); + Assert.AreEqual("value = 1 + 2", result); + } + + [TestMethod] + public void EnumerateStructuralCharacters_SkipsLongStringContents() + { + string result = new(LuaLineParser.EnumerateStructuralCharacters("{ [[ignored } text]] }").ToArray()); + Assert.AreEqual("{ }", result); + } + + [TestMethod] + public void ExtractCodeText_RemovesQuotedAndCommentText() + { + string result = LuaLineParser.ExtractCodeText("if value == \"then\" then -- comment"); + + Assert.AreEqual("if value == then ", result); + } +} diff --git a/TombLib/TombLib.Test/LuaTextMateSyntaxHighlightingTests.cs b/TombLib/TombLib.Test/LuaTextMateSyntaxHighlightingTests.cs new file mode 100644 index 0000000000..f58a86794d --- /dev/null +++ b/TombLib/TombLib.Test/LuaTextMateSyntaxHighlightingTests.cs @@ -0,0 +1,43 @@ +using ICSharpCode.AvalonEdit; +using ICSharpCode.AvalonEdit.Highlighting; +using TombLib.Scripting.Highlighting; + +namespace TombLib.Test; + +[TestClass] +public class LuaTextMateSyntaxHighlightingTests +{ + [TestMethod] + public void LoadFallbackHighlighting_ReturnsLuaDefinition() + { + IHighlightingDefinition? highlighting = LuaTextMateSyntaxHighlighting.LoadFallbackHighlighting(); + + Assert.IsNotNull(highlighting); + Assert.AreEqual("Lua", highlighting.Name); + } + + [TestMethod] + public void TryInstall_AttachesTransformerAndDisposeRemovesIt() + { + WpfTestHelper.RunInSta(() => + { + var editor = new TextEditor + { + Text = "local value = 1" + }; + + int initialTransformerCount = editor.TextArea.TextView.LineTransformers.Count; + + bool installed = LuaTextMateSyntaxHighlighting.TryInstall(editor, out LuaTextMateInstallation? installation); + + Assert.IsTrue(installed); + Assert.IsNotNull(installation); + Assert.AreEqual(initialTransformerCount + 1, editor.TextArea.TextView.LineTransformers.Count); + + installation.Dispose(); + installation.Dispose(); + + Assert.AreEqual(initialTransformerCount, editor.TextArea.TextView.LineTransformers.Count); + }); + } +} \ No newline at end of file diff --git a/TombLib/TombLib.Test/LuaThemeConfigurationTests.cs b/TombLib/TombLib.Test/LuaThemeConfigurationTests.cs new file mode 100644 index 0000000000..aee31d1747 --- /dev/null +++ b/TombLib/TombLib.Test/LuaThemeConfigurationTests.cs @@ -0,0 +1,170 @@ +using TombLib.Scripting.Highlighting; +using TombLib.Scripting.Lua; +using TombLib.Scripting.Lua.Resources; + +namespace TombLib.Test; + +[TestClass] +public class LuaThemeConfigurationTests +{ + [TestMethod] + public void DefaultConfiguration_UsesBundledSharpLuaClassicTheme() + { + var config = new LuaEditorConfiguration(); + + Assert.AreEqual("SharpLua Classic", config.SelectedThemeName); + Assert.AreEqual("SharpLua Classic", config.Theme.Name); + Assert.AreEqual("#2D2D2D", config.Theme.EditorBackground); + Assert.AreEqual("#CCCCCC", config.Theme.EditorForeground); + Assert.IsTrue(config.Theme.TextMateTheme.Rules.Count > 0); + } + + [TestMethod] + public void LegacyVs15Alias_MapsToVsCodeDarkTheme() + { + var config = new LuaEditorConfiguration + { + SelectedThemeName = "VS15" + }; + + Assert.AreEqual("VSCode Dark+", config.SelectedThemeName); + Assert.AreEqual("VSCode Dark+", config.Theme.Name); + } + + [TestMethod] + public void SwitchingTheme_LoadsBundledPreset() + { + var config = new LuaEditorConfiguration + { + SelectedThemeName = "Visual Studio 15" + }; + + Assert.AreEqual("Visual Studio 2015", config.SelectedThemeName); + Assert.AreEqual("Visual Studio 2015", config.Theme.Name); + Assert.AreEqual("#1E1E1E", config.Theme.EditorBackground); + Assert.AreEqual("#DCDCDC", config.Theme.EditorForeground); + } + + [TestMethod] + public void Repository_ExposesBundledThemes() + { + string[] themeNames = LuaThemeRepository.GetAvailableThemes().Select(theme => theme.Name).ToArray(); + + CollectionAssert.Contains(themeNames, "SharpLua Classic"); + CollectionAssert.Contains(themeNames, "Tomorrow"); + CollectionAssert.Contains(themeNames, "Tomorrow Night"); + CollectionAssert.Contains(themeNames, "VSCode Light+"); + CollectionAssert.Contains(themeNames, "NG_CENTER"); + } + + [TestMethod] + public void SharpLuaClassicTheme_UsesEnhancedLegacyPalette() + { + var config = new LuaEditorConfiguration + { + SelectedThemeName = "SharpLua" + }; + + TextMateTokenThemeRule? classRule = config.Theme.TextMateTheme.Rules.FirstOrDefault(rule => string.Equals(rule.Scope, "entity.name.class, support.class, support.type, support.variable, variable.language.self", System.StringComparison.Ordinal)); + TextMateTokenThemeRule? attributeRule = config.Theme.TextMateTheme.Rules.FirstOrDefault(rule => string.Equals(rule.Scope, "entity.other.attribute, support.type.property-name", System.StringComparison.Ordinal)); + TextMateTokenThemeRule? parameterRule = config.Theme.TextMateTheme.Rules.FirstOrDefault(rule => string.Equals(rule.Scope, "variable.parameter, variable.other.object", System.StringComparison.Ordinal)); + + Assert.AreEqual("SharpLua Classic", config.SelectedThemeName); + Assert.AreEqual("#2D2D2D", config.Theme.EditorBackground); + Assert.AreEqual("#CCCCCC", config.Theme.EditorForeground); + Assert.AreEqual("#66CCCC", config.Theme.SemanticColors.Type); + Assert.AreEqual("#E6C8FF", config.Theme.SemanticColors.Variable); + Assert.AreEqual("#D7B8FF", config.Theme.SemanticColors.Property); + Assert.AreNotEqual(config.Theme.SemanticColors.Variable, config.Theme.SemanticColors.Property); + Assert.AreEqual("#66CCCC", classRule?.Foreground); + Assert.AreEqual("#D7B8FF", attributeRule?.Foreground); + Assert.AreEqual("#E6C8FF", parameterRule?.Foreground); + } + + [TestMethod] + public void VsCodeDarkTheme_SeparatesLanguageConstantsFromNumericConstants() + { + var config = new LuaEditorConfiguration + { + SelectedThemeName = "VSCode Dark+" + }; + + TextMateTokenThemeRule? numericRule = config.Theme.TextMateTheme.Rules.FirstOrDefault(rule => string.Equals(rule.Scope, "constant.numeric", System.StringComparison.Ordinal)); + TextMateTokenThemeRule? languageRule = config.Theme.TextMateTheme.Rules.FirstOrDefault(rule => string.Equals(rule.Scope, "constant.language", System.StringComparison.Ordinal)); + + Assert.IsNotNull(numericRule); + Assert.IsNotNull(languageRule); + Assert.AreNotEqual(numericRule.Foreground, languageRule.Foreground); + } + + [TestMethod] + public void VsCodeDarkTheme_UsesFileAccentForEscapeSequences() + { + var config = new LuaEditorConfiguration + { + SelectedThemeName = "VSCode Dark+" + }; + + TextMateTokenThemeRule? numericRule = config.Theme.TextMateTheme.Rules.FirstOrDefault(rule => string.Equals(rule.Scope, "constant.numeric", System.StringComparison.Ordinal)); + TextMateTokenThemeRule? escapeRule = config.Theme.TextMateTheme.Rules.FirstOrDefault(rule => string.Equals(rule.Scope, "constant.character.escape", System.StringComparison.Ordinal)); + + Assert.IsNotNull(numericRule); + Assert.IsNotNull(escapeRule); + Assert.AreNotEqual(numericRule.Foreground, escapeRule.Foreground); + Assert.AreEqual(config.Theme.SemanticColors.File, escapeRule.Foreground); + } + + [TestMethod] + public void TomorrowNightTheme_UsesCanonicalPalette() + { + var config = new LuaEditorConfiguration + { + SelectedThemeName = "TomorrowNight" + }; + + TextMateTokenThemeRule? stringRule = config.Theme.TextMateTheme.Rules.FirstOrDefault(rule => string.Equals(rule.Scope, "string", System.StringComparison.Ordinal)); + TextMateTokenThemeRule? numericRule = config.Theme.TextMateTheme.Rules.FirstOrDefault(rule => string.Equals(rule.Scope, "constant.numeric", System.StringComparison.Ordinal)); + TextMateTokenThemeRule? languageRule = config.Theme.TextMateTheme.Rules.FirstOrDefault(rule => string.Equals(rule.Scope, "constant.language", System.StringComparison.Ordinal)); + TextMateTokenThemeRule? functionRule = config.Theme.TextMateTheme.Rules.FirstOrDefault(rule => string.Equals(rule.Scope, "entity.name.function, support.function, support.function.library, support.function.any-method", System.StringComparison.Ordinal)); + TextMateTokenThemeRule? keywordRule = config.Theme.TextMateTheme.Rules.FirstOrDefault(rule => string.Equals(rule.Scope, "keyword, storage", System.StringComparison.Ordinal)); + + Assert.AreEqual("Tomorrow Night", config.SelectedThemeName); + Assert.AreEqual("#1D1F21", config.Theme.EditorBackground); + Assert.AreEqual("#C5C8C6", config.Theme.EditorForeground); + Assert.AreEqual("#81A2BE", config.Theme.SemanticColors.Method); + Assert.AreEqual("#CC6666", config.Theme.SemanticColors.Variable); + Assert.AreEqual("#F0C674", config.Theme.SemanticColors.Type); + Assert.AreEqual("#B5BD68", stringRule?.Foreground); + Assert.AreEqual("#DE935F", numericRule?.Foreground); + Assert.AreEqual("#DE935F", languageRule?.Foreground); + Assert.AreEqual("#81A2BE", functionRule?.Foreground); + Assert.AreEqual("#B294BB", keywordRule?.Foreground); + } + + [TestMethod] + public void TomorrowTheme_UsesCanonicalPalette() + { + var config = new LuaEditorConfiguration + { + SelectedThemeName = "Tomorrow Light" + }; + + TextMateTokenThemeRule? stringRule = config.Theme.TextMateTheme.Rules.FirstOrDefault(rule => string.Equals(rule.Scope, "string", System.StringComparison.Ordinal)); + TextMateTokenThemeRule? numericRule = config.Theme.TextMateTheme.Rules.FirstOrDefault(rule => string.Equals(rule.Scope, "constant.numeric", System.StringComparison.Ordinal)); + TextMateTokenThemeRule? languageRule = config.Theme.TextMateTheme.Rules.FirstOrDefault(rule => string.Equals(rule.Scope, "constant.language", System.StringComparison.Ordinal)); + TextMateTokenThemeRule? functionRule = config.Theme.TextMateTheme.Rules.FirstOrDefault(rule => string.Equals(rule.Scope, "entity.name.function, support.function, support.function.library, support.function.any-method", System.StringComparison.Ordinal)); + TextMateTokenThemeRule? keywordRule = config.Theme.TextMateTheme.Rules.FirstOrDefault(rule => string.Equals(rule.Scope, "keyword, storage", System.StringComparison.Ordinal)); + + Assert.AreEqual("Tomorrow", config.SelectedThemeName); + Assert.AreEqual("#FFFFFF", config.Theme.EditorBackground); + Assert.AreEqual("#4D4D4C", config.Theme.EditorForeground); + Assert.AreEqual("#4271AE", config.Theme.SemanticColors.Method); + Assert.AreEqual("#C82829", config.Theme.SemanticColors.Variable); + Assert.AreEqual("#C99E00", config.Theme.SemanticColors.Type); + Assert.AreEqual("#718C00", stringRule?.Foreground); + Assert.AreEqual("#F5871F", numericRule?.Foreground); + Assert.AreEqual("#F5871F", languageRule?.Foreground); + Assert.AreEqual("#4271AE", functionRule?.Foreground); + Assert.AreEqual("#8959A8", keywordRule?.Foreground); + } +} diff --git a/TombLib/TombLib.Test/LuaWorkspaceFileWatcherTests.cs b/TombLib/TombLib.Test/LuaWorkspaceFileWatcherTests.cs new file mode 100644 index 0000000000..89af0048ef --- /dev/null +++ b/TombLib/TombLib.Test/LuaWorkspaceFileWatcherTests.cs @@ -0,0 +1,43 @@ +using System.Runtime.ExceptionServices; +using TombIDE.ScriptingStudio.Services.LuaIntellisense; + +namespace TombLib.Test; + +[TestClass] +public class LuaWorkspaceFileWatcherTests +{ + [TestMethod] + public async Task Dispose_DuringActiveDispatch_DoesNotFaultDispatch() + { + string workspaceRoot = Path.Combine(Path.GetTempPath(), "LuaWatcherDispose_" + Guid.NewGuid().ToString("N")); + var dispatchStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowDispatchToFinish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + try + { + Directory.CreateDirectory(workspaceRoot); + + using var watcher = new LuaWorkspaceFileWatcher(workspaceRoot, async (_, _) => + { + dispatchStarted.TrySetResult(true); + await allowDispatchToFinish.Task.ConfigureAwait(false); + }); + + watcher.QueueChangeForTest(Path.Combine(workspaceRoot, "test.lua"), FileChangeKind.Changed); + Task dispatchTask = watcher.DispatchPendingChangesForTestAsync(); + + Task completedTask = await Task.WhenAny(dispatchStarted.Task, Task.Delay(TimeSpan.FromSeconds(1))).ConfigureAwait(false); + Assert.AreSame(dispatchStarted.Task, completedTask); + + watcher.Dispose(); + allowDispatchToFinish.TrySetResult(true); + + await dispatchTask.ConfigureAwait(false); + } + finally + { + if (Directory.Exists(workspaceRoot)) + Directory.Delete(workspaceRoot, recursive: true); + } + } +} \ No newline at end of file diff --git a/TombLib/TombLib.Test/MarkdownToolTipRendererTests.cs b/TombLib/TombLib.Test/MarkdownToolTipRendererTests.cs new file mode 100644 index 0000000000..723c4415fb --- /dev/null +++ b/TombLib/TombLib.Test/MarkdownToolTipRendererTests.cs @@ -0,0 +1,23 @@ +using System.Windows.Media; +using ICSharpCode.AvalonEdit; +using TombLib.Scripting.Rendering; + +namespace TombLib.Test; + +[TestClass] +public class MarkdownToolTipRendererTests +{ + [TestMethod] + public void CreateCodeBlockEditor_DoesNotAcceptKeyboardFocus() + { + WpfTestHelper.RunInSta(() => + { + TextEditor editor = MarkdownToolTipRenderer.CreateCodeBlockEditor("lua", "local value = 1", Brushes.White); + + Assert.IsFalse(editor.Focusable); + Assert.IsFalse(editor.IsTabStop); + Assert.IsFalse(editor.TextArea.Focusable); + Assert.IsFalse(editor.TextArea.IsTabStop); + }); + } +} \ No newline at end of file diff --git a/TombLib/TombLib.Test/TextMateHighlightingTests.cs b/TombLib/TombLib.Test/TextMateHighlightingTests.cs new file mode 100644 index 0000000000..f297249cc4 --- /dev/null +++ b/TombLib/TombLib.Test/TextMateHighlightingTests.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Windows.Media; +using ICSharpCode.AvalonEdit.Document; +using TombLib.Scripting.Highlighting; +using TombLib.Scripting.Lua; + +namespace TombLib.Test; + +[TestClass] +public class TextMateHighlightingTests +{ + [TestMethod] + public void GetChangeInfo_CountsCrLfAsTwoAffectedLines() + { + (int startLineIndex, int removedLineCount, int insertedLineCount) = ApplyChangeAndGetInfo("alpha", document => document.Insert(5, "\r\nbeta")); + + Assert.AreEqual(0, startLineIndex); + Assert.AreEqual(1, removedLineCount); + Assert.AreEqual(2, insertedLineCount); + } + + [TestMethod] + public void GetChangeInfo_CountsLfAsTwoAffectedLines() + { + (int startLineIndex, int removedLineCount, int insertedLineCount) = ApplyChangeAndGetInfo("alpha", document => document.Insert(5, "\nbeta")); + + Assert.AreEqual(0, startLineIndex); + Assert.AreEqual(1, removedLineCount); + Assert.AreEqual(2, insertedLineCount); + } + + [TestMethod] + public void GetChangeInfo_CountsLoneCrAsTwoAffectedLines() + { + (int startLineIndex, int removedLineCount, int insertedLineCount) = ApplyChangeAndGetInfo("alpha", document => document.Insert(5, "\rbeta")); + + Assert.AreEqual(0, startLineIndex); + Assert.AreEqual(1, removedLineCount); + Assert.AreEqual(2, insertedLineCount); + } + + [TestMethod] + public void Resolve_UsesCommaSeparatedBundledFunctionSelectors() + { + var resolver = new TextMateThemeStyleResolver(new LuaEditorConfiguration + { + SelectedThemeName = "Tomorrow Light" + }.Theme.TextMateTheme); + + TextMateHighlightingStyle style = resolver.Resolve(["source.lua", "support.function.library.lua"]); + + Assert.AreEqual("#FF4271AE", GetForegroundColor(style)); + } + + [TestMethod] + public void Resolve_PrefersMoreSpecificBundledSelectorsOverBroaderParents() + { + var resolver = new TextMateThemeStyleResolver(new LuaEditorConfiguration + { + SelectedThemeName = "SharpLua" + }.Theme.TextMateTheme); + + TextMateHighlightingStyle style = resolver.Resolve(["source.lua", "support.type.property-name.lua"]); + + Assert.AreEqual("#FFD7B8FF", GetForegroundColor(style)); + } + + [TestMethod] + public void DocumentLineList_TracksInsertedAndReplacedLines() + { + var document = new TextDocument("alpha\r\nbeta"); + var lineList = new TextMateDocumentLineList(document); + + try + { + CollectionAssert.AreEqual(new[] { "alpha\r\n", "beta" }, GetSnapshotLines(lineList)); + + document.Insert(document.TextLength, "\r\ngamma"); + + CollectionAssert.AreEqual(new[] { "alpha\r\n", "beta\r\n", "gamma" }, GetSnapshotLines(lineList)); + Assert.AreEqual(3, lineList.GetNumberOfLines()); + + int replacementOffset = document.Text.IndexOf("beta\r\ngamma", StringComparison.Ordinal); + document.Replace(replacementOffset, "beta\r\ngamma".Length, "delta"); + + CollectionAssert.AreEqual(new[] { "alpha\r\n", "delta" }, GetSnapshotLines(lineList)); + Assert.AreEqual(2, lineList.GetNumberOfLines()); + } + finally + { + lineList.Dispose(); + } + } + + private static (int StartLineIndex, int RemovedLineCount, int InsertedLineCount) ApplyChangeAndGetInfo(string originalText, Action changeAction) + { + var document = new TextDocument(originalText); + DocumentChangeEventArgs? change = null; + + document.Changed += (_, args) => change = args; + changeAction(document); + + return TextMateDocumentLineList.GetChangeInfo(document, change!); + } + + private static string[] GetSnapshotLines(TextMateDocumentLineList lineList) + => [.. WpfTestHelper.GetPrivateField>(lineList, "_lineTexts")]; + + private static string GetForegroundColor(TextMateHighlightingStyle style) + { + Assert.IsNotNull(style.Foreground); + return ((SolidColorBrush)style.Foreground).Color.ToString(); + } +} \ No newline at end of file diff --git a/TombLib/TombLib.Test/Tomb1MainEditorToolTipTests.cs b/TombLib/TombLib.Test/Tomb1MainEditorToolTipTests.cs new file mode 100644 index 0000000000..20a6b1d7d6 --- /dev/null +++ b/TombLib/TombLib.Test/Tomb1MainEditorToolTipTests.cs @@ -0,0 +1,43 @@ +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Threading; +using TombLib.Scripting.Tomb1Main; + +namespace TombLib.Test; + +[TestClass] +public class Tomb1MainEditorToolTipTests +{ + [TestMethod] + public void ShowMarkdownToolTip_OpensPopupAndForceCloseClearsContent() + { + WpfTestHelper.RunInSta(() => + { + var editor = new Tomb1MainEditor(new Version(1, 0)); + Window hostWindow = WpfTestHelper.ShowInHostWindow(editor); + + try + { + editor.ShowMarkdownToolTip("`Level` tooltip"); + WpfTestHelper.PumpDispatcher(editor.Dispatcher, DispatcherPriority.Background); + + Popup popup = WpfTestHelper.GetPrivateField(editor, "_specialToolTip"); + ContentPresenter presenter = WpfTestHelper.GetPrivateField(editor, "_specialToolTipPresenter"); + + Assert.IsTrue(popup.IsOpen); + Assert.IsNotNull(presenter.Content); + + WpfTestHelper.InvokeInstanceMethod(editor, "CloseDefinitionToolTip", [typeof(bool)], true); + + Assert.IsFalse(popup.IsOpen); + Assert.IsNull(presenter.Content); + } + finally + { + hostWindow.Close(); + } + }); + } +} diff --git a/TombLib/TombLib.Test/TombEngineLanguageScriptServiceTests.cs b/TombLib/TombLib.Test/TombEngineLanguageScriptServiceTests.cs index 6f8c836bc5..f11e0e0914 100644 --- a/TombLib/TombLib.Test/TombEngineLanguageScriptServiceTests.cs +++ b/TombLib/TombLib.Test/TombEngineLanguageScriptServiceTests.cs @@ -1,5 +1,5 @@ using ICSharpCode.AvalonEdit.Document; -using TombLib.Scripting.Lua.Services; +using TombLib.Scripting.Lua.Utils; namespace TombLib.Test; @@ -74,6 +74,40 @@ public void TryInsertLanguageScript_PlacesCommaBeforeTrailingComment() StringAssert.Contains(document.Text, "newLevel = { \"New Level\" }"); } + [TestMethod] + public void TryInsertLanguageScript_IgnoresEscapedQuotesAndCommentMarkersInsideStrings() + { + var document = CreateDocument( + "local strings = {", + " existing = { \"A \\\"quoted\\\" } brace and -- marker\" } -- note", + "}", + string.Empty, + "TEN.Flow.SetStrings(strings)"); + + int? insertedLineNumber = _service.TryInsertLanguageScript(document, " newLevel = { \"New Level\" }"); + + Assert.AreEqual(3, insertedLineNumber); + StringAssert.Contains(document.Text, "existing = { \"A \\\"quoted\\\" } brace and -- marker\" }, -- note"); + StringAssert.Contains(document.Text, "newLevel = { \"New Level\" }"); + } + + [TestMethod] + public void TryInsertLanguageScript_IgnoresBracesInsideLongStrings() + { + var document = CreateDocument( + "local strings = {", + " existing = { [[A } brace inside a long string]] }", + "}", + string.Empty, + "TEN.Flow.SetStrings(strings)"); + + int? insertedLineNumber = _service.TryInsertLanguageScript(document, " newLevel = { \"New Level\" }"); + + Assert.AreEqual(3, insertedLineNumber); + StringAssert.Contains(document.Text, "existing = { [[A } brace inside a long string]] },"); + StringAssert.Contains(document.Text, "newLevel = { \"New Level\" }"); + } + [TestMethod] public void TryInsertLanguageScript_ReturnsNullWhenStringsTableIsMissing() { diff --git a/TombLib/TombLib.Test/TombLib.Test.csproj b/TombLib/TombLib.Test/TombLib.Test.csproj index 1e714767f0..81daa927ca 100644 --- a/TombLib/TombLib.Test/TombLib.Test.csproj +++ b/TombLib/TombLib.Test/TombLib.Test.csproj @@ -1,32 +1,15 @@ - net6.0-windows - 12 enable enable false true - x64;x86 - - - - none - true - - - x64 - - - x86 - - False - ..\..\Libs\ICSharpCode.AvalonEdit.dll - + @@ -35,6 +18,8 @@ + + diff --git a/TombLib/TombLib.Test/WpfTestHelper.cs b/TombLib/TombLib.Test/WpfTestHelper.cs new file mode 100644 index 0000000000..2b57a21160 --- /dev/null +++ b/TombLib/TombLib.Test/WpfTestHelper.cs @@ -0,0 +1,127 @@ +using System; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Threading; +using System.Windows; +using System.Windows.Threading; + +namespace TombLib.Test; + +internal static class WpfTestHelper +{ + public static T GetPrivateField(object instance, string fieldName) + { + FieldInfo field = FindInstanceField(instance.GetType(), fieldName) + ?? throw new InvalidOperationException($"Private field '{fieldName}' was not found."); + + return (T)(field.GetValue(instance) + ?? throw new InvalidOperationException($"Private field '{fieldName}' returned null.")); + } + + public static object? GetPrivateFieldValue(object instance, string fieldName) + { + FieldInfo field = FindInstanceField(instance.GetType(), fieldName) + ?? throw new InvalidOperationException($"Private field '{fieldName}' was not found."); + + return field.GetValue(instance); + } + + public static void SetPrivateField(object instance, string fieldName, object? value) + { + FieldInfo field = FindInstanceField(instance.GetType(), fieldName) + ?? throw new InvalidOperationException($"Private field '{fieldName}' was not found."); + + field.SetValue(instance, value); + } + + public static object? InvokeInstanceMethod(object instance, string methodName, Type[] parameterTypes, params object?[] arguments) + { + MethodInfo method = instance.GetType().GetMethod( + methodName, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + binder: null, + parameterTypes, + modifiers: null) + ?? throw new InvalidOperationException($"Instance method '{methodName}' was not found."); + + return method.Invoke(instance, arguments); + } + + public static object? InvokeStaticMethod(Type type, string methodName, Type[] parameterTypes, params object?[] arguments) + { + MethodInfo method = type.GetMethod( + methodName, + BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic, + binder: null, + parameterTypes, + modifiers: null) + ?? throw new InvalidOperationException($"Static method '{methodName}' was not found."); + + return method.Invoke(null, arguments); + } + + public static FieldInfo? FindInstanceField(Type type, string fieldName) + { + for (Type? currentType = type; currentType is not null; currentType = currentType.BaseType) + { + FieldInfo? field = currentType.GetField( + fieldName, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly); + + if (field is not null) + return field; + } + + return null; + } + + public static Window ShowInHostWindow(FrameworkElement content) + { + var window = new Window + { + Content = content, + Width = 800.0, + Height = 600.0, + ShowActivated = false, + ShowInTaskbar = false, + WindowStyle = WindowStyle.None + }; + + window.Show(); + PumpDispatcher(window.Dispatcher, DispatcherPriority.Background); + return window; + } + + public static void PumpDispatcher(Dispatcher dispatcher, DispatcherPriority priority) + => dispatcher.Invoke(priority, new Action(() => { })); + + public static void RunInSta(Action action) + { + Exception? capturedException = null; + using var completed = new ManualResetEventSlim(false); + + var thread = new Thread(() => + { + try + { + action(); + } + catch (Exception exception) + { + capturedException = exception; + } + finally + { + completed.Set(); + } + }); + + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + completed.Wait(); + thread.Join(); + + if (capturedException is not null) + ExceptionDispatchInfo.Capture(capturedException).Throw(); + } +} \ No newline at end of file diff --git a/TombLib/TombLib.WPF/BrushHelpers.cs b/TombLib/TombLib.WPF/BrushHelpers.cs index 0c2f454646..71c5d314c5 100644 --- a/TombLib/TombLib.WPF/BrushHelpers.cs +++ b/TombLib/TombLib.WPF/BrushHelpers.cs @@ -1,16 +1,32 @@ +using System; using System.Windows.Media; namespace TombLib.WPF; public static class BrushHelpers { - public static Brush CreateFrozenBrush(Color color) + public static SolidColorBrush CreateFrozenBrush(Color color) { var brush = new SolidColorBrush(color); brush.Freeze(); return brush; } + public static SolidColorBrush CreateFrozenBrush(string colorValue) + { + if (string.IsNullOrWhiteSpace(colorValue)) + throw new ArgumentException("Color value must not be empty.", nameof(colorValue)); + + object converted = ColorConverter.ConvertFromString(colorValue); + + if (converted is not Color color) + throw new ArgumentException($"'{colorValue}' is not a valid color.", nameof(colorValue)); + + var brush = new SolidColorBrush(color); + brush.Freeze(); + return brush; + } + public static Pen CreateFrozenPen(Brush brush, double thickness) { var pen = new Pen(brush, thickness); diff --git a/TombLib/TombLib.WPF/DependencyObjectExtensions.cs b/TombLib/TombLib.WPF/DependencyObjectExtensions.cs new file mode 100644 index 0000000000..2ec23ce2e1 --- /dev/null +++ b/TombLib/TombLib.WPF/DependencyObjectExtensions.cs @@ -0,0 +1,110 @@ +using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Media3D; + +namespace TombLib.WPF; + +public static class DependencyObjectExtensions +{ + public static T? FindVisualAncestor(this DependencyObject? dependencyObject) where T : DependencyObject + { + DependencyObject? ancestor = dependencyObject; + + do + ancestor = GetVisualParent(ancestor); + while (ancestor is not null and not T); + + return ancestor as T; + } + + public static T? FindVisualAncestorOrSelf(this DependencyObject? dependencyObject) where T : DependencyObject + { + if (dependencyObject is T self) + return self; + + return dependencyObject.FindVisualAncestor(); + } + + public static T? FindAncestor(this DependencyObject? dependencyObject) where T : DependencyObject + { + DependencyObject? ancestor = dependencyObject; + + do + ancestor = GetParentElement(ancestor); + while (ancestor is not null and not T); + + return ancestor as T; + } + + public static T? FindAncestorOrSelf(this DependencyObject? dependencyObject) where T : DependencyObject + { + if (dependencyObject is T self) + return self; + + return dependencyObject.FindAncestor(); + } + + public static T? FindVisualDescendant(this DependencyObject? dependencyObject) where T : DependencyObject + { + if (dependencyObject is null) + return null; + + if (dependencyObject is T self) + return self; + + if (!HasVisualChildren(dependencyObject)) + return null; + + int childCount = VisualTreeHelper.GetChildrenCount(dependencyObject); + + for (int i = 0; i < childCount; i++) + { + T? descendant = VisualTreeHelper.GetChild(dependencyObject, i).FindVisualDescendant(); + + if (descendant is not null) + return descendant; + } + + return null; + } + + public static bool IsDescendantOf(this DependencyObject? dependencyObject, DependencyObject? ancestor) + { + while (dependencyObject is not null) + { + if (ReferenceEquals(dependencyObject, ancestor)) + return true; + + dependencyObject = GetParentElement(dependencyObject); + } + + return false; + } + + private static DependencyObject? GetParentElement(DependencyObject? dependencyObject) + { + if (dependencyObject is null) + return null; + + if (dependencyObject is FrameworkContentElement contentElement) + return contentElement.Parent; + + DependencyObject? visualParent = GetVisualParent(dependencyObject); + + if (visualParent is not null) + return visualParent; + + return LogicalTreeHelper.GetParent(dependencyObject); + } + + private static DependencyObject? GetVisualParent(DependencyObject? dependencyObject) + { + if (dependencyObject is not Visual && dependencyObject is not Visual3D) + return null; + + return VisualTreeHelper.GetParent(dependencyObject); + } + + private static bool HasVisualChildren(DependencyObject dependencyObject) + => dependencyObject is Visual || dependencyObject is Visual3D; +} \ No newline at end of file diff --git a/TombLib/TombLib.WPF/TombLib.WPF.csproj b/TombLib/TombLib.WPF/TombLib.WPF.csproj index 4d40511969..d461ae8fa7 100644 --- a/TombLib/TombLib.WPF/TombLib.WPF.csproj +++ b/TombLib/TombLib.WPF/TombLib.WPF.csproj @@ -1,22 +1,9 @@  - net6.0-windows enable true true - x64;x86 - - - - none - true - - - x64 - - - x86 diff --git a/TombLib/TombLib/TombLib.csproj b/TombLib/TombLib/TombLib.csproj index 599b3f3c9c..012f41a9be 100644 --- a/TombLib/TombLib/TombLib.csproj +++ b/TombLib/TombLib/TombLib.csproj @@ -1,27 +1,8 @@  - net6.0-windows - Library false true - true - Debug;Release true - x64;x86 - - - ..\..\Build ($(Platform))\ - - - ..\..\BuildRelease ($(Platform))\ - none - true - - - x64 - - - x86 diff --git a/WadTool/WadTool.csproj b/WadTool/WadTool.csproj index aa3f17900c..acba098513 100644 --- a/WadTool/WadTool.csproj +++ b/WadTool/WadTool.csproj @@ -1,26 +1,8 @@  - net6.0-windows WinExe false true - true - Debug;Release - x64;x86 - - - ..\Build ($(Platform))\ - - - ..\BuildRelease ($(Platform))\ - none - true - - - x64 - - - x86 WT.ico