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..f4e5d1da23 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,37 @@ + + + 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..c467ae4b06 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,12 +16,13 @@ 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"); + public static string TRXColorConfigsDirectory => Path.Combine(ColorSchemesDirectory, "TRX"); #endregion Configs 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 97% rename from Installer/install_script_NET6_x64.nsi rename to Installer/install_script_x64.nsi index 9081fa5b9f..5588b1d712 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" \ @@ -271,6 +268,10 @@ Section "Uninstall" Delete "$INSTDIR\Rendering\DirectX11\SpriteShaderPS.cso" Delete "$INSTDIR\Rendering\DirectX11\RoomShaderVS.cso" Delete "$INSTDIR\Rendering\DirectX11\RoomShaderPS.cso" + Delete "$INSTDIR\Configs\TextEditors\ColorSchemes\TRX\VS15.trxsch" + Delete "$INSTDIR\Configs\TextEditors\ColorSchemes\TRX\Obsidian.trxsch" + Delete "$INSTDIR\Configs\TextEditors\ColorSchemes\TRX\NG_Center.trxsch" + Delete "$INSTDIR\Configs\TextEditors\ColorSchemes\TRX\Monokai.trxsch" Delete "$INSTDIR\Configs\TextEditors\ColorSchemes\Tomb1Main\VS15.t1msch" Delete "$INSTDIR\Configs\TextEditors\ColorSchemes\Tomb1Main\Obsidian.t1msch" Delete "$INSTDIR\Configs\TextEditors\ColorSchemes\Tomb1Main\NG_Center.t1msch" @@ -430,6 +431,8 @@ Section "Uninstall" Delete "$INSTDIR\WadTool.dll" Delete "$INSTDIR\WadTool.deps.json" Delete "$INSTDIR\TombLib.WPF.dll" + Delete "$INSTDIR\TombLib.Scripting.TRX.dll" + Delete "$INSTDIR\TombLib.Scripting.TRX.deps.json" Delete "$INSTDIR\TombLib.Scripting.Tomb1Main.dll" Delete "$INSTDIR\TombLib.Scripting.Tomb1Main.deps.json" Delete "$INSTDIR\TombLib.Scripting.Lua.dll" @@ -570,6 +573,7 @@ Section "Uninstall" RMDir "$INSTDIR\Resources\ClassicScript" RMDir "$INSTDIR\Rendering\Legacy" RMDir "$INSTDIR\Rendering\DirectX11" + RMDir "$INSTDIR\Configs\TextEditors\ColorSchemes\TRX" RMDir "$INSTDIR\Configs\TextEditors\ColorSchemes\Tomb1Main" RMDir "$INSTDIR\Configs\TextEditors\ColorSchemes\Lua" RMDir "$INSTDIR\Configs\TextEditors\ColorSchemes\GameFlowScript" 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/Tests/TombEditor.Tests/FlybyTimeline/FlybyPreviewTests.cs b/Tests/TombEditor.Tests/FlybyTimeline/FlybyPreviewTests.cs new file mode 100644 index 0000000000..2e28b3c4ac --- /dev/null +++ b/Tests/TombEditor.Tests/FlybyTimeline/FlybyPreviewTests.cs @@ -0,0 +1,96 @@ +using System.Numerics; +using TombEditor.Controls.FlybyTimeline.Preview; +using TombLib; +using TombLib.Graphics; +using TombLib.LevelData; + +namespace TombEditor.Tests.FlybyTimeline; + +[TestClass] +public class FlybyPreviewTests +{ + [TestMethod] + public void GetFrameForCamera_ConvertsToWorldSpaceAndRadians() + { + var level = FlybyTestFactory.CreateLevel(); + var room = FlybyTestFactory.CreateRoom(level, 1, new VectorInt3(1024, 256, 2048)); + var camera = FlybyTestFactory.AddCamera(room, 1, 0, new Vector3(128.0f, 64.0f, 256.0f), + rotationX: 15.0f, rotationY: 45.0f, roll: 30.0f, fov: 80.0f); + + var frame = FlybyPreview.GetFrameForCamera(camera); + + Assert.AreEqual(new Vector3(1152.0f, 320.0f, 2304.0f), frame.Position); + Assert.AreEqual(MathC.DegToRad(45.0f), frame.RotationY, 0.001f); + Assert.AreEqual(-MathC.DegToRad(15.0f), frame.RotationX, 0.001f); + Assert.AreEqual(MathC.DegToRad(30.0f), frame.Roll, 0.001f); + Assert.AreEqual(MathC.DegToRad(80.0f), frame.Fov, 0.001f); + } + + [TestMethod] + public void ApplyFrame_UpdatesCameraStateAndTarget() + { + var camera = new FreeCamera(Vector3.Zero, 0.0f, 0.0f, -MathF.PI * 0.5f, MathF.PI * 0.5f, MathC.DegToRad(60.0f)); + + var frame = new FlybyFrameState + { + Position = new Vector3(10.0f, 20.0f, 30.0f), + RotationY = MathC.DegToRad(90.0f), + RotationX = 0.0f, + Fov = MathC.DegToRad(70.0f) + }; + + FlybyPreview.ApplyFrame(camera, frame); + + Assert.AreEqual(frame.Position, camera.Position); + Assert.AreEqual(frame.RotationY, camera.RotationY, 0.001f); + Assert.AreEqual(frame.RotationX, camera.RotationX, 0.001f); + Assert.AreEqual(frame.Fov, camera.FieldOfView, 0.001f); + Assert.AreEqual(10.0f + Level.SectorSizeUnit, camera.Target.X, 0.001f); + Assert.AreEqual(20.0f, camera.Target.Y, 0.001f); + Assert.AreEqual(30.0f, camera.Target.Z, 0.001f); + } + + [TestMethod] + public void SetStaticFrame_PinsAndAppliesTheFrame() + { + var savedCamera = new FreeCamera(Vector3.Zero, 0.0f, 0.0f, -MathF.PI * 0.5f, MathF.PI * 0.5f, MathC.DegToRad(60.0f)); + using var preview = new FlybyPreview(savedCamera); + + var previewCamera = new FreeCamera(Vector3.Zero, 0.0f, 0.0f, -MathF.PI * 0.5f, MathF.PI * 0.5f, MathC.DegToRad(60.0f)); + + var frame = new FlybyFrameState + { + Position = new Vector3(64.0f, 32.0f, 16.0f), + RotationY = MathC.DegToRad(180.0f), + RotationX = MathC.DegToRad(-10.0f), + Roll = MathC.DegToRad(5.0f), + Fov = MathC.DegToRad(75.0f) + }; + + preview.SetStaticFrame(previewCamera, frame); + + Assert.IsTrue(preview.StaticFrame.HasValue); + Assert.AreEqual(frame.Position, preview.StaticFrame.Value.Position); + Assert.AreEqual(frame.Position, previewCamera.Position); + } + + [TestMethod] + public void BeginExternalUpdate_AtEndOfSequenceMarksPreviewFinished() + { + var level = FlybyTestFactory.CreateLevel(); + + FlybyTestFactory.CreateLinearSequence(level.Rooms[0], 2, + new Vector3(0.0f, 0.0f, 0.0f), + new Vector3(0.0f, 0.0f, 1024.0f)); + + var savedCamera = new FreeCamera(Vector3.Zero, 0.0f, 0.0f, -MathF.PI * 0.5f, MathF.PI * 0.5f, MathC.DegToRad(60.0f)); + using var preview = new FlybyPreview(level, 2, savedCamera); + + float playbackEnd = preview.Cache.Timing.TimelineToPlaybackTime(preview.Cache.TotalDuration + 1.0f); + + preview.BeginExternalUpdate(playbackEnd); + + Assert.IsTrue(preview.IsFinished); + Assert.AreEqual(preview.Cache.TotalDuration, preview.GetCurrentTimeSeconds(), 0.001f); + } +} diff --git a/Tests/TombEditor.Tests/FlybyTimeline/FlybySequenceCacheTests.cs b/Tests/TombEditor.Tests/FlybyTimeline/FlybySequenceCacheTests.cs new file mode 100644 index 0000000000..b496a2c304 --- /dev/null +++ b/Tests/TombEditor.Tests/FlybyTimeline/FlybySequenceCacheTests.cs @@ -0,0 +1,103 @@ +using System.Numerics; +using TombEditor.Controls.FlybyTimeline; +using TombEditor.Controls.FlybyTimeline.Sequence; + +namespace TombEditor.Tests.FlybyTimeline; + +[TestClass] +public class FlybySequenceCacheTests +{ + [TestMethod] + public void Constructor_IsInvalidWhenFewerThanTwoAssignedCamerasExist() + { + var level = FlybyTestFactory.CreateLevel(); + var camera = FlybyTestFactory.AddCamera(level.Rooms[0], 1, 0, Vector3.Zero); + var cache = FlybySequenceCache.Build([camera], useSmoothPause: false); + + Assert.IsFalse(cache.IsValid); + Assert.AreEqual(0.0f, cache.TotalDuration, 0.001f); + } + + [TestMethod] + public void SampleAtTime_ClampsToFirstAndLastFrame() + { + var level = FlybyTestFactory.CreateLevel(); + var cameras = FlybyTestFactory.CreateLinearSequence(level.Rooms[0], 2, + new Vector3(0.0f, 0.0f, 0.0f), + new Vector3(0.0f, 0.0f, 1024.0f)); + + var cache = FlybySequenceCache.Build(cameras, useSmoothPause: false); + + var firstFrame = cache.SampleAtTime(float.NegativeInfinity); + var lastFrame = cache.SampleAtTime(float.PositiveInfinity); + + Assert.AreEqual(cameras[0].Position, firstFrame.Position); + Assert.AreEqual(cameras[1].Position, lastFrame.Position); + } + + [TestMethod] + public void TimelineAndPlaybackTimeConversions_SkipCutRegions() + { + var level = FlybyTestFactory.CreateLevel(); + var cameras = FlybyTestFactory.CreateLinearSequence(level.Rooms[0], 3, + new Vector3(0.0f, 0.0f, 0.0f), + new Vector3(0.0f, 0.0f, 1024.0f), + new Vector3(0.0f, 0.0f, 2048.0f), + new Vector3(0.0f, 0.0f, 3072.0f)); + + cameras[1].Flags = FlybyConstants.FlagCameraCut; + cameras[1].Timer = 3; + + var cache = FlybySequenceCache.Build(cameras, useSmoothPause: false); + var cutRegion = cache.Timing.CutRegions[0]; + float playbackAfterCut = cache.Timing.TimelineToPlaybackTime(cutRegion.EndTime + FlybyConstants.TimeStep); + float timelineAfterCut = cache.Timing.PlaybackToTimelineTime(playbackAfterCut); + + Assert.IsTrue(playbackAfterCut < cutRegion.EndTime); + Assert.AreEqual(cutRegion.EndTime + FlybyConstants.TimeStep, timelineAfterCut, 0.001f); + } + + [TestMethod] + public void GetSpeedAtTime_ReturnsInvalidInsideCutRegionsAndOutsideRange() + { + var level = FlybyTestFactory.CreateLevel(); + var cameras = FlybyTestFactory.CreateLinearSequence(level.Rooms[0], 4, + new Vector3(0.0f, 0.0f, 0.0f), + new Vector3(0.0f, 0.0f, 1024.0f), + new Vector3(0.0f, 0.0f, 2048.0f), + new Vector3(0.0f, 0.0f, 3072.0f)); + + cameras[1].Flags = FlybyConstants.FlagCameraCut; + cameras[1].Timer = 3; + + var cache = FlybySequenceCache.Build(cameras, useSmoothPause: false); + var cutRegion = cache.Timing.CutRegions[0]; + float insideCut = (cutRegion.StartTime + cutRegion.EndTime) * 0.5f; + + Assert.AreEqual(-1.0f, cache.GetSpeedAtTime(-0.1f), 0.001f); + Assert.AreEqual(-1.0f, cache.GetSpeedAtTime(insideCut), 0.001f); + Assert.IsTrue(cache.GetSpeedAtTime(0.0f) > 0.0f); + } + + [TestMethod] + public void SampleAtTime_AfterCut_ResetsSplineFromSkippedCamera() + { + var level = FlybyTestFactory.CreateLevel(); + var cameras = FlybyTestFactory.CreateLinearSequence(level.Rooms[0], 5, + new Vector3(0.0f, 0.0f, 0.0f), + new Vector3(0.0f, 0.0f, 1024.0f), + new Vector3(1024.0f, 0.0f, 2048.0f), + new Vector3(0.0f, 0.0f, 3072.0f), + new Vector3(0.0f, 0.0f, 4096.0f)); + + cameras[1].Flags = FlybyConstants.FlagCameraCut; + cameras[1].Timer = 3; + + var cache = FlybySequenceCache.Build(cameras, useSmoothPause: false); + var cutRegion = cache.Timing.CutRegions[0]; + var firstPostCutFrame = cache.SampleAtTime(cutRegion.EndTime + FlybyConstants.TimeStep); + + Assert.AreEqual(0.0f, firstPostCutFrame.Position.X, 0.01f); + Assert.IsTrue(firstPostCutFrame.Position.Z > cameras[3].Position.Z); + } +} diff --git a/Tests/TombEditor.Tests/FlybyTimeline/FlybySequenceHelperTests.cs b/Tests/TombEditor.Tests/FlybyTimeline/FlybySequenceHelperTests.cs new file mode 100644 index 0000000000..a778c7a46e --- /dev/null +++ b/Tests/TombEditor.Tests/FlybyTimeline/FlybySequenceHelperTests.cs @@ -0,0 +1,170 @@ +using System.Numerics; +using TombEditor.Controls.FlybyTimeline; +using TombEditor.Controls.FlybyTimeline.Sequence; +using TombLib; +using TombLib.Graphics; +using TombLib.LevelData; + +namespace TombEditor.Tests.FlybyTimeline; + +[TestClass] +public class FlybySequenceHelperTests +{ + [TestMethod] + public void GetCameras_ReturnsOrderedSequenceSubsetAcrossRooms() + { + var level = FlybyTestFactory.CreateLevel(); + var firstRoom = level.Rooms[0]; + var secondRoom = FlybyTestFactory.CreateRoom(level, 1, new VectorInt3(10, 0, 0)); + + FlybyTestFactory.AddCamera(firstRoom, 7, 5, new Vector3(0.0f, 0.0f, 0.0f)); + FlybyTestFactory.AddCamera(secondRoom, 7, 2, new Vector3(128.0f, 0.0f, 0.0f)); + FlybyTestFactory.AddCamera(firstRoom, 3, 0, new Vector3(256.0f, 0.0f, 0.0f)); + FlybyTestFactory.AddCamera(secondRoom, 7, 9, new Vector3(384.0f, 0.0f, 0.0f)); + + var cameras = FlybySequenceHelper.GetCameras(level, 7); + + CollectionAssert.AreEqual(new ushort[] { 2, 5, 9 }, cameras.Select(camera => camera.Number).ToArray()); + Assert.IsTrue(cameras.All(camera => camera.Sequence == 7)); + } + + [TestMethod] + public void GetAllSequences_ReturnsDistinctSequenceIds() + { + var level = FlybyTestFactory.CreateLevel(); + var firstRoom = level.Rooms[0]; + var secondRoom = FlybyTestFactory.CreateRoom(level, 1); + + FlybyTestFactory.AddCamera(firstRoom, 2, 0, Vector3.Zero); + FlybyTestFactory.AddCamera(firstRoom, 2, 1, new Vector3(0.0f, 0.0f, 1024.0f)); + FlybyTestFactory.AddCamera(secondRoom, 9, 0, new Vector3(0.0f, 0.0f, 2048.0f)); + + var sequences = FlybySequenceHelper.GetAllSequences(level); + + CollectionAssert.AreEquivalent(new ushort[] { 2, 9 }, sequences.ToArray()); + } + + [TestMethod] + public void GetFreezeDuration_ReturnsSecondsOnlyForNonCutFreezeCameras() + { + var freezeCamera = new FlybyCameraInstance + { + Flags = FlybyConstants.FlagFreezeCamera, + Timer = FlybyTestFactory.FreezeFrames(60) + }; + + var cutFreezeCamera = new FlybyCameraInstance + { + Flags = FlybyConstants.FlagFreezeCamera | FlybyConstants.FlagCameraCut, + Timer = FlybyTestFactory.FreezeFrames(60) + }; + + Assert.AreEqual(2.0f, FlybySequenceHelper.GetFreezeDuration(freezeCamera), 0.001f); + Assert.AreEqual(0.0f, FlybySequenceHelper.GetFreezeDuration(cutFreezeCamera), 0.001f); + } + + [TestMethod] + public void FindCameraIndexAtTimeAndFindInsertionIndex_UsePrecomputedTiming() + { + var level = FlybyTestFactory.CreateLevel(); + var cameras = FlybyTestFactory.CreateLinearSequence(level.Rooms[0], 4, + new Vector3(0.0f, 0.0f, 0.0f), + new Vector3(0.0f, 0.0f, 1024.0f), + new Vector3(0.0f, 0.0f, 2048.0f)); + + var timing = FlybySequenceTiming.Build(cameras, useSmoothPause: false); + float midpoint = (timing.GetCameraTime(0) + timing.GetCameraTime(1)) * 0.5f; + float slightlyAfterMidpoint = midpoint + 0.01f; + + Assert.AreEqual(0, FlybySequenceHelper.FindCameraIndexAtTime(cameras, midpoint * 0.5f, timing)); + Assert.AreEqual(0, FlybySequenceHelper.FindCameraIndexAtTime(cameras, midpoint, timing)); + Assert.AreEqual(1, FlybySequenceHelper.FindCameraIndexAtTime(cameras, slightlyAfterMidpoint, timing)); + Assert.AreEqual(2, FlybySequenceHelper.FindCameraIndexAtTime(cameras, timing.GetCameraTime(2), timing)); + Assert.AreEqual(1, FlybySequenceHelper.FindInsertionIndex(cameras, midpoint, timing)); + Assert.AreEqual(cameras.Count, FlybySequenceHelper.FindInsertionIndex(cameras, -0.01f, timing)); + Assert.AreEqual(cameras.Count, FlybySequenceHelper.FindInsertionIndex(cameras, timing.GetCameraTime(cameras.Count - 1), timing)); + Assert.AreEqual(cameras.Count, FlybySequenceHelper.FindInsertionIndex(cameras, float.NaN, timing)); + } + + [TestMethod] + public void FormattersAndFlagHelpers_HandleEdgeCases() + { + Assert.AreEqual("00:01.23", FlybySequenceHelper.FormatTimecode(1.239f)); + Assert.AreEqual("1.24", FlybySequenceHelper.FormatRulerLabel(1.239f)); + Assert.AreEqual("00:00.00", FlybySequenceHelper.FormatTimecode(float.PositiveInfinity)); + Assert.AreEqual("0.00", FlybySequenceHelper.FormatRulerLabel(float.NaN)); + + Assert.IsTrue(FlybySequenceHelper.GetFlagBit(1 << 15, 15)); + Assert.IsFalse(FlybySequenceHelper.GetFlagBit(ushort.MaxValue, 16)); + Assert.IsTrue(FlybySequenceHelper.GetFlagBit(1 << 7, 7)); + Assert.IsFalse(FlybySequenceHelper.GetFlagBit(0, 20)); + Assert.AreEqual((ushort)(1 << 3), FlybySequenceHelper.SetFlagBit(0, 3, true)); + Assert.AreEqual((ushort)0, FlybySequenceHelper.SetFlagBit(1 << 3, 3, false)); + } + + [TestMethod] + public void CameraListsMatchByReference_RequiresSameInstancesInSameOrder() + { + var first = new FlybyCameraInstance(); + var second = new FlybyCameraInstance(); + + IReadOnlyList original = [first, second]; + IReadOnlyList sameOrder = [first, second]; + IReadOnlyList reversed = [second, first]; + IReadOnlyList differentInstance = [first, new FlybyCameraInstance()]; + + Assert.IsTrue(FlybySequenceHelper.CameraListsMatchByReference(original, sameOrder)); + Assert.IsFalse(FlybySequenceHelper.CameraListsMatchByReference(original, reversed)); + Assert.IsFalse(FlybySequenceHelper.CameraListsMatchByReference(original, differentInstance)); + Assert.IsFalse(FlybySequenceHelper.CameraListsMatchByReference(original, null)); + } + + [TestMethod] + public void SolveSegmentSpeedForTargetTime_AdjustsTimingTowardRequestedTarget() + { + var level = FlybyTestFactory.CreateLevel(); + var cameras = FlybyTestFactory.CreateLinearSequence(level.Rooms[0], 5, + new Vector3(0.0f, 0.0f, 0.0f), + new Vector3(0.0f, 0.0f, 1024.0f), + new Vector3(0.0f, 0.0f, 2048.0f)); + + var originalTiming = FlybySequenceTiming.Build(cameras, useSmoothPause: false); + float targetTime = originalTiming.GetCameraTime(1) * 0.6f; + + float solvedSpeed = FlybySequenceHelper.SolveSegmentSpeedForTargetTime(cameras, 0, 1, targetTime, useSmoothPause: false); + cameras[0].Speed = solvedSpeed; + + var adjustedTiming = FlybySequenceTiming.Build(cameras, useSmoothPause: false); + + Assert.IsTrue(solvedSpeed >= FlybyConstants.MinSpeed); + Assert.AreEqual(targetTime, adjustedTiming.GetCameraTime(1), 0.05f); + } + + [TestMethod] + public void SnapSpeedToStep_RoundsToNearestIncrement() + { + float snappedDown = FlybySequenceHelper.SnapSpeedToStep(1.124f, FlybyConstants.TimelineDragSpeedStep); + float snappedUp = FlybySequenceHelper.SnapSpeedToStep(1.126f, FlybyConstants.TimelineDragSpeedStep); + + Assert.AreEqual(1.12f, snappedDown, 0.001f); + Assert.AreEqual(1.13f, snappedUp, 0.001f); + } + + [TestMethod] + public void ApplyEditorCameraRotation_CopiesOrientationAndFov() + { + var editorCamera = new FreeCamera(new Vector3(10.0f, 20.0f, 30.0f), 0.0f, 0.0f, + -MathF.PI * 0.5f, MathF.PI * 0.5f, MathC.DegToRad(70.0f)) + { + Target = new Vector3(1010.0f, -480.0f, 530.0f) + }; + + var flyby = new FlybyCameraInstance(); + + FlybySequenceHelper.ApplyEditorCameraRotation(editorCamera, flyby); + + Assert.AreEqual(63.4349f, flyby.RotationY, 0.01f); + Assert.AreEqual(-24.0948f, flyby.RotationX, 0.01f); + Assert.AreEqual(70.0f, flyby.Fov, 0.001f); + } +} diff --git a/Tests/TombEditor.Tests/FlybyTimeline/FlybySequenceTimingTests.cs b/Tests/TombEditor.Tests/FlybyTimeline/FlybySequenceTimingTests.cs new file mode 100644 index 0000000000..72ad1fc9d8 --- /dev/null +++ b/Tests/TombEditor.Tests/FlybyTimeline/FlybySequenceTimingTests.cs @@ -0,0 +1,222 @@ +using System.Numerics; +using TombEditor.Controls.FlybyTimeline; +using TombEditor.Controls.FlybyTimeline.Sequence; +using TombLib.LevelData; + +namespace TombEditor.Tests.FlybyTimeline; + +[TestClass] +public class FlybySequenceTimingTests +{ + [TestMethod] + public void Build_ReturnsEmptyTimingForEmptySequence() + { + var timing = FlybySequenceTiming.Build([], useSmoothPause: false); + + Assert.AreEqual(0, timing.CameraCount); + Assert.AreEqual(0.0f, timing.TotalDuration, 0.001f); + Assert.AreEqual(0, timing.SplineTimeline.Count); + Assert.AreEqual(0, timing.CutRegions.Count); + } + + [TestMethod] + public void Build_AddsFreezeDurationToLaterCameraTimes() + { + var level = FlybyTestFactory.CreateLevel(); + var cameras = FlybyTestFactory.CreateLinearSequence(level.Rooms[0], 1, + new Vector3(0.0f, 0.0f, 0.0f), + new Vector3(0.0f, 0.0f, 1024.0f), + new Vector3(0.0f, 0.0f, 2048.0f)); + + cameras[1].Flags = FlybyConstants.FlagFreezeCamera; + cameras[1].Timer = FlybyTestFactory.FreezeFrames(30); + + var timing = FlybySequenceTiming.Build(cameras, useSmoothPause: false); + + Assert.AreEqual(1.0f, timing.GetFreezeDuration(1), 0.001f); + Assert.IsTrue(timing.GetCameraTime(2) - timing.GetCameraTime(1) >= 1.0f); + } + + [TestMethod] + public void Build_WithSmoothPauseDelaysTheFreezeBoundary() + { + var level = FlybyTestFactory.CreateLevel(TRVersion.Game.TombEngine); + var cameras = FlybyTestFactory.CreateLinearSequence(level.Rooms[0], 2, + new Vector3(0.0f, 0.0f, 0.0f), + new Vector3(0.0f, 0.0f, 1024.0f), + new Vector3(0.0f, 0.0f, 2048.0f)); + + cameras[1].Flags = FlybyConstants.FlagFreezeCamera; + cameras[1].Timer = FlybyTestFactory.FreezeFrames(30); + + var standardTiming = FlybySequenceTiming.Build(cameras, useSmoothPause: false); + var smoothTiming = FlybySequenceTiming.Build(cameras, useSmoothPause: true); + + Assert.IsTrue(smoothTiming.GetCameraTime(1) > standardTiming.GetCameraTime(1)); + Assert.IsTrue(smoothTiming.TotalDuration > standardTiming.TotalDuration); + } + + [TestMethod] + public void Build_WithFinalFreeze_KeepsSequenceEndAligned() + { + var level = FlybyTestFactory.CreateLevel(); + var cameras = FlybyTestFactory.CreateLinearSequence(level.Rooms[0], 3, + new Vector3(0.0f, 0.0f, 0.0f), + new Vector3(0.0f, 0.0f, 1024.0f), + new Vector3(0.0f, 0.0f, 2048.0f), + new Vector3(0.0f, 0.0f, 3072.0f)); + + cameras[3].Flags = FlybyConstants.FlagFreezeCamera; + cameras[3].Timer = FlybyTestFactory.FreezeFrames(15); + + var timing = FlybySequenceTiming.Build(cameras, useSmoothPause: false); + float expectedEndTime = timing.GetCameraTime(3) + timing.GetFreezeDuration(3); + + Assert.AreEqual(expectedEndTime, timing.TotalDuration, 0.001f); + } + + [TestMethod] + public void Build_CapturesCutRegionsAndBypassDurations() + { + var level = FlybyTestFactory.CreateLevel(); + var cameras = FlybyTestFactory.CreateLinearSequence(level.Rooms[0], 3, + new Vector3(0.0f, 0.0f, 0.0f), + new Vector3(0.0f, 0.0f, 1024.0f), + new Vector3(0.0f, 0.0f, 2048.0f), + new Vector3(0.0f, 0.0f, 3072.0f)); + + cameras[1].Flags = FlybyConstants.FlagCameraCut; + cameras[1].Timer = 3; + + var timing = FlybySequenceTiming.Build(cameras, useSmoothPause: false); + + Assert.AreEqual(1, timing.CutRegions.Count); + Assert.IsTrue(timing.GetCutBypassDuration(1) > 0.0f); + Assert.IsTrue(timing.CutRegions[0].EndTime > timing.CutRegions[0].StartTime); + Assert.IsTrue(timing.TotalDuration >= timing.GetCameraTime(3)); + } + + [TestMethod] + public void Build_ResolvesCutTargetsByCameraNumber() + { + var level = FlybyTestFactory.CreateLevel(); + var cameras = FlybyTestFactory.CreateLinearSequence(level.Rooms[0], 5, + new Vector3(0.0f, 0.0f, 0.0f), + new Vector3(0.0f, 0.0f, 1024.0f), + new Vector3(0.0f, 0.0f, 2048.0f), + new Vector3(0.0f, 0.0f, 3072.0f)); + + cameras[0].Number = 0; + cameras[1].Number = 2; + cameras[2].Number = 4; + cameras[3].Number = 6; + + cameras[1].Flags = FlybyConstants.FlagCameraCut; + cameras[1].Timer = 4; + + var timing = FlybySequenceTiming.Build(cameras, useSmoothPause: false); + float expectedBypass = timing.GetCameraTime(2) - timing.GetCameraTime(1); + float cutRegionDuration = timing.CutRegions[0].EndTime - timing.CutRegions[0].StartTime; + + Assert.AreEqual(1, timing.CutRegions.Count); + Assert.AreEqual(expectedBypass, timing.GetCutBypassDuration(1), 0.001f); + Assert.AreEqual(expectedBypass, cutRegionDuration, FlybyConstants.TimeStep); + } + + [TestMethod] + public void Build_IgnoresAmbiguousCutTargetsWhenCameraNumbersDuplicate() + { + var level = FlybyTestFactory.CreateLevel(); + var cameras = FlybyTestFactory.CreateLinearSequence(level.Rooms[0], 6, + new Vector3(0.0f, 0.0f, 0.0f), + new Vector3(0.0f, 0.0f, 1024.0f), + new Vector3(0.0f, 0.0f, 2048.0f), + new Vector3(0.0f, 0.0f, 3072.0f)); + + cameras[0].Number = 0; + cameras[1].Number = 2; + cameras[2].Number = 2; + cameras[3].Number = 4; + + cameras[0].Flags = FlybyConstants.FlagCameraCut; + cameras[0].Timer = 2; + + var timing = FlybySequenceTiming.Build(cameras, useSmoothPause: false); + + Assert.AreEqual(0.0f, timing.GetCutBypassDuration(0), 0.001f); + Assert.AreEqual(0, timing.CutRegions.Count); + } + + [TestMethod] + public void Build_WithSmoothPauseCutBypassMatchesSkippedFreezeTiming() + { + var level = FlybyTestFactory.CreateLevel(TRVersion.Game.TombEngine); + var cameras = FlybyTestFactory.CreateLinearSequence(level.Rooms[0], 7, + new Vector3(0.0f, 0.0f, 0.0f), + new Vector3(0.0f, 0.0f, 1024.0f), + new Vector3(0.0f, 0.0f, 2048.0f), + new Vector3(0.0f, 0.0f, 3072.0f)); + + cameras[1].Flags = FlybyConstants.FlagCameraCut; + cameras[1].Timer = 3; + cameras[2].Flags = FlybyConstants.FlagFreezeCamera; + cameras[2].Timer = FlybyTestFactory.FreezeFrames(30); + + var timing = FlybySequenceTiming.Build(cameras, useSmoothPause: true); + float expectedBypass = timing.GetCameraTime(3) - timing.GetCameraTime(1); + + Assert.AreEqual(1, timing.CutRegions.Count); + Assert.AreEqual(expectedBypass, timing.CutRegions[0].Duration, FlybyConstants.TimeStep); + } + + [TestMethod] + public void Build_WithSmoothPauseAndFrozenCutTarget_KeepsFinalCameraAligned() + { + var level = FlybyTestFactory.CreateLevel(TRVersion.Game.TombEngine); + var cameras = FlybyTestFactory.CreateLinearSequence(level.Rooms[0], 8, + new Vector3(0.0f, 0.0f, 0.0f), + new Vector3(0.0f, 0.0f, 1024.0f), + new Vector3(0.0f, 0.0f, 2048.0f), + new Vector3(0.0f, 0.0f, 3072.0f), + new Vector3(0.0f, 0.0f, 4096.0f)); + + cameras[1].Flags = FlybyConstants.FlagCameraCut; + cameras[1].Timer = 3; + cameras[3].Flags = FlybyConstants.FlagFreezeCamera; + cameras[3].Timer = FlybyTestFactory.FreezeFrames(30); + + var timing = FlybySequenceTiming.Build(cameras, useSmoothPause: true); + float finalCameraTime = timing.GetCameraTime(4); + + Assert.IsTrue(timing.TotalDuration >= finalCameraTime); + Assert.AreEqual(finalCameraTime, timing.TotalDuration, FlybyConstants.TimeStep); + } + + [TestMethod] + public void Build_CompletesForLongSlowSequences() + { + const int cameraCount = 130; + + var level = FlybyTestFactory.CreateLevel(); + var positions = new Vector3[cameraCount]; + + for (int i = 0; i < positions.Length; i++) + positions[i] = new Vector3(0.0f, 0.0f, i * 1024.0f); + + var cameras = FlybyTestFactory.CreateLinearSequence(level.Rooms[0], 4, positions); + + foreach (var camera in cameras) + camera.Speed = FlybyConstants.MinSpeed; + + var task = Task.Run(() => FlybySequenceTiming.Build(cameras, useSmoothPause: false)); + + Assert.IsTrue(task.Wait(TimeSpan.FromSeconds(5.0))); + + var timing = task.Result; + float lastCameraTime = timing.GetCameraTime(cameras.Count - 1); + + Assert.AreEqual(cameraCount, timing.CameraCount); + Assert.IsTrue(float.IsFinite(lastCameraTime)); + Assert.IsTrue(lastCameraTime > 0.0f); + } +} diff --git a/Tests/TombEditor.Tests/FlybyTimeline/FlybyTestFactory.cs b/Tests/TombEditor.Tests/FlybyTimeline/FlybyTestFactory.cs new file mode 100644 index 0000000000..94888467dd --- /dev/null +++ b/Tests/TombEditor.Tests/FlybyTimeline/FlybyTestFactory.cs @@ -0,0 +1,58 @@ +using System.Numerics; +using TombLib; +using TombLib.LevelData; + +namespace TombEditor.Tests.FlybyTimeline; + +internal static class FlybyTestFactory +{ + public static Level CreateLevel(TRVersion.Game version = TRVersion.Game.TR4) + => Level.CreateSimpleLevel(version); + + public static Room CreateRoom(Level level, int roomIndex, VectorInt3? worldPos = null) + { + var room = new Room(level, Room.DefaultRoomDimensions, Room.DefaultRoomDimensions, level.Settings.DefaultAmbientLight, $"Room {roomIndex}"); + + if (worldPos is not null) + room.WorldPos = worldPos.Value; + + level.Rooms[roomIndex] = room; + return room; + } + + public static FlybyCameraInstance AddCamera(Room room, ushort sequence, ushort number, Vector3 position, + float speed = 1.0f, short timer = 0, ushort flags = 0, float rotationX = 0.0f, float rotationY = 0.0f, + float roll = 0.0f, float fov = 80.0f) + { + var camera = new FlybyCameraInstance + { + Sequence = sequence, + Number = number, + Position = position, + Speed = speed, + Timer = timer, + Flags = flags, + RotationX = rotationX, + RotationY = rotationY, + Roll = roll, + Fov = fov + }; + + room.AddObject(room.Level, camera); + return camera; + } + + public static IReadOnlyList CreateLinearSequence(Room room, ushort sequence, + params Vector3[] positions) + { + var cameras = new List(positions.Length); + + for (ushort i = 0; i < positions.Length; i++) + cameras.Add(AddCamera(room, sequence, i, positions[i])); + + return cameras; + } + + public static short FreezeFrames(int frames) + => (short)(frames << 4); +} diff --git a/Tests/TombEditor.Tests/ScriptingStudio/StudioContributionSurfaceTests.cs b/Tests/TombEditor.Tests/ScriptingStudio/StudioContributionSurfaceTests.cs new file mode 100644 index 0000000000..7265aed6ea --- /dev/null +++ b/Tests/TombEditor.Tests/ScriptingStudio/StudioContributionSurfaceTests.cs @@ -0,0 +1,70 @@ +using System.Windows.Forms; +using TombIDE.ScriptingStudio.ToolStrips; +using TombIDE.ScriptingStudio.UI; + +namespace TombEditor.Tests.ScriptingStudio; + +[TestClass] +public class StudioContributionSurfaceTests +{ + [TestMethod] + public void RebuildStudioModeItems_MenuStrip_UsesContributionsWhenStudioModeIsNone() + { + var menuStrip = new StudioMenuStrip + { + StudioMode = StudioMode.None, + StudioModeContributionItems = + new[] + { + new StudioToolStripItem + { + LangKey = "TestRoot", + Position = "0", + DropDownItems = + new List + { + new StudioToolStripItem + { + LangKey = "TestCommand", + Command = nameof(UICommand.About) + } + } + } + } + }; + + menuStrip.RebuildStudioModeItems(); + + Assert.AreEqual(1, menuStrip.Items.Count); + Assert.IsTrue(menuStrip.Items[0] is ToolStripMenuItem); + + var rootItem = menuStrip.Items[0] as ToolStripMenuItem ?? throw new AssertFailedException(); + Assert.AreEqual(1, rootItem.DropDownItems.Count); + var rootArgs = rootItem.DropDownItems[0].Tag as UIElementArgs ?? throw new AssertFailedException(); + Assert.AreEqual(UICommand.About, rootArgs.Command); + } + + [TestMethod] + public void RebuildStudioModeItems_ToolStrip_UsesContributionsWhenStudioModeIsNone() + { + var toolStrip = new StudioToolStrip + { + StudioMode = StudioMode.None, + StudioModeContributionItems = + new[] + { + new StudioToolStripItem + { + LangKey = "TestCommand", + Command = nameof(UICommand.About) + } + } + }; + + toolStrip.RebuildStudioModeItems(); + + Assert.AreEqual(1, toolStrip.Items.Count); + var toolStripArgs = toolStrip.Items[0].Tag as UIElementArgs ?? throw new AssertFailedException(); + Assert.AreEqual(UICommand.About, toolStripArgs.Command); + } +} \ No newline at end of file diff --git a/TombEditor.Tests/TombEditor.Tests.csproj b/Tests/TombEditor.Tests/TombEditor.Tests.csproj similarity index 57% rename from TombEditor.Tests/TombEditor.Tests.csproj rename to Tests/TombEditor.Tests/TombEditor.Tests.csproj index f0ad47e018..ddfabd0342 100644 --- a/TombEditor.Tests/TombEditor.Tests.csproj +++ b/Tests/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 @@ -32,7 +18,8 @@ - + + diff --git a/Tests/TombLib.LanguageServer.Core.Tests/Client/LanguageServerClientTests.BackgroundLoops.cs b/Tests/TombLib.LanguageServer.Core.Tests/Client/LanguageServerClientTests.BackgroundLoops.cs new file mode 100644 index 0000000000..b1a4aecb6c --- /dev/null +++ b/Tests/TombLib.LanguageServer.Core.Tests/Client/LanguageServerClientTests.BackgroundLoops.cs @@ -0,0 +1,199 @@ +using NLog; +using System.Diagnostics; +using System.Text.Json; + +namespace TombLib.LanguageServer.Core.Tests; + +public partial class LanguageServerClientTests +{ + [TestMethod] + public async Task WaitForBackgroundLoopsAsync_WhenLoopFaults_LogsSpecificWarningWithoutFallback() + { + using var logScope = new NLogMemoryScope(LogLevel.Debug); + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + + await InvokePrivateTaskAsync(client, "WaitForBackgroundLoopsAsync", + Task.FromException(new IOException("Simulated loop failure.")), + Task.CompletedTask).ConfigureAwait(false); + + Assert.IsTrue(logScope.Logs.Any(log => log.Contains("background loop", StringComparison.OrdinalIgnoreCase) + && log.Contains("Simulated loop failure.", StringComparison.Ordinal)), + string.Join(Environment.NewLine, logScope.Logs)); + + Assert.IsFalse(logScope.Logs.Any(log => log.Contains("Language server background loop failed during disposal.", StringComparison.Ordinal)), + string.Join(Environment.NewLine, logScope.Logs)); + } + + [TestMethod] + public async Task ObserveBackgroundLoop_WhenLoopFaultsBeforeDisposal_LogsImmediateWarning() + { + using var logScope = new NLogMemoryScope(LogLevel.Warn); + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + + InvokePrivateMethod(client, + "ObserveBackgroundLoop", + Task.FromException(new IOException("Simulated callback pump failure.")), + "callback dispatcher", + true); + + await Task.Delay(50).ConfigureAwait(false); + + Assert.IsTrue(logScope.Logs.Any(log => log.StartsWith("Warn|", StringComparison.Ordinal) + && log.Contains("background loop", StringComparison.OrdinalIgnoreCase) + && log.Contains("callback dispatcher", StringComparison.OrdinalIgnoreCase) + && log.Contains("terminated unexpectedly", StringComparison.OrdinalIgnoreCase) + && log.Contains("Simulated callback pump failure.", StringComparison.Ordinal)), + string.Join(Environment.NewLine, logScope.Logs)); + } + + [TestMethod] + public async Task ObserveBackgroundLoop_WhenTrackedPumpFaults_MarksReadyClientUnhealthy() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object session = CreateTransportSession(client, 4, process: null, Stream.Null, Stream.Null); + + SetActiveSession(client, session); + SetReadyState(client, true); + + InvokePrivateMethod(client, + "ObserveBackgroundLoop", + Task.FromException(new IOException("Simulated callback pump failure.")), + "callback dispatcher", + true); + + await Task.Delay(50).ConfigureAwait(false); + + Assert.IsFalse(client.IsReady); + + await Assert.ThrowsExceptionAsync(async () => + await client.SendRequestAsync( + "workspace/configuration", + new WorkspaceConfigurationParams([]), + CancellationToken.None).ConfigureAwait(false)).ConfigureAwait(false); + } + + [TestMethod] + public void EnsureTransportBackgroundLoopsRunning_WhenCallbackPumpFaulted_ReplacesTrackedCallbackPumpTask() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + Task faultedCallbackPump = Task.FromException(new IOException("Simulated callback pump failure.")); + + SetPrivateField(client, "_callbackPumpTask", faultedCallbackPump); + + InvokePrivateMethod(client, "EnsureTransportBackgroundLoopsRunning", false); + + Task replacementTask = (Task)GetPrivateField(client, "_callbackPumpTask"); + + Assert.AreNotSame(faultedCallbackPump, replacementTask); + Assert.IsFalse(replacementTask.IsCompleted, "Restart recovery should recreate the callback pump instead of keeping the faulted task tracked."); + } + + [TestMethod] + public async Task EnsureTransportBackgroundLoopsRunning_WhenFaultedPumpIsReplaced_ForgetsObservedTermination() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + Task faultedCallbackPump = Task.FromException(new IOException("Simulated callback pump failure.")); + + SetPrivateField(client, "_callbackPumpTask", faultedCallbackPump); + InvokePrivateMethod(client, + "ObserveBackgroundLoop", + faultedCallbackPump, + "callback dispatcher", + true); + + await Task.Delay(50).ConfigureAwait(false); + + Assert.IsTrue((bool)InvokePrivateMethodWithReturn(client, "WasObservedBackgroundLoopTermination", faultedCallbackPump)); + + InvokePrivateMethod(client, "EnsureTransportBackgroundLoopsRunning", false); + + Assert.IsFalse((bool)InvokePrivateMethodWithReturn(client, "WasObservedBackgroundLoopTermination", faultedCallbackPump)); + } + + [TestMethod] + public async Task EnsureTransportBackgroundLoopsRunning_WhenDiagnosticsPumpFaulted_RestartRecoveryStillPublishesDiagnostics() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object originalSession = CreateTransportSession(client, 4, process: null, Stream.Null, Stream.Null); + var publishedMessage = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Task faultedDiagnosticsPump = Task.FromException(new IOException("Simulated diagnostics pump failure.")); + + SetActiveSession(client, originalSession); + SetReadyState(client, true); + + client.DiagnosticsPublished += parameters => publishedMessage.TrySetResult(parameters.Diagnostics?[0].Message); + + SetPrivateField(client, "_diagnosticsPumpTask", faultedDiagnosticsPump); + + InvokePrivateMethod(client, + "ObserveBackgroundLoop", + faultedDiagnosticsPump, + "diagnostics pump", + true); + + await Task.Delay(50).ConfigureAwait(false); + + Assert.IsFalse(client.IsReady); + + object restartedSession = CreateTransportSession(client, 5, process: null, Stream.Null, Stream.Null); + + SetActiveSession(client, restartedSession); + InvokePrivateMethod(client, "EnsureTransportBackgroundLoopsRunning", true); + SetReadyState(client, true); + + InvokePrivateMethod(client, + "RaiseDiagnosticsPublished", + GetTransportGeneration(restartedSession), + CreateDiagnosticsParameters("file:///C:/Workspace/test.lua", "Recovered warning.")); + + Assert.AreEqual("Recovered warning.", + await publishedMessage.Task.WaitAsync(TimeSpan.FromSeconds(1)).ConfigureAwait(false)); + } + + [TestMethod] + public async Task WaitWithDisposeBudgetAsync_WhenLoopAlreadyLogged_DoesNotLogDuplicateDisposalWarning() + { + using var logScope = new NLogMemoryScope(LogLevel.Warn); + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + Task faultedLoopTask = Task.FromException(new IOException("Simulated callback pump failure.")); + + InvokePrivateMethod(client, "ObserveBackgroundLoop", faultedLoopTask, "callback dispatcher", true); + + await Task.Delay(50).ConfigureAwait(false); + + await InvokePrivateTaskAsync(client, + "WaitWithDisposeBudgetAsync", + faultedLoopTask, + Stopwatch.StartNew(), + "Language server callback dispatcher did not complete within {TimeoutMs} ms during disposal.", + "Disposing the language server callback dispatcher raised exceptions.").ConfigureAwait(false); + + Assert.IsFalse(logScope.Logs.Any(log => log.Contains("Disposing the language server callback dispatcher raised exceptions.", StringComparison.Ordinal)), + string.Join(Environment.NewLine, logScope.Logs)); + + Assert.AreEqual(1, logScope.Logs.Count(log => log.StartsWith("Warn|", StringComparison.Ordinal) + && log.Contains("callback dispatcher", StringComparison.OrdinalIgnoreCase) + && log.Contains("Simulated callback pump failure.", StringComparison.Ordinal)), + string.Join(Environment.NewLine, logScope.Logs)); + } + + [TestMethod] + public async Task WaitForBackgroundLoopsAsync_WhenLoopIsCanceled_LogsDebugWithoutWarning() + { + using var logScope = new NLogMemoryScope(LogLevel.Debug); + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + + await InvokePrivateTaskAsync(client, "WaitForBackgroundLoopsAsync", + Task.FromCanceled(new CancellationToken(canceled: true)), + Task.CompletedTask).ConfigureAwait(false); + + Assert.IsTrue(logScope.Logs.Any(log => log.StartsWith("Debug|", StringComparison.Ordinal) + && log.Contains("was canceled during disposal", StringComparison.OrdinalIgnoreCase) + && log.Contains("JSON-RPC completion", StringComparison.OrdinalIgnoreCase)), + string.Join(Environment.NewLine, logScope.Logs)); + + Assert.IsFalse(logScope.Logs.Any(log => log.StartsWith("Warn|", StringComparison.Ordinal) + && log.Contains("background loop", StringComparison.OrdinalIgnoreCase)), + string.Join(Environment.NewLine, logScope.Logs)); + } +} diff --git a/Tests/TombLib.LanguageServer.Core.Tests/Client/LanguageServerClientTests.Capabilities.cs b/Tests/TombLib.LanguageServer.Core.Tests/Client/LanguageServerClientTests.Capabilities.cs new file mode 100644 index 0000000000..98151a1e43 --- /dev/null +++ b/Tests/TombLib.LanguageServer.Core.Tests/Client/LanguageServerClientTests.Capabilities.cs @@ -0,0 +1,271 @@ +using System.Reflection; + +namespace TombLib.LanguageServer.Core.Tests; + +public partial class LanguageServerClientTests +{ + [TestMethod] + public void FreshClient_ExposesConservativeCapabilitiesBeforeStartup() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + + Assert.IsFalse(client.IsReady); + Assert.AreEqual(0L, client.TransportGeneration); + Assert.AreEqual(TextDocumentSyncKind.None, client.TextDocumentSyncKind); + Assert.AreEqual(0, client.SemanticTokenTypes.Count); + Assert.AreEqual(0, client.SemanticTokenModifiers.Count); + Assert.IsFalse(client.SupportsCompletionResolve); + Assert.IsFalse(client.SupportsReferences); + Assert.IsFalse(client.SupportsRename); + Assert.IsFalse(client.SupportsFormatting); + Assert.IsFalse(client.SupportsSemanticTokensFull); + Assert.IsFalse(client.SupportsSemanticTokensDelta); + } + + [TestMethod] + public void ActiveSessionBeforeHandshake_ExposesConservativeCapabilities() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object session = CreateTransportSession(client, 3, process: null, Stream.Null, Stream.Null); + + SetActiveSession(client, session); + + Assert.IsFalse(client.IsReady); + Assert.AreEqual(3L, client.TransportGeneration); + Assert.AreEqual(TextDocumentSyncKind.None, client.TextDocumentSyncKind); + Assert.AreEqual(0, client.SemanticTokenTypes.Count); + Assert.AreEqual(0, client.SemanticTokenModifiers.Count); + Assert.IsFalse(client.SupportsCompletionResolve); + Assert.IsFalse(client.SupportsReferences); + Assert.IsFalse(client.SupportsRename); + Assert.IsFalse(client.SupportsFormatting); + Assert.IsFalse(client.SupportsSemanticTokensFull); + Assert.IsFalse(client.SupportsSemanticTokensDelta); + } + + [TestMethod] + public void CaptureServerCapabilities_UsesFullTextSyncWhenServerAdvertisesFullSync() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + + InvokePrivateMethod(client, "CaptureServerCapabilities", DeserializeInitializeResponse( + """ + { + "capabilities": { + "textDocumentSync": { + "change": 1 + } + } + } + """)); + + Assert.AreEqual(TextDocumentSyncKind.Full, client.TextDocumentSyncKind); + } + + [TestMethod] + public void CaptureServerCapabilities_RejectsMissingDocumentChangeSupport() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + + 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 LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + + InvokePrivateMethod(client, "CaptureServerCapabilities", DeserializeInitializeResponse( + """ + { + "capabilities": { + "textDocumentSync": { + "change": 1 + }, + "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 LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + + InvokePrivateMethod(client, "CaptureServerCapabilities", DeserializeInitializeResponse( + """ + { + "capabilities": { + "textDocumentSync": { + "change": 1 + }, + "semanticTokensProvider": { + "full": { + "delta": true + }, + "legend": { + "tokenTypes": ["function", "variable"], + "tokenModifiers": ["declaration"] + } + } + } + } + """)); + + Assert.IsTrue(client.SupportsSemanticTokensDelta); + Assert.IsTrue(client.SupportsSemanticTokensFull); + CollectionAssert.AreEqual(new[] { "function", "variable" }, client.SemanticTokenTypes.ToArray()); + CollectionAssert.AreEqual(new[] { "declaration" }, client.SemanticTokenModifiers.ToArray()); + } + + [TestMethod] + public void CaptureServerCapabilities_TracksWhenFullSemanticTokensAreUnsupported() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + + InvokePrivateMethod(client, "CaptureServerCapabilities", DeserializeInitializeResponse( + """ + { + "capabilities": { + "textDocumentSync": { + "change": 1 + }, + "semanticTokensProvider": { + "full": false, + "legend": { + "tokenTypes": ["function"], + "tokenModifiers": [] + } + } + } + } + """)); + + Assert.IsFalse(client.SupportsSemanticTokensFull); + Assert.IsFalse(client.SupportsSemanticTokensDelta); + CollectionAssert.AreEqual(new[] { "function" }, client.SemanticTokenTypes.ToArray()); + } + + [TestMethod] + public void CaptureServerCapabilities_RejectsMissingCapabilitiesPayload() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + + TargetInvocationException exception = Assert.ThrowsException(() => + InvokePrivateMethod(client, "CaptureServerCapabilities", DeserializeInitializeResponse("""{}"""))); + + Assert.IsInstanceOfType(exception.InnerException, typeof(NotSupportedException)); + } + + [TestMethod] + public void CaptureServerCapabilities_RejectsMissingTextDocumentSyncCapability() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + + TargetInvocationException exception = Assert.ThrowsException(() => + InvokePrivateMethod(client, "CaptureServerCapabilities", DeserializeInitializeResponse( + """ + { + "capabilities": { + "referencesProvider": true, + "renameProvider": true + } + } + """))); + + Assert.IsInstanceOfType(exception.InnerException, typeof(NotSupportedException)); + } + + [TestMethod] + public void DeserializeInitializeResponse_UnsupportedCapabilityShapesDegradePredictably() + { + InitializeResponse response = DeserializeInitializeResponse( + """ + { + "capabilities": { + "textDocumentSync": { + "change": "invalid" + }, + "referencesProvider": "unexpected", + "renameProvider": [true], + "documentFormattingProvider": 123, + "semanticTokensProvider": { + "full": [], + "legend": { + "tokenTypes": ["function"], + "tokenModifiers": ["declaration"] + } + } + } + } + """); + + Assert.IsNotNull(response.Capabilities); + Assert.AreEqual(TextDocumentSyncKind.None, response.Capabilities.TextDocumentSync?.Kind); + Assert.IsFalse(response.Capabilities.ReferencesProvider?.IsSupported ?? true); + Assert.IsFalse(response.Capabilities.RenameProvider?.IsSupported ?? true); + Assert.IsFalse(response.Capabilities.DocumentFormattingProvider?.IsSupported ?? true); + Assert.IsFalse(response.Capabilities.SemanticTokensProvider?.Full?.SupportsDelta ?? true); + CollectionAssert.AreEqual(new[] { "function" }, response.Capabilities.SemanticTokensProvider?.Legend?.TokenTypes); + CollectionAssert.AreEqual(new[] { "declaration" }, response.Capabilities.SemanticTokensProvider?.Legend?.TokenModifiers); + } + + [TestMethod] + public void DeserializeInitializeResponse_BooleanTextDocumentSyncDegradesToNone() + { + InitializeResponse response = DeserializeInitializeResponse( + """ + { + "capabilities": { + "textDocumentSync": true + } + } + """); + + Assert.IsNotNull(response.Capabilities); + Assert.AreEqual(TextDocumentSyncKind.None, response.Capabilities.TextDocumentSync?.Kind); + } + + [TestMethod] + public void SemanticTokenCapabilityLists_AreNotExposedAsMutableArrays() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + + InvokePrivateMethod(client, "CaptureServerCapabilities", DeserializeInitializeResponse( + """ + { + "capabilities": { + "textDocumentSync": { + "change": 1 + }, + "semanticTokensProvider": { + "legend": { + "tokenTypes": ["function"], + "tokenModifiers": ["declaration"] + } + } + } + } + """)); + + Assert.IsFalse(client.SemanticTokenTypes is string[]); + Assert.IsFalse(client.SemanticTokenModifiers is string[]); + } +} diff --git a/Tests/TombLib.LanguageServer.Core.Tests/Client/LanguageServerClientTests.Configuration.cs b/Tests/TombLib.LanguageServer.Core.Tests/Client/LanguageServerClientTests.Configuration.cs new file mode 100644 index 0000000000..143fe89be5 --- /dev/null +++ b/Tests/TombLib.LanguageServer.Core.Tests/Client/LanguageServerClientTests.Configuration.cs @@ -0,0 +1,394 @@ +using NLog; +using System.Text.Json; + +namespace TombLib.LanguageServer.Core.Tests; + +public partial class LanguageServerClientTests +{ + [TestMethod] + public void BuildConfigurationResponse_ReturnsRequestedLuaSections() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", new LanguageServerClientOptions(static () => new + { + Lua = new + { + runtime = new + { + version = "Lua 5.4" + } + } + })); + + object[] response = (object[])InvokePrivateMethodWithReturn(client, "BuildConfigurationResponse", + new WorkspaceConfigurationParams( + [ + new WorkspaceConfigurationItem("Lua"), + new WorkspaceConfigurationItem("Lua.runtime") + ])); + + Assert.AreEqual(2, response.Length); + Assert.AreEqual("Lua 5.4", JsonSerializer.SerializeToElement(response[1]).GetProperty("version").GetString()); + } + + [TestMethod] + public void BuildConfigurationResponse_ReturnsRequestedNestedNonLuaSections() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", new LanguageServerClientOptions(static () => new + { + Editor = new + { + theme = "Dark", + fonts = new + { + size = 14 + } + } + })); + + object[] response = (object[])InvokePrivateMethodWithReturn(client, "BuildConfigurationResponse", + new WorkspaceConfigurationParams( + [ + new WorkspaceConfigurationItem("Editor"), + new WorkspaceConfigurationItem("Editor.fonts") + ])); + + Assert.AreEqual(2, response.Length); + Assert.AreEqual("Dark", JsonSerializer.SerializeToElement(response[0]).GetProperty("theme").GetString()); + Assert.AreEqual(14, JsonSerializer.SerializeToElement(response[1]).GetProperty("size").GetInt32()); + } + + [TestMethod] + public void BuildConfigurationResponse_ReturnsNullWhenLuaSectionIsMissing() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", new LanguageServerClientOptions(static () => new + { + Editor = new + { + theme = "Dark" + } + })); + + object[] response = (object[])InvokePrivateMethodWithReturn(client, "BuildConfigurationResponse", + new WorkspaceConfigurationParams( + [ + new WorkspaceConfigurationItem("Lua"), + new WorkspaceConfigurationItem("Lua.runtime") + ])); + + Assert.AreEqual(2, response.Length); + Assert.IsNull(response[0]); + Assert.IsNull(response[1]); + } + + [TestMethod] + public void BuildConfigurationResponse_ReusesCachedSettingsSnapshotAcrossRepeatedRequests() + { + int settingsProviderCallCount = 0; + + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", new LanguageServerClientOptions(() => + { + settingsProviderCallCount++; + + return new + { + Lua = new + { + Runtime = new + { + Version = "Lua 5.4" + } + } + }; + })); + + object[] firstResponse = (object[])InvokePrivateMethodWithReturn(client, "BuildConfigurationResponse", + new WorkspaceConfigurationParams( + [ + new WorkspaceConfigurationItem("Lua.runtime") + ])); + + object[] secondResponse = (object[])InvokePrivateMethodWithReturn(client, "BuildConfigurationResponse", + new WorkspaceConfigurationParams( + [ + new WorkspaceConfigurationItem("Lua.runtime") + ])); + + Assert.AreEqual(1, settingsProviderCallCount); + Assert.AreEqual("Lua 5.4", JsonSerializer.SerializeToElement(firstResponse[0]).GetProperty("version").GetString()); + Assert.AreEqual("Lua 5.4", JsonSerializer.SerializeToElement(secondResponse[0]).GetProperty("version").GetString()); + } + + [TestMethod] + public async Task SendNotificationAsync_WhenDidChangeConfigurationIsAlreadyCanceled_PreservesCachedSettingsSnapshot() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", new LanguageServerClientOptions(static () => new + { + Lua = new + { + Runtime = new + { + Version = "Lua 5.4" + } + } + })); + + using var cancellationSource = new CancellationTokenSource(); + object session = CreateTransportSession(client, 11, process: null, Stream.Null, Stream.Null); + + SetActiveSession(client, session); + SetReadyState(client, true); + cancellationSource.Cancel(); + + object[] initialResponse = (object[])InvokePrivateMethodWithReturn(client, "BuildConfigurationResponse", + new WorkspaceConfigurationParams( + [ + new WorkspaceConfigurationItem("Lua.runtime") + ])); + + Assert.AreEqual("Lua 5.4", JsonSerializer.SerializeToElement(initialResponse[0]).GetProperty("version").GetString()); + + await Assert.ThrowsExceptionAsync(async () => + await client.SendNotificationAsync( + "workspace/didChangeConfiguration", + new DidChangeConfigurationParams(new + { + Lua = new + { + Runtime = new + { + Version = "Lua 5.1" + } + } + }), + cancellationSource.Token).ConfigureAwait(false)).ConfigureAwait(false); + + object[] responseAfterFailure = (object[])InvokePrivateMethodWithReturn(client, "BuildConfigurationResponse", + new WorkspaceConfigurationParams( + [ + new WorkspaceConfigurationItem("Lua.runtime") + ])); + + Assert.AreEqual("Lua 5.4", JsonSerializer.SerializeToElement(responseAfterFailure[0]).GetProperty("version").GetString()); + Assert.IsTrue(client.IsReady); + Assert.AreEqual(11L, client.TransportGeneration); + } + + [TestMethod] + public void BuildConfigurationResponse_TypedSettingsObject_MatchesNestedSectionCaseInsensitively() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", new LanguageServerClientOptions(static () => new TestConfigurationRoot + { + Lua = new TestLuaConfiguration + { + Runtime = new TestLuaRuntimeConfiguration + { + Version = "Lua 5.4" + } + } + })); + + object[] response = (object[])InvokePrivateMethodWithReturn(client, "BuildConfigurationResponse", + new WorkspaceConfigurationParams( + [ + new WorkspaceConfigurationItem("Lua.runtime") + ])); + + Assert.AreEqual(1, response.Length); + Assert.IsNotNull(response[0]); + Assert.AreEqual("Lua 5.4", JsonSerializer.SerializeToElement(response[0]).GetProperty("version").GetString()); + } + + [TestMethod] + public void WorkspaceConfiguration_WhenSettingsSerializationFails_ReturnsNullValuesAndLogsWarning() + { + using var logScope = new NLogMemoryScope(LogLevel.Warn); + + var cyclicSettings = new Dictionary(); + cyclicSettings["self"] = cyclicSettings; + + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", new LanguageServerClientOptions(() => cyclicSettings)); + object session = CreateTransportSession(client, 1, process: null, Stream.Null, Stream.Null); + + SetActiveSession(client, session); + object rpcTarget = CreateRpcTarget(client, GetTransportGeneration(session)); + + object[] response = (object[])InvokePrivateMethodWithReturn(rpcTarget, + "WorkspaceConfiguration", + new WorkspaceConfigurationParams( + [ + new WorkspaceConfigurationItem("Lua"), + new WorkspaceConfigurationItem("Lua.runtime") + ])); + + Assert.AreEqual(2, response.Length); + Assert.IsNull(response[0]); + Assert.IsNull(response[1]); + + Assert.IsTrue(logScope.Logs.Any(log => log.StartsWith("Warn|", StringComparison.Ordinal) + && log.Contains("workspace/configuration", StringComparison.OrdinalIgnoreCase) + && log.Contains("returning null values", StringComparison.OrdinalIgnoreCase)), + string.Join(Environment.NewLine, logScope.Logs)); + } + + [TestMethod] + public void BuildInitializeParams_UsesInjectedInitializationOptions() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", new LanguageServerClientOptions(static () => new { }) + { + ClientCapabilitiesProvider = static _ => new { }, + InitializationOptionsProvider = static workspaceRoot => new + { + workspace = workspaceRoot, + customFlag = true + } + }); + + JsonElement initializeParams = JsonSerializer.SerializeToElement(InvokePrivateMethodWithReturn(client, "BuildInitializeParams")); + JsonElement initializationOptions = initializeParams.GetProperty("initializationOptions"); + + Assert.AreEqual(@"C:\Workspace", initializationOptions.GetProperty("workspace").GetString()); + Assert.IsTrue(initializationOptions.GetProperty("customFlag").GetBoolean()); + } + + [TestMethod] + public void BuildInitializeParams_UsesInjectedClientCapabilities() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", new LanguageServerClientOptions(static () => new { }) + { + ClientCapabilitiesProvider = static workspaceRoot => new + { + workspace = new + { + workspaceFolders = true, + configuration = true, + root = workspaceRoot + } + } + }); + + JsonElement initializeParams = JsonSerializer.SerializeToElement(InvokePrivateMethodWithReturn(client, "BuildInitializeParams")); + JsonElement capabilities = initializeParams.GetProperty("capabilities"); + + Assert.IsTrue(capabilities.GetProperty("workspace").GetProperty("workspaceFolders").GetBoolean()); + Assert.IsTrue(capabilities.GetProperty("workspace").GetProperty("configuration").GetBoolean()); + Assert.AreEqual(@"C:\Workspace", capabilities.GetProperty("workspace").GetProperty("root").GetString()); + } + + [TestMethod] + public void BuildInitializeParams_ForcesUnsupportedDynamicRegistrationFlagsToFalse() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", new LanguageServerClientOptions(static () => new { }) + { + ClientCapabilitiesProvider = static _ => new + { + workspace = new + { + didChangeWatchedFiles = new { dynamicRegistration = true } + }, + textDocument = new + { + rename = new { dynamicRegistration = true, prepareSupport = true }, + references = new { dynamicRegistration = true }, + formatting = new { dynamicRegistration = true }, + completion = new { dynamicRegistration = true } + } + } + }); + + JsonElement initializeParams = JsonSerializer.SerializeToElement(InvokePrivateMethodWithReturn(client, "BuildInitializeParams")); + JsonElement capabilities = initializeParams.GetProperty("capabilities"); + + Assert.IsFalse(capabilities.GetProperty("workspace").GetProperty("didChangeWatchedFiles").GetProperty("dynamicRegistration").GetBoolean()); + Assert.IsFalse(capabilities.GetProperty("textDocument").GetProperty("rename").GetProperty("dynamicRegistration").GetBoolean()); + Assert.IsFalse(capabilities.GetProperty("textDocument").GetProperty("references").GetProperty("dynamicRegistration").GetBoolean()); + Assert.IsFalse(capabilities.GetProperty("textDocument").GetProperty("formatting").GetProperty("dynamicRegistration").GetBoolean()); + Assert.IsFalse(capabilities.GetProperty("textDocument").GetProperty("completion").GetProperty("dynamicRegistration").GetBoolean()); + Assert.IsTrue(capabilities.GetProperty("textDocument").GetProperty("rename").GetProperty("prepareSupport").GetBoolean()); + } + + [TestMethod] + public void BuildInitializeParams_NormalizesWorkspaceRootAndFolderName() + { + using var client = new LanguageServerClient(@"C:/Workspace/", "lua-language-server.exe", new LanguageServerClientOptions(static () => new { }) + { + ClientCapabilitiesProvider = static _ => new { }, + InitializationOptionsProvider = static workspaceRoot => new + { + workspace = workspaceRoot + } + }); + + JsonElement initializeParams = JsonSerializer.SerializeToElement(InvokePrivateMethodWithReturn(client, "BuildInitializeParams")); + JsonElement workspaceFolder = initializeParams.GetProperty("workspaceFolders")[0]; + string expectedWorkspaceRoot = LanguageServerPathHelper.NormalizeLocalPath(@"C:/Workspace/"); + + Assert.AreEqual(expectedWorkspaceRoot, initializeParams.GetProperty("initializationOptions").GetProperty("workspace").GetString()); + Assert.AreEqual(LanguageServerPathHelper.CreateFileUri(expectedWorkspaceRoot), initializeParams.GetProperty("rootUri").GetString()); + Assert.AreEqual(LanguageServerPathHelper.CreateFileUri(expectedWorkspaceRoot), workspaceFolder.GetProperty("uri").GetString()); + Assert.AreEqual("Workspace", workspaceFolder.GetProperty("name").GetString()); + } + + [TestMethod] + public void WorkspaceFolders_ReturnsDriveRootNameWhenWorkspaceRootIsDriveRoot() + { + using var client = new LanguageServerClient(@"C:\", "lua-language-server.exe", DefaultClientOptions); + object session = CreateTransportSession(client, 1, process: null, Stream.Null, Stream.Null); + + SetActiveSession(client, session); + object rpcTarget = CreateRpcTarget(client, GetTransportGeneration(session)); + + WorkspaceFolder[] workspaceFolders = (WorkspaceFolder[])InvokePrivateMethodWithReturn(rpcTarget, "WorkspaceFolders"); + + Assert.AreEqual(1, workspaceFolders.Length); + Assert.AreEqual(LanguageServerPathHelper.CreateFileUri(@"C:\"), workspaceFolders[0].Uri); + Assert.AreEqual("C:", workspaceFolders[0].Name); + } + + [TestMethod] + public void WorkspaceConfiguration_StaleTransportGeneration_ReturnsNullValuesWithoutReadingActiveSettings() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", new LanguageServerClientOptions(static () => new + { + Lua = new + { + Runtime = new + { + Version = "5.4" + } + } + })); + + object staleSession = CreateTransportSession(client, 1, process: null, Stream.Null, Stream.Null); + object activeSession = CreateTransportSession(client, 2, process: null, Stream.Null, Stream.Null); + + SetActiveSession(client, activeSession); + + object rpcTarget = CreateRpcTarget(client, GetTransportGeneration(staleSession)); + object[] response = (object[])InvokePrivateMethodWithReturn(rpcTarget, + "WorkspaceConfiguration", + new WorkspaceConfigurationParams( + [ + new WorkspaceConfigurationItem("Lua"), + new WorkspaceConfigurationItem("Lua.runtime") + ])); + + Assert.AreEqual(2, response.Length); + Assert.IsNull(response[0]); + Assert.IsNull(response[1]); + } + + [TestMethod] + public void WorkspaceFolders_StaleTransportGeneration_ReturnsEmptyArray() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object staleSession = CreateTransportSession(client, 1, process: null, Stream.Null, Stream.Null); + object activeSession = CreateTransportSession(client, 2, process: null, Stream.Null, Stream.Null); + + SetActiveSession(client, activeSession); + + object rpcTarget = CreateRpcTarget(client, GetTransportGeneration(staleSession)); + WorkspaceFolder[] workspaceFolders = (WorkspaceFolder[])InvokePrivateMethodWithReturn(rpcTarget, "WorkspaceFolders"); + + Assert.AreEqual(0, workspaceFolders.Length); + } +} diff --git a/Tests/TombLib.LanguageServer.Core.Tests/Client/LanguageServerClientTests.Diagnostics.cs b/Tests/TombLib.LanguageServer.Core.Tests/Client/LanguageServerClientTests.Diagnostics.cs new file mode 100644 index 0000000000..6c3b5d81af --- /dev/null +++ b/Tests/TombLib.LanguageServer.Core.Tests/Client/LanguageServerClientTests.Diagnostics.cs @@ -0,0 +1,406 @@ +using NLog; + +namespace TombLib.LanguageServer.Core.Tests; + +public partial class LanguageServerClientTests +{ + [TestMethod] + public async Task HandleDiagnosticsPublished_WhenTransportIsAttachedButNotReady_QueuesDiagnostics() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object session = CreateTransportSession(client, 6, process: null, Stream.Null, Stream.Null); + var publishedMessage = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + SetActiveSession(client, session); + InvokePrivateMethod(client, "EnsureTransportBackgroundLoopsRunning", true); + client.DiagnosticsPublished += parameters => publishedMessage.TrySetResult(parameters.Diagnostics?[0].Message); + + object rpcTarget = CreateRpcTarget(client, GetTransportGeneration(session)); + + InvokePrivateMethod(rpcTarget, + "PublishDiagnostics", + CreateDiagnosticsParameters("file:///C:/Workspace/test.lua", "Initial warning.")); + + Assert.AreEqual("Initial warning.", await publishedMessage.Task.WaitAsync(TimeSpan.FromSeconds(1)).ConfigureAwait(false)); + } + + [TestMethod] + public async Task HandleDiagnosticsPublished_IgnoresUnhealthyTransportGeneration() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object session = CreateTransportSession(client, 6, process: null, Stream.Null, Stream.Null); + var publishedMessage = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + SetActiveSession(client, session); + SetReadyState(client, true); + client.DiagnosticsPublished += parameters => publishedMessage.TrySetResult(parameters.Diagnostics?[0].Message); + + client.MarkTransportUnhealthy(); + + object rpcTarget = CreateRpcTarget(client, GetTransportGeneration(session)); + + InvokePrivateMethod(rpcTarget, + "PublishDiagnostics", + CreateDiagnosticsParameters("file:///C:/Workspace/test.lua", "Ignored warning.")); + + Task completedTask = await Task.WhenAny(publishedMessage.Task, Task.Delay(TimeSpan.FromMilliseconds(200))).ConfigureAwait(false); + + Assert.AreNotSame(publishedMessage.Task, completedTask); + } + + [TestMethod] + public async Task PumpDiagnosticsAsync_CoalescesQueuedDiagnosticsByFile() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + 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_CoalescesWindowsFileUrisThatDifferOnlyByCase() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + 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", "First warning.")); + InvokePrivateMethod(client, "RaiseDiagnosticsPublished", 0L, CreateDiagnosticsParameters("file:///c:/workspace/test.lua", "Latest warning.")); + + await InvokePrivateTaskAsync(client, "PumpDiagnosticsAsync").ConfigureAwait(false); + + Assert.AreEqual(1, publishedCount); + Assert.AreEqual("Latest warning.", lastMessage); + } + + [TestMethod] + public async Task PumpDiagnosticsAsync_DropsQueuedDiagnosticsFromInactiveTransportGeneration() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + 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 async Task PumpDiagnosticsAsync_SameUriStaleGenerationDoesNotOverwriteCurrentGenerationPayload() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + 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(newSession), + CreateDiagnosticsParameters("file:///C:/Workspace/test.lua", "Current warning.")); + + InvokePrivateMethod(client, "RaiseDiagnosticsPublished", GetTransportGeneration(oldSession), + CreateDiagnosticsParameters("file:///C:/Workspace/test.lua", "Stale warning.")); + + await InvokePrivateTaskAsync(client, "PumpDiagnosticsAsync").ConfigureAwait(false); + + Assert.AreEqual(1, publishedCount); + Assert.AreEqual("Current warning.", lastMessage); + } + + [TestMethod] + public async Task PumpDiagnosticsAsync_WhenHandlerThrows_LogsWarningAndContinuesProcessing() + { + using var logScope = new NLogMemoryScope(LogLevel.Debug); + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + int publishedCount = 0; + var publishedMessages = new List(); + + client.DiagnosticsPublished += parameters => + { + publishedCount++; + + if (parameters.Diagnostics?[0].Message is { } message) + publishedMessages.Add(message); + + if (publishedCount == 1) + throw new InvalidOperationException("Simulated diagnostics subscriber failure."); + + CancelLifetime(client); + }; + + InvokePrivateMethod(client, "RaiseDiagnosticsPublished", 0L, CreateDiagnosticsParameters("file:///C:/Workspace/first.lua", "First warning.")); + InvokePrivateMethod(client, "RaiseDiagnosticsPublished", 0L, CreateDiagnosticsParameters("file:///C:/Workspace/second.lua", "Second warning.")); + + await InvokePrivateTaskAsync(client, "PumpDiagnosticsAsync").ConfigureAwait(false); + + Assert.AreEqual(2, publishedCount); + CollectionAssert.AreEquivalent(new[] { "First warning.", "Second warning." }, publishedMessages); + + Assert.IsTrue(logScope.Logs.Any(log => log.Contains("Diagnostics handler threw", StringComparison.OrdinalIgnoreCase) + && log.Contains("Simulated diagnostics subscriber failure.", StringComparison.Ordinal)), + string.Join(Environment.NewLine, logScope.Logs)); + } + + [TestMethod] + public async Task PumpDiagnosticsAsync_SlowSubscriberDoesNotBlockLaterSubscriber() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + var firstSubscriberEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var firstSubscriberCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var secondSubscriberObserved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowFirstSubscriberToFinish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + client.DiagnosticsPublished += parameters => + { + firstSubscriberEntered.TrySetResult(true); + allowFirstSubscriberToFinish.Task.GetAwaiter().GetResult(); + firstSubscriberCompleted.TrySetResult(true); + }; + + client.DiagnosticsPublished += parameters => + { + secondSubscriberObserved.TrySetResult(parameters.Diagnostics?[0].Message); + CancelLifetime(client); + }; + + Task diagnosticsPumpTask = InvokePrivateTaskAsync(client, "PumpDiagnosticsAsync"); + + InvokePrivateMethod(client, "RaiseDiagnosticsPublished", 0L, CreateDiagnosticsParameters("file:///C:/Workspace/test.lua", "Current warning.")); + + await Task.WhenAll( + firstSubscriberEntered.Task.WaitAsync(TimeSpan.FromSeconds(1)), + secondSubscriberObserved.Task.WaitAsync(TimeSpan.FromSeconds(1))).ConfigureAwait(false); + + Assert.AreEqual("Current warning.", await secondSubscriberObserved.Task.ConfigureAwait(false)); + + allowFirstSubscriberToFinish.TrySetResult(true); + + await firstSubscriberCompleted.Task.WaitAsync(TimeSpan.FromSeconds(1)).ConfigureAwait(false); + await diagnosticsPumpTask.ConfigureAwait(false); + } + + [TestMethod] + public async Task PumpDiagnosticsAsync_SubscribersReceiveIndependentDiagnosticsSnapshots() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + var firstSubscriberMutated = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var secondSubscriberObserved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + client.DiagnosticsPublished += parameters => + { + if (parameters.Diagnostics is { Length: > 0 } diagnostics) + { + diagnostics[0] = diagnostics[0] with { Message = "Mutated warning." }; + } + + firstSubscriberMutated.TrySetResult(true); + }; + + client.DiagnosticsPublished += parameters => + { + firstSubscriberMutated.Task.GetAwaiter().GetResult(); + secondSubscriberObserved.TrySetResult(parameters.Diagnostics?[0].Message); + CancelLifetime(client); + }; + + Task diagnosticsPumpTask = InvokePrivateTaskAsync(client, "PumpDiagnosticsAsync"); + + InvokePrivateMethod(client, "RaiseDiagnosticsPublished", 0L, + CreateDiagnosticsParameters("file:///C:/Workspace/test.lua", "Original warning.")); + + Assert.AreEqual("Original warning.", + await secondSubscriberObserved.Task.WaitAsync(TimeSpan.FromSeconds(1)).ConfigureAwait(false)); + + await diagnosticsPumpTask.ConfigureAwait(false); + } + + [TestMethod] + public async Task InvokeDiagnosticsPublished_WhenSubscriberIsBusy_CoalescesPendingPayloadsPerDocument() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + const string uri = "file:///C:/Workspace/test.lua"; + var firstInvocationEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowFirstInvocationToFinish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var secondInvocationMessage = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var unexpectedThirdInvocation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int invocationCount = 0; + + client.DiagnosticsPublished += parameters => + { + int currentInvocation = Interlocked.Increment(ref invocationCount); + string? message = parameters.Diagnostics?[0].Message; + + if (currentInvocation == 1) + { + firstInvocationEntered.TrySetResult(true); + allowFirstInvocationToFinish.Task.GetAwaiter().GetResult(); + return; + } + + if (currentInvocation == 2) + { + secondInvocationMessage.TrySetResult(message); + return; + } + + unexpectedThirdInvocation.TrySetResult(true); + }; + + InvokePrivateMethod(client, "InvokeDiagnosticsPublished", uri, CreateDiagnosticsParameters(uri, "First warning.")); + await firstInvocationEntered.Task.WaitAsync(TimeSpan.FromSeconds(1)).ConfigureAwait(false); + + InvokePrivateMethod(client, "InvokeDiagnosticsPublished", uri, CreateDiagnosticsParameters(uri, "Second warning.")); + InvokePrivateMethod(client, "InvokeDiagnosticsPublished", uri, CreateDiagnosticsParameters(uri, "Third warning.")); + InvokePrivateMethod(client, "InvokeDiagnosticsPublished", uri, CreateDiagnosticsParameters(uri, "Latest warning.")); + + allowFirstInvocationToFinish.TrySetResult(true); + Assert.AreEqual("Latest warning.", await secondInvocationMessage.Task.WaitAsync(TimeSpan.FromSeconds(1)).ConfigureAwait(false)); + + Task completedTask = await Task.WhenAny(unexpectedThirdInvocation.Task, Task.Delay(TimeSpan.FromMilliseconds(200))).ConfigureAwait(false); + + Assert.AreNotSame(unexpectedThirdInvocation.Task, completedTask); + Assert.AreEqual(2, Volatile.Read(ref invocationCount)); + } + + [TestMethod] + public async Task PumpDiagnosticsAsync_CoalescesRepeatedUpdatesWhileCallbackPumpIsBusy() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + const string uri = "file:///C:/Workspace/test.lua"; + var firstHandlerEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowFirstHandlerToFinish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var secondHandlerMessage = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var unexpectedThirdHandler = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int publishedCount = 0; + + client.DiagnosticsPublished += parameters => + { + int invocationCount = Interlocked.Increment(ref publishedCount); + string? message = parameters.Diagnostics?[0].Message; + + if (invocationCount == 1) + { + firstHandlerEntered.TrySetResult(true); + allowFirstHandlerToFinish.Task.GetAwaiter().GetResult(); + return; + } + + if (invocationCount == 2) + { + secondHandlerMessage.TrySetResult(message); + CancelLifetime(client); + return; + } + + unexpectedThirdHandler.TrySetResult(true); + }; + + Task diagnosticsPumpTask = InvokePrivateTaskAsync(client, "PumpDiagnosticsAsync"); + + InvokePrivateMethod(client, "RaiseDiagnosticsPublished", 0L, CreateDiagnosticsParameters(uri, "First warning.")); + await firstHandlerEntered.Task.WaitAsync(TimeSpan.FromSeconds(1)).ConfigureAwait(false); + + InvokePrivateMethod(client, "RaiseDiagnosticsPublished", 0L, CreateDiagnosticsParameters(uri, "Second warning.")); + InvokePrivateMethod(client, "RaiseDiagnosticsPublished", 0L, CreateDiagnosticsParameters(uri, "Third warning.")); + InvokePrivateMethod(client, "RaiseDiagnosticsPublished", 0L, CreateDiagnosticsParameters(uri, "Latest warning.")); + + allowFirstHandlerToFinish.TrySetResult(true); + + Assert.AreEqual("Latest warning.", await secondHandlerMessage.Task.WaitAsync(TimeSpan.FromSeconds(1)).ConfigureAwait(false)); + await diagnosticsPumpTask.ConfigureAwait(false); + + Task completedTask = await Task.WhenAny(unexpectedThirdHandler.Task, Task.Delay(TimeSpan.FromMilliseconds(200))).ConfigureAwait(false); + + Assert.AreNotSame(unexpectedThirdHandler.Task, completedTask); + Assert.AreEqual(2, Volatile.Read(ref publishedCount)); + } + + [TestMethod] + public async Task InvokeDiagnosticsPublished_AfterUnsubscribe_DoesNotDeliverQueuedCallbacks() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + const string uri = "file:///C:/Workspace/test.lua"; + var firstInvocationEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowFirstInvocationToFinish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var unexpectedSecondInvocation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int invocationCount = 0; + + void handler(PublishDiagnosticsParams _) + { + int currentInvocation = Interlocked.Increment(ref invocationCount); + + if (currentInvocation == 1) + { + firstInvocationEntered.TrySetResult(true); + allowFirstInvocationToFinish.Task.GetAwaiter().GetResult(); + return; + } + + unexpectedSecondInvocation.TrySetResult(true); + } + + client.DiagnosticsPublished += handler; + + InvokePrivateMethod(client, "InvokeDiagnosticsPublished", uri, CreateDiagnosticsParameters(uri, "First warning.")); + await firstInvocationEntered.Task.WaitAsync(TimeSpan.FromSeconds(1)).ConfigureAwait(false); + + client.DiagnosticsPublished -= handler; + + for (int i = 0; i < 5; i++) + InvokePrivateMethod(client, "InvokeDiagnosticsPublished", uri, CreateDiagnosticsParameters(uri, "Later warning.")); + + allowFirstInvocationToFinish.TrySetResult(true); + + Task completedTask = await Task.WhenAny(unexpectedSecondInvocation.Task, Task.Delay(TimeSpan.FromMilliseconds(200))).ConfigureAwait(false); + + Assert.AreNotSame(unexpectedSecondInvocation.Task, completedTask); + Assert.AreEqual(1, Volatile.Read(ref invocationCount)); + } +} diff --git a/Tests/TombLib.LanguageServer.Core.Tests/Client/LanguageServerClientTests.Lifecycle.cs b/Tests/TombLib.LanguageServer.Core.Tests/Client/LanguageServerClientTests.Lifecycle.cs new file mode 100644 index 0000000000..8aa3ce877e --- /dev/null +++ b/Tests/TombLib.LanguageServer.Core.Tests/Client/LanguageServerClientTests.Lifecycle.cs @@ -0,0 +1,633 @@ +using NLog; +using StreamJsonRpc; +using System.Diagnostics; +using System.Reflection; + +namespace TombLib.LanguageServer.Core.Tests; + +public partial class LanguageServerClientTests +{ + [TestMethod] + public void Dispose_WritesGracefulShutdownMessagesAndLogsGracefulAttempt() + { + using var logScope = new NLogMemoryScope(LogLevel.Debug); + using Process process = StartDisposableProcess(); + using var serverOutputStream = new PendingReadStream(); + using var serverInputStream = new RecordingStream(); + using var client = new LanguageServerClient(@"C:\Workspace", process.StartInfo.FileName, DefaultClientOptions); + object session = CreateTransportSession(client, 1, process, serverOutputStream, serverInputStream, startListening: true); + + SetActiveSession(client, session); + + client.Dispose(); + + string writtenPayload = serverInputStream.GetWrittenText(); + + Assert.IsTrue(writtenPayload.Contains("\"method\":\"shutdown\"", StringComparison.Ordinal), writtenPayload); + Assert.IsTrue(writtenPayload.Contains("\"method\":\"exit\"", StringComparison.Ordinal), writtenPayload); + + Assert.IsTrue(logScope.Logs.Any(log => log.StartsWith("Info|", StringComparison.Ordinal) + && log.Contains("Attempting graceful shutdown", StringComparison.Ordinal) + && log.Contains("generation 1", StringComparison.OrdinalIgnoreCase)), + string.Join(Environment.NewLine, logScope.Logs)); + } + + [TestMethod] + public async Task DisposeAsync_WritesGracefulShutdownMessages() + { + using Process process = StartDisposableProcess(); + using var serverOutputStream = new PendingReadStream(); + using var serverInputStream = new RecordingStream(); + await using var client = new LanguageServerClient(@"C:\Workspace", process.StartInfo.FileName, DefaultClientOptions); + object session = CreateTransportSession(client, 1, process, serverOutputStream, serverInputStream, startListening: true); + + SetActiveSession(client, session); + + await client.DisposeAsync().ConfigureAwait(false); + + string writtenPayload = serverInputStream.GetWrittenText(); + + Assert.IsTrue(writtenPayload.Contains("\"method\":\"shutdown\"", StringComparison.Ordinal), writtenPayload); + Assert.IsTrue(writtenPayload.Contains("\"method\":\"exit\"", StringComparison.Ordinal), writtenPayload); + } + + [TestMethod] + public async Task Dispose_AndDisposeAsync_DisposeStartLockAfterCleanup() + { + for (int i = 0; i < 2; i++) + { + var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + SemaphoreSlim startLock = GetStartLock(client); + + if (i == 0) + client.Dispose(); + else + await client.DisposeAsync().ConfigureAwait(false); + + Assert.ThrowsException(() => startLock.Wait(0)); + } + } + + [TestMethod] + public async Task StartAsync_DisposeDuringStartupWait_ReturnsFalseWithoutObjectDisposedException() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + SemaphoreSlim startLock = GetStartLock(client); + + startLock.Wait(); + + try + { + Task startTask = client.StartAsync(CancellationToken.None); + + await Task.Delay(50).ConfigureAwait(false); + client.Dispose(); + + Assert.IsFalse(await startTask.ConfigureAwait(false)); + } + finally + { + startLock.Release(); + } + } + + [TestMethod] + public async Task StartAsync_WhenDisposalAlreadyBegan_DoesNotReportReadySuccess() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object session = CreateTransportSession(client, 1, process: null, Stream.Null, Stream.Null); + + SetActiveSession(client, session); + SetReadyState(client, true); + + bool disposeStarted = (bool)InvokePrivateMethodWithReturn(client, "TryBeginDispose"); + + Assert.IsTrue(disposeStarted); + Assert.IsTrue(client.IsReady, "The regression test expects readiness to still be visible immediately after disposal begins."); + + await Assert.ThrowsExceptionAsync(async () => + await client.StartAsync(CancellationToken.None).ConfigureAwait(false)).ConfigureAwait(false); + } + + [TestMethod] + public async Task StartAsync_WhenStartupFailsBeforeSessionActivation_KillsSpawnedProcess() + { + using var logScope = new NLogMemoryScope(LogLevel.Debug); + Process? startedProcess = null; + int? startedProcessId = null; + + using var client = new LanguageServerClient( + @"C:\Workspace", + Path.Combine(Environment.SystemDirectory, "cmd.exe"), + DefaultClientOptions, + processStartedTestHook: async (process, _) => + { + startedProcess = process; + startedProcessId = process.Id; + throw new InvalidOperationException("Simulated startup failure before session activation."); + }); + + bool started = await client.StartAsync(CancellationToken.None).ConfigureAwait(false); + + Assert.IsFalse(started); + Assert.IsNotNull(startedProcess); + Assert.IsNotNull(startedProcessId); + Assert.IsTrue(await WaitForProcessExitAsync(startedProcessId.Value).ConfigureAwait(false)); + Assert.IsFalse(client.IsReady); + + Assert.IsTrue(logScope.Logs.Any(log => log.StartsWith("Warn|", StringComparison.Ordinal) + && log.Contains("Startup cleanup is forcing language server process termination", StringComparison.Ordinal)), + string.Join(Environment.NewLine, logScope.Logs)); + } + + [TestMethod] + public async Task StartAsync_WhenCallerCancelsBeforeSessionActivation_CleansStartupProcessAndRethrows() + { + using var logScope = new NLogMemoryScope(LogLevel.Debug); + using var startupCancellation = new CancellationTokenSource(); + var processStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowStartupToContinue = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int? startedProcessId = null; + + using var client = new LanguageServerClient( + @"C:\Workspace", + Path.Combine(Environment.SystemDirectory, "cmd.exe"), + DefaultClientOptions, + processStartedTestHook: async (process, cancellationToken) => + { + startedProcessId = process.Id; + processStarted.TrySetResult(true); + + Task completedTask = await Task.WhenAny( + allowStartupToContinue.Task, + Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken)).ConfigureAwait(false); + + if (!ReferenceEquals(completedTask, allowStartupToContinue.Task)) + cancellationToken.ThrowIfCancellationRequested(); + + await allowStartupToContinue.Task.ConfigureAwait(false); + }); + + Task startTask = client.StartAsync(startupCancellation.Token); + + await processStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + startupCancellation.Cancel(); + allowStartupToContinue.TrySetResult(true); + + await Assert.ThrowsExceptionAsync(async () => + await startTask.ConfigureAwait(false)).ConfigureAwait(false); + + Assert.IsNotNull(startedProcessId); + Assert.IsTrue(await WaitForProcessExitAsync(startedProcessId.Value).ConfigureAwait(false)); + Assert.IsFalse(client.IsReady); + + Assert.IsTrue(logScope.Logs.Any(log => + (log.StartsWith("Debug|", StringComparison.Ordinal) + && log.Contains("after cancellation before session activation completed", StringComparison.OrdinalIgnoreCase)) + || (log.StartsWith("Info|", StringComparison.Ordinal) + && log.Contains("Attempting graceful shutdown", StringComparison.OrdinalIgnoreCase))), + string.Join(Environment.NewLine, logScope.Logs)); + + Assert.IsFalse(logScope.Logs.Any(log => log.StartsWith("Warn|", StringComparison.Ordinal) + && log.Contains("Startup cleanup", StringComparison.OrdinalIgnoreCase)), + string.Join(Environment.NewLine, logScope.Logs)); + } + + [TestMethod] + public async Task StartAsync_WhenClientIsDisposedBeforeSessionActivation_CleansStartupProcessAndReturnsFalse() + { + using var logScope = new NLogMemoryScope(LogLevel.Debug); + var processStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowStartupToContinue = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int? startedProcessId = null; + + using var client = new LanguageServerClient( + @"C:\Workspace", + Path.Combine(Environment.SystemDirectory, "cmd.exe"), + DefaultClientOptions, + processStartedTestHook: async (process, cancellationToken) => + { + startedProcessId = process.Id; + processStarted.TrySetResult(true); + + Task completedTask = await Task.WhenAny( + allowStartupToContinue.Task, + Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken)).ConfigureAwait(false); + + if (!ReferenceEquals(completedTask, allowStartupToContinue.Task)) + cancellationToken.ThrowIfCancellationRequested(); + + await allowStartupToContinue.Task.ConfigureAwait(false); + }); + + Task startTask = client.StartAsync(CancellationToken.None); + + await processStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + client.Dispose(); + allowStartupToContinue.TrySetResult(true); + + Assert.IsFalse(await startTask.ConfigureAwait(false)); + Assert.IsNotNull(startedProcessId); + Assert.IsTrue(await WaitForProcessExitAsync(startedProcessId.Value).ConfigureAwait(false)); + Assert.IsFalse(client.IsReady); + + Assert.IsTrue(logScope.Logs.Any(log => + (log.StartsWith("Debug|", StringComparison.Ordinal) + && log.Contains("after cancellation before session activation completed", StringComparison.OrdinalIgnoreCase)) + || (log.StartsWith("Info|", StringComparison.Ordinal) + && log.Contains("Attempting graceful shutdown", StringComparison.OrdinalIgnoreCase))), + string.Join(Environment.NewLine, logScope.Logs)); + + Assert.IsFalse(logScope.Logs.Any(log => log.StartsWith("Warn|", StringComparison.Ordinal) + && log.Contains("Startup cleanup", StringComparison.OrdinalIgnoreCase)), + string.Join(Environment.NewLine, logScope.Logs)); + } + + [TestMethod] + public async Task DisposeStartupSessionResourcesAsync_WhenCancellationIsExpected_LogsDebugWithoutWarning() + { + using var logScope = new NLogMemoryScope(LogLevel.Debug); + using Process process = StartDisposableProcess(); + using var client = new LanguageServerClient(@"C:\Workspace", process.StartInfo.FileName, DefaultClientOptions); + int processId = process.Id; + + await InvokePrivateTaskAsync(client, "DisposeStartupSessionResourcesAsync", null, process, true).ConfigureAwait(false); + + Assert.IsTrue(await WaitForProcessExitAsync(processId).ConfigureAwait(false)); + + Assert.IsTrue(logScope.Logs.Any(log => log.StartsWith("Debug|", StringComparison.Ordinal) + && log.Contains("after cancellation before session activation completed", StringComparison.OrdinalIgnoreCase)), + string.Join(Environment.NewLine, logScope.Logs)); + + Assert.IsFalse(logScope.Logs.Any(log => log.StartsWith("Warn|", StringComparison.Ordinal) + && log.Contains("Startup cleanup", StringComparison.OrdinalIgnoreCase)), + string.Join(Environment.NewLine, logScope.Logs)); + } + + [TestMethod] + public void Dispose_WhenShutdownRequestTimesOut_LogsForcedTermination() + { + using var logScope = new NLogMemoryScope(LogLevel.Debug); + using Process process = StartDisposableProcess(); + using var serverOutputStream = new PendingReadStream(); + using var serverInputStream = new RecordingStream(); + using var client = new LanguageServerClient(@"C:\Workspace", process.StartInfo.FileName, DefaultClientOptions); + object session = CreateTransportSession(client, 7, process, serverOutputStream, serverInputStream, startListening: true); + + SetActiveSession(client, session); + RecordStandardErrorLine(session, "LuaLS did not respond to shutdown."); + + client.Dispose(); + + Assert.IsTrue(logScope.Logs.Any(log => log.StartsWith("Warn|", StringComparison.Ordinal) + && log.Contains("did not acknowledge shutdown", StringComparison.OrdinalIgnoreCase) + && log.Contains("generation 7", StringComparison.OrdinalIgnoreCase)), + string.Join(Environment.NewLine, logScope.Logs)); + + Assert.IsTrue(logScope.Logs.Any(log => log.StartsWith("Warn|", StringComparison.Ordinal) + && log.Contains("forcing process termination", StringComparison.OrdinalIgnoreCase) + && log.Contains("generation 7", StringComparison.OrdinalIgnoreCase)), + string.Join(Environment.NewLine, logScope.Logs)); + + Assert.IsTrue(logScope.Logs.Any(log => log.StartsWith("Warn|", StringComparison.Ordinal) + && log.Contains("recent language server stderr", StringComparison.OrdinalIgnoreCase) + && log.Contains("LuaLS did not respond to shutdown.", StringComparison.Ordinal)), + string.Join(Environment.NewLine, logScope.Logs)); + } + + [TestMethod] + public async Task Dispose_WhenShutdownAcknowledgesWithinConfiguredBudget_DoesNotLogTimeoutOrForcedTermination() + { + using var logScope = new NLogMemoryScope(LogLevel.Debug); + using Process process = StartShortLivedProcess(); + int processId = process.Id; + + using var serverOutputStream = new DelayedJsonRpcResponseStream( + CreateJsonRpcResultMessage(id: 1, resultJson: "null"), + TimeSpan.FromMilliseconds(150)); + + using var serverInputStream = new RecordingStream(); + + using var client = new LanguageServerClient(@"C:\Workspace", process.StartInfo.FileName, new LanguageServerClientOptions(static () => new { }) + { + ShutdownRequestTimeout = TimeSpan.FromMilliseconds(1500), + DisposeWaitTimeout = TimeSpan.FromMilliseconds(1500) + }); + + object session = CreateTransportSession(client, 8, process, serverOutputStream, serverInputStream, startListening: true); + + SetActiveSession(client, session); + + client.Dispose(); + + Assert.IsTrue(await WaitForProcessExitAsync(processId).ConfigureAwait(false)); + + Assert.IsFalse(logScope.Logs.Any(log => log.StartsWith("Warn|", StringComparison.Ordinal) + && log.Contains("did not acknowledge shutdown", StringComparison.OrdinalIgnoreCase)), + string.Join(Environment.NewLine, logScope.Logs)); + + Assert.IsFalse(logScope.Logs.Any(log => log.StartsWith("Warn|", StringComparison.Ordinal) + && log.Contains("forcing process termination", StringComparison.OrdinalIgnoreCase)), + string.Join(Environment.NewLine, logScope.Logs)); + } + + [TestMethod] + public void Dispose_WhenShutdownRequestTimesOut_UsesConfiguredTimeoutInLog() + { + using var logScope = new NLogMemoryScope(LogLevel.Debug); + using Process process = StartDisposableProcess(); + using var serverOutputStream = new PendingReadStream(); + using var serverInputStream = new RecordingStream(); + + using var client = new LanguageServerClient(@"C:\Workspace", process.StartInfo.FileName, new LanguageServerClientOptions(static () => new { }) + { + ShutdownRequestTimeout = TimeSpan.FromMilliseconds(50) + }); + + object session = CreateTransportSession(client, 9, process, serverOutputStream, serverInputStream, startListening: true); + + SetActiveSession(client, session); + + client.Dispose(); + + Assert.IsTrue(logScope.Logs.Any(log => log.StartsWith("Warn|", StringComparison.Ordinal) + && log.Contains("did not acknowledge shutdown", StringComparison.OrdinalIgnoreCase) + && log.Contains("50", StringComparison.Ordinal) + && log.Contains("generation 9", StringComparison.OrdinalIgnoreCase)), + string.Join(Environment.NewLine, logScope.Logs)); + } + + [TestMethod] + public async Task WaitWithDisposeBudgetAsync_UsesConfiguredBudgetWithoutImmediateTimeout() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", new LanguageServerClientOptions(static () => new { }) + { + DisposeWaitTimeout = TimeSpan.FromMilliseconds(250) + }); + + var disposeStopwatch = Stopwatch.StartNew(); + var releaseTask = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + Task delayedTask = Task.Run(async () => + { + await Task.Delay(125).ConfigureAwait(false); + releaseTask.TrySetResult(true); + }); + + await InvokePrivateTaskAsync( + client, + "WaitWithDisposeBudgetAsync", + releaseTask.Task, + disposeStopwatch, + "timeout message {0}", + "exception message") + .ConfigureAwait(false); + + await delayedTask.ConfigureAwait(false); + Assert.IsTrue(releaseTask.Task.IsCompletedSuccessfully); + } + + [TestMethod] + public async Task DisposeStartLockAsync_WhenStartupGateStaysBusy_LogsTimeoutWithoutDisposingGate() + { + using var logScope = new NLogMemoryScope(LogLevel.Warn); + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + SemaphoreSlim startLock = GetStartLock(client); + bool reacquiredStartLock = false; + + startLock.Wait(); + + try + { + await InvokePrivateTaskAsync(client, "DisposeStartLockAsync", TimeSpan.FromMilliseconds(50)).ConfigureAwait(false); + } + finally + { + startLock.Release(); + } + + try + { + reacquiredStartLock = startLock.Wait(0); + Assert.IsTrue(reacquiredStartLock); + } + finally + { + if (reacquiredStartLock) + startLock.Release(); + } + + Assert.IsTrue(logScope.Logs.Any(log => log.StartsWith("Warn|", StringComparison.Ordinal) + && log.Contains("startup gate did not become available", StringComparison.OrdinalIgnoreCase) + && log.Contains("50", StringComparison.Ordinal)), + string.Join(Environment.NewLine, logScope.Logs)); + } + + [TestMethod] + public async Task StartAsync_WhenCancelledDuringHandshake_DetachesPublishedSessionAndLeavesClientNotReady() + { + var sessionActivated = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var startupCancellation = new CancellationTokenSource(); + + await using var client = new LanguageServerClient( + @"C:\Workspace", + Path.Combine(Environment.SystemDirectory, "cmd.exe"), + DefaultClientOptions, + processStartedTestHook: null, + sessionActivatedTestHook: cancellationToken => WaitForStartupCancellationAsync(sessionActivated, cancellationToken)); + + Task startTask = client.StartAsync(startupCancellation.Token); + + await sessionActivated.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + startupCancellation.Cancel(); + + await Assert.ThrowsExceptionAsync(async () => await startTask.ConfigureAwait(false)).ConfigureAwait(false); + + Assert.IsFalse(client.IsReady); + + Assert.ThrowsException(() => + InvokePrivateMethodWithReturn(client, "GetRequiredActiveSession", false)); + } + + [TestMethod] + public async Task StartAsync_WhenInitializationTimesOut_UsesConfiguredTimeoutAndLeavesClientNotReady() + { + using var logScope = new NLogMemoryScope(LogLevel.Debug); + var initializeStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var client = new LanguageServerClient( + @"C:\Workspace", + Path.Combine(Environment.SystemDirectory, "cmd.exe"), + new LanguageServerClientOptions(static () => new { }) + { + InitializeTimeout = TimeSpan.FromMilliseconds(50) + }, + processStartedTestHook: null, + sessionActivatedTestHook: null, + beforeInitializeRequestTestHook: async cancellationToken => + { + initializeStarted.TrySetResult(true); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); + }); + + Task startTask = client.StartAsync(CancellationToken.None); + + await initializeStarted.Task.WaitAsync(TimeSpan.FromSeconds(1)).ConfigureAwait(false); + + bool started = await startTask.ConfigureAwait(false); + + Assert.IsFalse(started); + Assert.IsFalse(client.IsReady); + + Assert.IsTrue(logScope.Logs.Any(log => log.StartsWith("Warn|", StringComparison.Ordinal) + && log.Contains("did not complete initialization within 50 ms", StringComparison.OrdinalIgnoreCase)), + string.Join(Environment.NewLine, logScope.Logs)); + } + + [TestMethod] + public void JsonRpc_Disconnected_UnexpectedDisconnect_LogsRecentStderrContext() + { + using var logScope = new NLogMemoryScope(LogLevel.Debug); + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object session = CreateTransportSession(client, 11, process: null, Stream.Null, Stream.Null); + + SetActiveSession(client, session); + SetReadyState(client, true); + RecordStandardErrorLine(session, "LuaLS handshake failed near initialize."); + + InvokePrivateMethod(client, "JsonRpc_Disconnected", + session, + new JsonRpcDisconnectedEventArgs("stream closed", DisconnectedReason.StreamError)); + + Assert.IsTrue(logScope.Logs.Any(log => log.StartsWith("Warn|", StringComparison.Ordinal) + && log.Contains("disconnected unexpectedly", StringComparison.OrdinalIgnoreCase) + && log.Contains("workspace", StringComparison.OrdinalIgnoreCase) + && log.Contains("recreate the session", StringComparison.OrdinalIgnoreCase) + && log.Contains("generation 11", StringComparison.OrdinalIgnoreCase)), + string.Join(Environment.NewLine, logScope.Logs)); + + Assert.IsTrue(logScope.Logs.Any(log => log.StartsWith("Warn|", StringComparison.Ordinal) + && log.Contains("recent language server stderr", StringComparison.OrdinalIgnoreCase) + && log.Contains("LuaLS handshake failed near initialize.", StringComparison.Ordinal)), + string.Join(Environment.NewLine, logScope.Logs)); + } + + [TestMethod] + public void LanguageServerClientOptions_RejectsNonPositiveTimeouts() + { + Assert.ThrowsException(() => new LanguageServerClientOptions(static () => new { }) + { + InitializeTimeout = TimeSpan.Zero + }); + + Assert.ThrowsException(() => new LanguageServerClientOptions(static () => new { }) + { + ShutdownRequestTimeout = Timeout.InfiniteTimeSpan + }); + + Assert.ThrowsException(() => new LanguageServerClientOptions(static () => new { }) + { + DisposeWaitTimeout = TimeSpan.FromMilliseconds(-1) + }); + } + + [TestMethod] + public async Task Dispose_ConcurrentSyncAndAsyncCalls_DoNotFault() + { + for (int i = 0; i < 25; i++) + { + await using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object session = CreateTransportSession(client, i + 1, process: null, Stream.Null, Stream.Null, startListening: true); + + SetActiveSession(client, session); + + Task[] disposeTasks = + [ + Task.Run(client.Dispose), + Task.Run(async () => await client.DisposeAsync().ConfigureAwait(false)), + Task.Run(client.Dispose) + ]; + + await Task.WhenAll(disposeTasks).ConfigureAwait(false); + + await Assert.ThrowsExceptionAsync(() => + client.SendNotificationAsync("workspace/didChangeConfiguration", new { settings = new { } }, CancellationToken.None)) + .ConfigureAwait(false); + } + } + + [TestMethod] + public void Dispose_UsesOneOverallDisposeBudgetAcrossTeardownStages() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", new LanguageServerClientOptions(static () => new { }) + { + DisposeWaitTimeout = TimeSpan.FromMilliseconds(100) + }); + + object session = CreateTransportSession(client, 1, process: null, Stream.Null, Stream.Null); + var pendingRpcCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var pendingStderrLoop = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + SemaphoreSlim startLock = GetStartLock(client); + + SetSessionProperty(session, "RpcCompletionTask", pendingRpcCompletion.Task); + SetSessionProperty(session, "StderrLoopTask", pendingStderrLoop.Task); + SetActiveSession(client, session); + + startLock.Wait(); + var stopwatch = Stopwatch.StartNew(); + + try + { + client.Dispose(); + } + finally + { + stopwatch.Stop(); + + try + { + startLock.Release(); + } + catch (ObjectDisposedException) + { } + catch (SemaphoreFullException) + { } + } + + Assert.IsTrue(stopwatch.Elapsed < TimeSpan.FromMilliseconds(175), + $"Dispose should honor a single overall budget, but took {stopwatch.Elapsed.TotalMilliseconds:F0} ms."); + } + + [TestMethod] + public async Task StartAsync_InterleavedWithDisposeAsync_DoesNotLeaveReadyClientReachable() + { + for (int i = 0; i < 10; i++) + { + var sessionActivated = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var client = new LanguageServerClient( + @"C:\Workspace", + Path.Combine(Environment.SystemDirectory, "cmd.exe"), + DefaultClientOptions, + processStartedTestHook: null, + sessionActivatedTestHook: cancellationToken => WaitForStartupCancellationAsync(sessionActivated, cancellationToken)); + + Task startTask = client.StartAsync(CancellationToken.None); + + await sessionActivated.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + await client.DisposeAsync().ConfigureAwait(false); + + Assert.IsFalse(await startTask.ConfigureAwait(false)); + Assert.IsFalse(client.IsReady); + Assert.ThrowsException(() => GetStartLock(client).Wait(0)); + + await Assert.ThrowsExceptionAsync(() => + client.SendNotificationAsync("workspace/didChangeConfiguration", new { settings = new { } }, CancellationToken.None)) + .ConfigureAwait(false); + } + } +} diff --git a/Tests/TombLib.LanguageServer.Core.Tests/Client/LanguageServerClientTests.SemanticTokens.cs b/Tests/TombLib.LanguageServer.Core.Tests/Client/LanguageServerClientTests.SemanticTokens.cs new file mode 100644 index 0000000000..ef0f0d32e2 --- /dev/null +++ b/Tests/TombLib.LanguageServer.Core.Tests/Client/LanguageServerClientTests.SemanticTokens.cs @@ -0,0 +1,290 @@ +namespace TombLib.LanguageServer.Core.Tests; + +public partial class LanguageServerClientTests +{ + [TestMethod] + public async Task HandleSemanticTokensRefreshRequestAsync_RaisesEventAndReturnsNull() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object session = CreateTransportSession(client, 1, process: null, Stream.Null, Stream.Null); + var refreshRequested = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + SetActiveSession(client, session); + SetReadyState(client, true); + + object rpcTarget = CreateRpcTarget(client, GetTransportGeneration(session)); + + client.SemanticTokensRefreshRequested += () => refreshRequested.TrySetResult(true); + + object? result = await InvokePrivateTaskAsync(rpcTarget, "RefreshSemanticTokensAsync").ConfigureAwait(false); + + Assert.IsNull(result); + await refreshRequested.Task.WaitAsync(TimeSpan.FromSeconds(1)).ConfigureAwait(false); + } + + [TestMethod] + public async Task HandleSemanticTokensRefreshRequestAsync_ReturnsBeforeSlowHandlerCompletes() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object session = CreateTransportSession(client, 1, process: null, Stream.Null, Stream.Null); + var handlerEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowHandlerToFinish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + SetActiveSession(client, session); + SetReadyState(client, true); + + object rpcTarget = CreateRpcTarget(client, GetTransportGeneration(session)); + + client.SemanticTokensRefreshRequested += () => + { + handlerEntered.TrySetResult(true); + allowHandlerToFinish.Task.GetAwaiter().GetResult(); + }; + + Task refreshTask = InvokePrivateTaskAsync(rpcTarget, "RefreshSemanticTokensAsync"); + + Task completedTask = await Task.WhenAny(refreshTask, Task.Delay(TimeSpan.FromSeconds(1))).ConfigureAwait(false); + Assert.AreSame(refreshTask, completedTask); + Assert.IsNull(await refreshTask.ConfigureAwait(false)); + + await handlerEntered.Task.WaitAsync(TimeSpan.FromSeconds(1)).ConfigureAwait(false); + allowHandlerToFinish.TrySetResult(true); + } + + [TestMethod] + public async Task HandleSemanticTokensRefreshRequestAsync_SlowSubscriberDoesNotBlockLaterSubscriber() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object session = CreateTransportSession(client, 1, process: null, Stream.Null, Stream.Null); + var firstSubscriberEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var secondSubscriberObserved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowFirstSubscriberToFinish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + SetActiveSession(client, session); + SetReadyState(client, true); + + object rpcTarget = CreateRpcTarget(client, GetTransportGeneration(session)); + + client.SemanticTokensRefreshRequested += () => + { + firstSubscriberEntered.TrySetResult(true); + allowFirstSubscriberToFinish.Task.GetAwaiter().GetResult(); + }; + + client.SemanticTokensRefreshRequested += () => secondSubscriberObserved.TrySetResult(true); + + object? result = await InvokePrivateTaskAsync(rpcTarget, "RefreshSemanticTokensAsync").ConfigureAwait(false); + + Assert.IsNull(result); + + await Task.WhenAll( + firstSubscriberEntered.Task.WaitAsync(TimeSpan.FromSeconds(1)), + secondSubscriberObserved.Task.WaitAsync(TimeSpan.FromSeconds(1))).ConfigureAwait(false); + + allowFirstSubscriberToFinish.TrySetResult(true); + } + + [TestMethod] + public async Task InvokeSemanticTokensRefreshRequested_WhenSubscriberIsBusy_CoalescesPendingSignalsPerSubscriber() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + var firstInvocationEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowFirstInvocationToFinish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var secondInvocationEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var unexpectedThirdInvocation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int invocationCount = 0; + + client.SemanticTokensRefreshRequested += () => + { + int currentInvocation = Interlocked.Increment(ref invocationCount); + + if (currentInvocation == 1) + { + firstInvocationEntered.TrySetResult(true); + allowFirstInvocationToFinish.Task.GetAwaiter().GetResult(); + return; + } + + if (currentInvocation == 2) + { + secondInvocationEntered.TrySetResult(true); + return; + } + + unexpectedThirdInvocation.TrySetResult(true); + }; + + InvokePrivateMethod(client, "InvokeSemanticTokensRefreshRequested"); + await firstInvocationEntered.Task.WaitAsync(TimeSpan.FromSeconds(1)).ConfigureAwait(false); + + for (int i = 0; i < 10; i++) + InvokePrivateMethod(client, "InvokeSemanticTokensRefreshRequested"); + + allowFirstInvocationToFinish.TrySetResult(true); + await secondInvocationEntered.Task.WaitAsync(TimeSpan.FromSeconds(1)).ConfigureAwait(false); + + Task completedTask = await Task.WhenAny(unexpectedThirdInvocation.Task, Task.Delay(TimeSpan.FromMilliseconds(200))).ConfigureAwait(false); + + Assert.AreNotSame(unexpectedThirdInvocation.Task, completedTask); + Assert.AreEqual(2, Volatile.Read(ref invocationCount)); + } + + [TestMethod] + public async Task HandleSemanticTokensRefreshRequestAsync_CoalescesRepeatedRequestsWhileHandlerIsBusy() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object session = CreateTransportSession(client, 1, process: null, Stream.Null, Stream.Null); + var firstHandlerEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowFirstHandlerToFinish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var secondHandlerEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var unexpectedThirdHandler = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int refreshRequestedCount = 0; + + SetActiveSession(client, session); + SetReadyState(client, true); + + object rpcTarget = CreateRpcTarget(client, GetTransportGeneration(session)); + + client.SemanticTokensRefreshRequested += () => + { + int invocationCount = Interlocked.Increment(ref refreshRequestedCount); + + if (invocationCount == 1) + { + firstHandlerEntered.TrySetResult(true); + allowFirstHandlerToFinish.Task.GetAwaiter().GetResult(); + return; + } + + if (invocationCount == 2) + { + secondHandlerEntered.TrySetResult(true); + return; + } + + unexpectedThirdHandler.TrySetResult(true); + }; + + Assert.IsNull(await InvokePrivateTaskAsync(rpcTarget, "RefreshSemanticTokensAsync").ConfigureAwait(false)); + await firstHandlerEntered.Task.WaitAsync(TimeSpan.FromSeconds(1)).ConfigureAwait(false); + + Task[] repeatedRefreshRequests = + [ + InvokePrivateTaskAsync(rpcTarget, "RefreshSemanticTokensAsync"), + InvokePrivateTaskAsync(rpcTarget, "RefreshSemanticTokensAsync"), + InvokePrivateTaskAsync(rpcTarget, "RefreshSemanticTokensAsync"), + InvokePrivateTaskAsync(rpcTarget, "RefreshSemanticTokensAsync"), + InvokePrivateTaskAsync(rpcTarget, "RefreshSemanticTokensAsync") + ]; + + object?[] repeatedResults = await Task.WhenAll(repeatedRefreshRequests).WaitAsync(TimeSpan.FromSeconds(1)).ConfigureAwait(false); + + Assert.IsTrue(repeatedResults.All(result => result is null)); + + allowFirstHandlerToFinish.TrySetResult(true); + await secondHandlerEntered.Task.WaitAsync(TimeSpan.FromSeconds(1)).ConfigureAwait(false); + + Task completedTask = await Task.WhenAny(unexpectedThirdHandler.Task, Task.Delay(TimeSpan.FromMilliseconds(200))).ConfigureAwait(false); + + Assert.AreNotSame(unexpectedThirdHandler.Task, completedTask); + Assert.AreEqual(2, Volatile.Read(ref refreshRequestedCount)); + } + + [TestMethod] + public async Task InvokeSemanticTokensRefreshRequested_AfterUnsubscribe_DoesNotDeliverQueuedCallbacks() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + var firstInvocationEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowFirstInvocationToFinish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var unexpectedSecondInvocation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int invocationCount = 0; + + void handler() + { + int currentInvocation = Interlocked.Increment(ref invocationCount); + + if (currentInvocation == 1) + { + firstInvocationEntered.TrySetResult(true); + allowFirstInvocationToFinish.Task.GetAwaiter().GetResult(); + return; + } + + unexpectedSecondInvocation.TrySetResult(true); + } + + client.SemanticTokensRefreshRequested += handler; + + InvokePrivateMethod(client, "InvokeSemanticTokensRefreshRequested"); + await firstInvocationEntered.Task.WaitAsync(TimeSpan.FromSeconds(1)).ConfigureAwait(false); + + client.SemanticTokensRefreshRequested -= handler; + + for (int i = 0; i < 5; i++) + InvokePrivateMethod(client, "InvokeSemanticTokensRefreshRequested"); + + allowFirstInvocationToFinish.TrySetResult(true); + + Task completedTask = await Task.WhenAny(unexpectedSecondInvocation.Task, Task.Delay(TimeSpan.FromMilliseconds(200))).ConfigureAwait(false); + + Assert.AreNotSame(unexpectedSecondInvocation.Task, completedTask); + Assert.AreEqual(1, Volatile.Read(ref invocationCount)); + } + + [TestMethod] + public async Task HandleSemanticTokensRefreshRequestAsync_IgnoresStaleTransportGeneration() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object oldSession = CreateTransportSession(client, 1, process: null, Stream.Null, Stream.Null); + object newSession = CreateTransportSession(client, 2, process: null, Stream.Null, Stream.Null); + int refreshRequestedCount = 0; + + SetActiveSession(client, newSession); + SetReadyState(client, true); + + client.SemanticTokensRefreshRequested += () => refreshRequestedCount++; + + object oldRpcTarget = CreateRpcTarget(client, GetTransportGeneration(oldSession)); + object? result = await InvokePrivateTaskAsync(oldRpcTarget, "RefreshSemanticTokensAsync").ConfigureAwait(false); + + Assert.AreEqual(0, refreshRequestedCount); + Assert.IsNull(result); + } + + [TestMethod] + public async Task HandleSemanticTokensRefreshRequestAsync_WhenTransportIsAttachedButNotReady_DeliversRefreshCallback() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object session = CreateTransportSession(client, 1, process: null, Stream.Null, Stream.Null); + var refreshRequested = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + SetActiveSession(client, session); + client.SemanticTokensRefreshRequested += () => refreshRequested.TrySetResult(true); + + object rpcTarget = CreateRpcTarget(client, GetTransportGeneration(session)); + object? result = await InvokePrivateTaskAsync(rpcTarget, "RefreshSemanticTokensAsync").ConfigureAwait(false); + + Assert.IsNull(result); + await refreshRequested.Task.WaitAsync(TimeSpan.FromSeconds(1)).ConfigureAwait(false); + } + + [TestMethod] + public async Task HandleSemanticTokensRefreshRequestAsync_IgnoresUnhealthyTransportGeneration() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object session = CreateTransportSession(client, 1, process: null, Stream.Null, Stream.Null); + int refreshRequestedCount = 0; + + SetActiveSession(client, session); + SetReadyState(client, true); + client.SemanticTokensRefreshRequested += () => refreshRequestedCount++; + + client.MarkTransportUnhealthy(); + + object rpcTarget = CreateRpcTarget(client, GetTransportGeneration(session)); + object? result = await InvokePrivateTaskAsync(rpcTarget, "RefreshSemanticTokensAsync").ConfigureAwait(false); + + Assert.AreEqual(0, refreshRequestedCount); + Assert.IsNull(result); + } +} diff --git a/Tests/TombLib.LanguageServer.Core.Tests/Client/LanguageServerClientTests.cs b/Tests/TombLib.LanguageServer.Core.Tests/Client/LanguageServerClientTests.cs new file mode 100644 index 0000000000..36bbe28950 --- /dev/null +++ b/Tests/TombLib.LanguageServer.Core.Tests/Client/LanguageServerClientTests.cs @@ -0,0 +1,1591 @@ +using NLog; +using StreamJsonRpc; +using System.Diagnostics; +using System.Reflection; +using System.Text; +using System.Text.Json; + +namespace TombLib.LanguageServer.Core.Tests; + +[TestClass] +public partial class LanguageServerClientTests +{ + private static readonly LanguageServerClientOptions DefaultClientOptions = new(static () => new { }); + + [TestMethod] + public async Task SendNotificationAsync_CompletesAfterLocalDispatchEvenWhenTransportWriteRemainsBlocked() + { + using var blockingWriteStream = new BlockingWriteStream(); + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object session = CreateTransportSession(client, 1, process: null, blockingWriteStream, Stream.Null); + using var cancellationSource = new CancellationTokenSource(TimeSpan.FromMilliseconds(50)); + + SetActiveSession(client, session); + SetReadyState(client, true); + + Task notificationTask = client.SendNotificationAsync( + "workspace/didChangeConfiguration", + new { settings = new { } }, + cancellationSource.Token); + + try + { + Task completedTask = await Task.WhenAny(notificationTask, Task.Delay(TimeSpan.FromSeconds(1))).ConfigureAwait(false); + Assert.AreSame(notificationTask, completedTask); + await notificationTask.ConfigureAwait(false); + } + finally + { + blockingWriteStream.Release(); + } + } + + [TestMethod] + public void CapabilityRegistrationParams_Deserialize_BindsRegistrationsWireProperty() + { + CapabilityRegistrationParams parameters = JsonSerializer.Deserialize( + """ + { + "registrations": [ + { "id": "1", "method": "textDocument/rename" } + ] + } + """); + + Assert.IsNotNull(parameters.Registrations); + Assert.AreEqual(1, parameters.Registrations.Length); + Assert.AreEqual("1", parameters.Registrations[0].Id); + Assert.AreEqual("textDocument/rename", parameters.Registrations[0].Method); + } + + [TestMethod] + public void CapabilityUnregistrationParams_Deserialize_BindsHistoricalWireProperty() + { + CapabilityUnregistrationParams parameters = JsonSerializer.Deserialize( + """ + { + "unregisterations": [ + { "id": "1", "method": "textDocument/rename" } + ] + } + """); + + Assert.IsNotNull(parameters.Unregistrations); + Assert.AreEqual(1, parameters.Unregistrations.Length); + Assert.AreEqual("1", parameters.Unregistrations[0].Id); + Assert.AreEqual("textDocument/rename", parameters.Unregistrations[0].Method); + } + + [TestMethod] + public void CapabilityUnregistrationParams_Deserialize_BindsCorrectedPropertyName() + { + CapabilityUnregistrationParams parameters = JsonSerializer.Deserialize( + """ + { + "unregistrations": [ + { "id": "1", "method": "textDocument/rename" } + ] + } + """); + + Assert.IsNotNull(parameters.Unregistrations); + Assert.AreEqual(1, parameters.Unregistrations.Length); + Assert.AreEqual("1", parameters.Unregistrations[0].Id); + Assert.AreEqual("textDocument/rename", parameters.Unregistrations[0].Method); + } + + [TestMethod] + public void CapabilityUnregistrationParams_Serialize_WritesCurrentSpecPropertyName() + { + string json = JsonSerializer.Serialize( + new CapabilityUnregistrationParams( + [ + new CapabilityUnregistrationPayload("1", "textDocument/rename") + ])); + + using JsonDocument document = JsonDocument.Parse(json); + JsonElement root = document.RootElement; + + Assert.IsTrue(root.TryGetProperty("unregisterations", out JsonElement unregistrations)); + Assert.IsFalse(root.TryGetProperty("unregistrations", out _)); + Assert.AreEqual(JsonValueKind.Array, unregistrations.ValueKind); + Assert.AreEqual(1, unregistrations.GetArrayLength()); + } + + [TestMethod] + public void GetRequiredReadySession_WhenClientIsNotReady_ThrowsIOException() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object session = CreateTransportSession(client, 1, process: null, Stream.Null, Stream.Null); + + SetActiveSession(client, session); + + Assert.IsFalse(client.IsReady); + + TargetInvocationException exception = Assert.ThrowsException(() => + InvokePrivateMethodWithReturn(client, "GetRequiredReadySession", false)); + + Assert.IsInstanceOfType(exception.InnerException, typeof(IOException)); + } + + [TestMethod] + public void GetRequiredReadySession_WhenActiveSessionGenerationDoesNotMatchPublishedReadyGeneration_ThrowsIOException() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object readySession = CreateTransportSession(client, 1, process: null, Stream.Null, Stream.Null); + object replacementSession = CreateTransportSession(client, 2, process: null, Stream.Null, Stream.Null); + + SetActiveSession(client, readySession); + SetReadyState(client, true); + SetPrivateField(client, "_activeSession", replacementSession); + + TargetInvocationException exception = Assert.ThrowsException(() => + InvokePrivateMethodWithReturn(client, "GetRequiredReadySession", false)); + + Assert.IsInstanceOfType(exception.InnerException, typeof(IOException)); + } + + [TestMethod] + public async Task SendNotificationAsync_WhenClientIsNotReady_ThrowsIOException() + { + using var blockingWriteStream = new BlockingWriteStream(); + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object session = CreateTransportSession(client, 1, process: null, blockingWriteStream, Stream.Null); + + SetActiveSession(client, session); + + Assert.IsFalse(client.IsReady); + + await Assert.ThrowsExceptionAsync(async () => + await client.SendNotificationAsync( + "workspace/didChangeConfiguration", + new { settings = new { } }, + CancellationToken.None).ConfigureAwait(false)).ConfigureAwait(false); + } + + [TestMethod] + public async Task SendNotificationAsync_WhenPayloadSerializationFails_DoesNotInvalidateTransport() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object session = CreateTransportSession(client, 10, process: null, Stream.Null, Stream.Null); + var cyclicPayload = new Dictionary(); + + cyclicPayload["self"] = cyclicPayload; + + SetActiveSession(client, session); + SetReadyState(client, true); + + Exception? observedException = null; + + try + { + await client.SendNotificationAsync("workspace/didChangeWatchedFiles", cyclicPayload, CancellationToken.None).ConfigureAwait(false); + Assert.Fail("Expected the notification serialization to fail."); + } + catch (Exception exception) + { + observedException = exception; + } + + Assert.IsNotNull(observedException); + Assert.IsFalse(observedException is LanguageServerTransportUnavailableException); + Assert.IsTrue(client.IsReady); + Assert.AreEqual(10L, client.TransportGeneration); + } + + [TestMethod] + public async Task SendRequestAsync_WhenClientIsNotReady_ThrowsIOException() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object session = CreateTransportSession(client, 1, process: null, Stream.Null, Stream.Null); + + SetActiveSession(client, session); + + Assert.IsFalse(client.IsReady); + + await Assert.ThrowsExceptionAsync(async () => + await client.SendRequestAsync( + "workspace/configuration", + new WorkspaceConfigurationParams([]), + CancellationToken.None).ConfigureAwait(false)).ConfigureAwait(false); + } + + [TestMethod] + public async Task MarkTransportUnhealthy_DuringInFlightRequest_DoesNotCancelOwnedSessionButBlocksFutureRequests() + { + using var serverOutputStream = new PendingReadStream(); + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object session = CreateTransportSession(client, 1, process: null, serverOutputStream, Stream.Null, startListening: true); + + SetActiveSession(client, session); + SetReadyState(client, true); + + Task requestTask = client.SendRequestAsync( + "workspace/configuration", + new WorkspaceConfigurationParams([]), + CancellationToken.None); + + Task completedTask = await Task.WhenAny(requestTask, Task.Delay(TimeSpan.FromMilliseconds(100))).ConfigureAwait(false); + Assert.AreNotSame(requestTask, completedTask); + + client.MarkTransportUnhealthy(); + + Assert.IsFalse(client.IsReady); + + await Assert.ThrowsExceptionAsync(async () => + await client.SendRequestAsync( + "workspace/configuration", + new WorkspaceConfigurationParams([]), + CancellationToken.None).ConfigureAwait(false)).ConfigureAwait(false); + + completedTask = await Task.WhenAny(requestTask, Task.Delay(TimeSpan.FromMilliseconds(100))).ConfigureAwait(false); + Assert.AreNotSame(requestTask, completedTask); + + ((JsonRpc)GetPropertyValue(session, "JsonRpc")).Dispose(); + + await AssertFaultedOrCanceledAsync(requestTask).ConfigureAwait(false); + } + + [TestMethod] + public async Task ActiveSessionDisconnect_WhileRequestIsInFlight_CompletesPendingRequestAndMarksClientNotReady() + { + using var serverOutputStream = new PendingReadStream(); + await using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object session = CreateTransportSession(client, 1, process: null, serverOutputStream, Stream.Null, startListening: true); + + SetActiveSession(client, session); + SetReadyState(client, true); + + Task requestTask = client.SendRequestAsync( + "workspace/configuration", + new WorkspaceConfigurationParams([]), + CancellationToken.None); + + Task completedTask = await Task.WhenAny(requestTask, Task.Delay(TimeSpan.FromMilliseconds(100))).ConfigureAwait(false); + Assert.AreNotSame(requestTask, completedTask); + + ((JsonRpc)GetPropertyValue(session, "JsonRpc")).Dispose(); + + completedTask = await Task.WhenAny(requestTask, Task.Delay(TimeSpan.FromSeconds(1))).ConfigureAwait(false); + Assert.AreSame(requestTask, completedTask); + Assert.IsFalse(client.IsReady); + + await Assert.ThrowsExceptionAsync(async () => + await client.SendRequestAsync( + "workspace/configuration", + new WorkspaceConfigurationParams([]), + CancellationToken.None).ConfigureAwait(false)).ConfigureAwait(false); + + await AssertFaultedOrCanceledAsync(requestTask).ConfigureAwait(false); + } + + [TestMethod] + public async Task SendRequestAsync_WhenActiveTransportFails_LogsRequestFailureWithGeneration() + { + using var logScope = new NLogMemoryScope(LogLevel.Debug); + using var serverOutputStream = new PendingReadStream(); + await using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object session = CreateTransportSession(client, 7, process: null, serverOutputStream, Stream.Null, startListening: true); + + SetActiveSession(client, session); + SetReadyState(client, true); + + Task requestTask = client.SendRequestAsync( + "workspace/configuration", + new WorkspaceConfigurationParams([]), + CancellationToken.None); + + Task completedTask = await Task.WhenAny(requestTask, Task.Delay(TimeSpan.FromMilliseconds(100))).ConfigureAwait(false); + Assert.AreNotSame(requestTask, completedTask); + + ((JsonRpc)GetPropertyValue(session, "JsonRpc")).Dispose(); + + await AssertFaultedOrCanceledAsync(requestTask).ConfigureAwait(false); + Assert.IsFalse(client.IsReady); + + Assert.IsTrue(logScope.Logs.Any(log => log.Contains("request", StringComparison.OrdinalIgnoreCase) + && log.Contains("workspace/configuration", StringComparison.OrdinalIgnoreCase) + && log.Contains("failed", StringComparison.OrdinalIgnoreCase) + && log.Contains("generation 7", StringComparison.OrdinalIgnoreCase)), + string.Join(Environment.NewLine, logScope.Logs)); + } + + [TestMethod] + public async Task SendRequestAsync_WhenServerReturnsJsonRpcError_DoesNotInvalidateTransport() + { + using var deferredServerOutputStream = new DeferredPersistentJsonRpcResponseStream(); + using var serverInputStream = new RecordingStream(); + await using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object session = CreateTransportSession(client, 9, process: null, deferredServerOutputStream, serverInputStream, startListening: true); + + SetActiveSession(client, session); + SetReadyState(client, true); + + Task requestTask = client.SendRequestAsync( + "workspace/configuration", + new WorkspaceConfigurationParams([]), + CancellationToken.None); + + int requestId = await WaitForRequestIdAsync(serverInputStream).ConfigureAwait(false); + deferredServerOutputStream.SetPayload(CreateJsonRpcErrorMessage(requestId, -32000, "Simulated request failure.")); + + Exception? observedException = null; + + try + { + await requestTask.ConfigureAwait(false); + Assert.Fail("Expected the JSON-RPC request to fail."); + } + catch (Exception exception) + { + observedException = exception; + } + + Assert.IsNotNull(observedException); + Assert.IsFalse(observedException is LanguageServerTransportUnavailableException); + Assert.IsTrue(client.IsReady); + Assert.AreEqual(9L, client.TransportGeneration); + } + + [TestMethod] + public void JsonRpc_Disconnected_LocallyDisposedActiveTransport_LogsExpectedShutdownAtInfo() + { + using var logScope = new NLogMemoryScope(LogLevel.Debug); + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object session = CreateTransportSession(client, 3, process: null, Stream.Null, Stream.Null); + + SetActiveSession(client, session); + SetReadyState(client, true); + + InvokePrivateMethod(client, "JsonRpc_Disconnected", + session, + new JsonRpcDisconnectedEventArgs("active transport closed", DisconnectedReason.LocallyDisposed)); + + Assert.IsTrue(logScope.Logs.Any(log => log.StartsWith("Info|", StringComparison.Ordinal) + && log.Contains("expected local shutdown", StringComparison.OrdinalIgnoreCase) + && log.Contains("generation 3", StringComparison.OrdinalIgnoreCase)), + string.Join(Environment.NewLine, logScope.Logs)); + } + + [TestMethod] + public async Task JsonRpc_Disconnected_DuringClientDisposal_LogsDebugWithoutWarning() + { + using var logScope = new NLogMemoryScope(LogLevel.Debug); + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object session = CreateTransportSession(client, 4, process: null, Stream.Null, Stream.Null); + + SetActiveSession(client, session); + SetReadyState(client, true); + + bool disposeStarted = (bool)InvokePrivateMethodWithReturn(client, "TryBeginDispose"); + + Assert.IsTrue(disposeStarted); + + InvokePrivateMethod(client, "JsonRpc_Disconnected", + session, + new JsonRpcDisconnectedEventArgs("disposing transport closed", DisconnectedReason.StreamError)); + + Assert.IsFalse(client.IsReady); + + Assert.IsTrue(logScope.Logs.Any(log => log.StartsWith("Debug|", StringComparison.Ordinal) + && log.Contains("during client disposal", StringComparison.OrdinalIgnoreCase) + && log.Contains("generation 4", StringComparison.OrdinalIgnoreCase)), + string.Join(Environment.NewLine, logScope.Logs)); + + Assert.IsFalse(logScope.Logs.Any(log => log.StartsWith("Warn|", StringComparison.Ordinal) + && log.Contains("disconnected unexpectedly", StringComparison.OrdinalIgnoreCase)), + string.Join(Environment.NewLine, logScope.Logs)); + + await InvokePrivateTaskAsync(client, "DisposeCoreAsync").ConfigureAwait(false); + } + + [TestMethod] + public void MarkTransportUnhealthy_WhenReady_LogsRestartBoundary() + { + using var logScope = new NLogMemoryScope(LogLevel.Debug); + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object session = CreateTransportSession(client, 5, process: null, Stream.Null, Stream.Null); + + SetActiveSession(client, session); + + InvokePrivateMethod(client, "CaptureServerCapabilities", DeserializeInitializeResponse( + """ + { + "capabilities": { + "textDocumentSync": { + "change": 1 + }, + "renameProvider": true, + "semanticTokensProvider": { + "full": { + "delta": true + }, + "legend": { + "tokenTypes": ["function"], + "tokenModifiers": ["declaration"] + } + } + } + } + """)); + + SetReadyState(client, true); + + client.MarkTransportUnhealthy(); + + Assert.IsFalse(client.IsReady); + Assert.AreEqual(5L, client.TransportGeneration); + Assert.AreEqual(TextDocumentSyncKind.None, client.TextDocumentSyncKind); + Assert.AreEqual(0, client.SemanticTokenTypes.Count); + Assert.AreEqual(0, client.SemanticTokenModifiers.Count); + Assert.IsFalse(client.SupportsCompletionResolve); + Assert.IsFalse(client.SupportsReferences); + Assert.IsFalse(client.SupportsRename); + Assert.IsFalse(client.SupportsFormatting); + Assert.IsFalse(client.SupportsSemanticTokensDelta); + + Assert.IsTrue(logScope.Logs.Any(log => log.StartsWith("Warn|", StringComparison.Ordinal) + && log.Contains("generation 5", StringComparison.OrdinalIgnoreCase) + && log.Contains("restart", StringComparison.OrdinalIgnoreCase)), + string.Join(Environment.NewLine, logScope.Logs)); + } + + [TestMethod] + public void TryMarkTransportUnhealthy_StaleGenerationDoesNotOverwriteActiveSnapshot() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object oldSession = CreateTransportSession(client, 5, process: null, Stream.Null, Stream.Null); + object newSession = CreateTransportSession(client, 6, process: null, Stream.Null, Stream.Null); + + SetActiveSession(client, newSession); + + InvokePrivateMethod(client, "CaptureServerCapabilities", DeserializeInitializeResponse( + """ + { + "capabilities": { + "textDocumentSync": { + "change": 1 + }, + "renameProvider": true + } + } + """)); + + SetReadyState(client, true); + + bool markedUnhealthy = client.TryMarkTransportUnhealthy(GetTransportGeneration(oldSession)); + + Assert.IsFalse(markedUnhealthy); + + Assert.AreEqual(6L, client.TransportGeneration); + Assert.IsTrue(client.IsReady); + Assert.AreEqual(TextDocumentSyncKind.Full, client.TextDocumentSyncKind); + Assert.IsTrue(client.SupportsRename); + } + + [TestMethod] + public void DetachActiveSession_ResetsPublishedCapabilitySnapshot() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object session = CreateTransportSession(client, 9, process: null, Stream.Null, Stream.Null); + + SetActiveSession(client, session); + + InvokePrivateMethod(client, "CaptureServerCapabilities", DeserializeInitializeResponse( + """ + { + "capabilities": { + "textDocumentSync": { + "change": 1 + }, + "referencesProvider": true, + "renameProvider": true, + "documentFormattingProvider": true, + "semanticTokensProvider": { + "full": { + "delta": true + }, + "legend": { + "tokenTypes": ["function"], + "tokenModifiers": ["declaration"] + } + } + } + } + """)); + + SetReadyState(client, true); + + object detachedSession = InvokePrivateMethodWithReturn(client, "DetachActiveSession"); + + Assert.AreSame(session, detachedSession); + Assert.AreEqual(0L, client.TransportGeneration); + Assert.IsFalse(client.IsReady); + Assert.AreEqual(TextDocumentSyncKind.None, client.TextDocumentSyncKind); + Assert.AreEqual(0, client.SemanticTokenTypes.Count); + Assert.AreEqual(0, client.SemanticTokenModifiers.Count); + Assert.IsFalse(client.SupportsCompletionResolve); + Assert.IsFalse(client.SupportsReferences); + Assert.IsFalse(client.SupportsRename); + Assert.IsFalse(client.SupportsFormatting); + Assert.IsFalse(client.SupportsSemanticTokensDelta); + } + + [TestMethod] + public async Task SendNotificationAsync_WhenStartupHandshakeIsInProgress_ThrowsIOException() + { + var sessionActivated = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var startupCancellation = new CancellationTokenSource(); + + await using var client = new LanguageServerClient( + @"C:\Workspace", + Path.Combine(Environment.SystemDirectory, "cmd.exe"), + DefaultClientOptions, + processStartedTestHook: null, + sessionActivatedTestHook: cancellationToken => WaitForStartupCancellationAsync(sessionActivated, cancellationToken)); + + Task startTask = client.StartAsync(startupCancellation.Token); + + await sessionActivated.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + await Assert.ThrowsExceptionAsync(async () => + await client.SendNotificationAsync( + "workspace/didChangeConfiguration", + new { settings = new { } }, + CancellationToken.None).ConfigureAwait(false)).ConfigureAwait(false); + + startupCancellation.Cancel(); + + await Assert.ThrowsExceptionAsync(async () => await startTask.ConfigureAwait(false)).ConfigureAwait(false); + } + + [TestMethod] + public async Task SendRequestAsync_WhenStartupHandshakeIsInProgress_ThrowsIOException() + { + var sessionActivated = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var startupCancellation = new CancellationTokenSource(); + + await using var client = new LanguageServerClient( + @"C:\Workspace", + Path.Combine(Environment.SystemDirectory, "cmd.exe"), + DefaultClientOptions, + processStartedTestHook: null, + sessionActivatedTestHook: cancellationToken => WaitForStartupCancellationAsync(sessionActivated, cancellationToken)); + + Task startTask = client.StartAsync(startupCancellation.Token); + + await sessionActivated.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + await Assert.ThrowsExceptionAsync(async () => + await client.SendRequestAsync( + "workspace/configuration", + new WorkspaceConfigurationParams([]), + CancellationToken.None).ConfigureAwait(false)).ConfigureAwait(false); + + startupCancellation.Cancel(); + + await Assert.ThrowsExceptionAsync(async () => await startTask.ConfigureAwait(false)).ConfigureAwait(false); + } + + [TestMethod] + public void RegisterCapability_IgnoresDynamicRegistrationAndLogsWarning() + { + using var logScope = new NLogMemoryScope(LogLevel.Debug); + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object session = CreateTransportSession(client, 1, process: null, Stream.Null, Stream.Null); + + SetActiveSession(client, session); + + object rpcTarget = CreateRpcTarget(client, GetTransportGeneration(session)); + object? result = InvokePrivateMethodAllowingNull(rpcTarget, "RegisterCapability", + new CapabilityRegistrationParams( + [ + new CapabilityRegistrationPayload("1", "textDocument/rename") + ])); + + Assert.IsNull(result); + + Assert.IsTrue(logScope.Logs.Any(log => log.StartsWith("Warn|", StringComparison.Ordinal) + && log.Contains("client/registerCapability", StringComparison.Ordinal) + && log.Contains("generation 1", StringComparison.OrdinalIgnoreCase) + && log.Contains("dynamicRegistration = false", StringComparison.Ordinal) + && log.Contains("textDocument/rename", StringComparison.Ordinal)), + string.Join(Environment.NewLine, logScope.Logs)); + } + + [TestMethod] + public void UnregisterCapability_IgnoresDynamicUnregistrationAndLogsWarning() + { + using var logScope = new NLogMemoryScope(LogLevel.Debug); + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object session = CreateTransportSession(client, 2, process: null, Stream.Null, Stream.Null); + + SetActiveSession(client, session); + + object rpcTarget = CreateRpcTarget(client, GetTransportGeneration(session)); + object? result = InvokePrivateMethodAllowingNull(rpcTarget, "UnregisterCapability", + new CapabilityUnregistrationParams( + [ + new CapabilityUnregistrationPayload("1", "textDocument/rename") + ])); + + Assert.IsNull(result); + + Assert.IsTrue(logScope.Logs.Any(log => log.StartsWith("Warn|", StringComparison.Ordinal) + && log.Contains("client/unregisterCapability", StringComparison.Ordinal) + && log.Contains("generation 2", StringComparison.OrdinalIgnoreCase) + && log.Contains("dynamicRegistration = false", StringComparison.Ordinal) + && log.Contains("textDocument/rename", StringComparison.Ordinal)), + string.Join(Environment.NewLine, logScope.Logs)); + } + + [TestMethod] + public void RegisterCapability_StaleTransportGeneration_LogsDebugWithoutWarning() + { + using var logScope = new NLogMemoryScope(LogLevel.Debug); + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object oldSession = CreateTransportSession(client, 3, process: null, Stream.Null, Stream.Null); + object newSession = CreateTransportSession(client, 4, process: null, Stream.Null, Stream.Null); + + SetActiveSession(client, newSession); + + object rpcTarget = CreateRpcTarget(client, GetTransportGeneration(oldSession)); + object? result = InvokePrivateMethodAllowingNull(rpcTarget, "RegisterCapability", + new CapabilityRegistrationParams( + [ + new CapabilityRegistrationPayload("1", "textDocument/rename") + ])); + + Assert.IsNull(result); + + Assert.IsTrue(logScope.Logs.Any(log => log.StartsWith("Debug|", StringComparison.Ordinal) + && log.Contains("client/registerCapability", StringComparison.Ordinal) + && log.Contains("generation 3", StringComparison.OrdinalIgnoreCase) + && log.Contains("stale", StringComparison.OrdinalIgnoreCase)), + string.Join(Environment.NewLine, logScope.Logs)); + + Assert.IsFalse(logScope.Logs.Any(log => log.StartsWith("Warn|", StringComparison.Ordinal) + && log.Contains("client/registerCapability", StringComparison.Ordinal)), + string.Join(Environment.NewLine, logScope.Logs)); + } + + [TestMethod] + public void Hello_IgnoresArrayPayloadWithoutThrowing() + { + using var logScope = new NLogMemoryScope(LogLevel.Debug); + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object session = CreateTransportSession(client, 5, process: null, Stream.Null, Stream.Null); + + SetActiveSession(client, session); + SetReadyState(client, true); + + object rpcTarget = CreateRpcTarget(client, GetTransportGeneration(session)); + + InvokePrivateMethod(rpcTarget, "Hello", JsonSerializer.SerializeToElement(new[] { "world" })); + + Assert.IsTrue(logScope.Logs.Any(log => log.StartsWith("Debug|", StringComparison.Ordinal) + && log.Contains("$/hello", StringComparison.Ordinal) + && log.Contains("generation 5", StringComparison.OrdinalIgnoreCase)), + string.Join(Environment.NewLine, logScope.Logs)); + } + + [TestMethod] + public async Task SendRequestAsync_WhenTransportGenerationIsReplacedBeforeSuccessfulResponse_CompletesWithTransportChangedException() + { + using var deferredServerOutputStream = new DeferredJsonRpcResponseStream(); + using var serverInputStream = new RecordingStream(); + await using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object originalSession = CreateTransportSession(client, 1, process: null, deferredServerOutputStream, serverInputStream, startListening: true); + object replacementSession = CreateTransportSession(client, 2, process: null, Stream.Null, Stream.Null); + + SetActiveSession(client, originalSession); + SetReadyState(client, true); + + Task requestTask = client.SendRequestAsync( + "workspace/configuration", + new WorkspaceConfigurationParams([]), + CancellationToken.None); + + int requestId = await WaitForRequestIdAsync(serverInputStream).ConfigureAwait(false); + + SetActiveSession(client, replacementSession); + SetReadyState(client, true); + deferredServerOutputStream.SetPayload(CreateJsonRpcResultMessage(requestId, "{\"value\":1}")); + + await Assert.ThrowsExceptionAsync(async () => await requestTask.ConfigureAwait(false)).ConfigureAwait(false); + } + + [TestMethod] + public void JsonRpc_Disconnected_OldTransportGenerationDoesNotAffectActiveSession() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + 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); + SetReadyState(client, 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); + Assert.IsNull(GetPrivateFieldAllowingNull(client, "_activeSession")); + } + + [TestMethod] + public void Process_Exited_SupersededTransportGenerationDoesNotAffectActiveSessionOrWarn() + { + using var logScope = new NLogMemoryScope(LogLevel.Debug); + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + 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); + SetReadyState(client, true); + + InvokePrivateMethod(client, "Process_Exited", oldSession); + + Assert.AreEqual(2L, client.TransportGeneration); + Assert.IsTrue(client.IsReady); + + Assert.IsFalse(logScope.Logs.Any(log => log.StartsWith("Warn|", StringComparison.Ordinal) + && log.Contains("generation 1", StringComparison.OrdinalIgnoreCase) + && log.Contains("exited unexpectedly", StringComparison.OrdinalIgnoreCase)), + string.Join(Environment.NewLine, logScope.Logs)); + } + + [TestMethod] + public void Process_Exited_ActiveTransport_DetachesActiveSessionImmediately() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object session = CreateTransportSession(client, 2, process: null, Stream.Null, Stream.Null); + + SetActiveSession(client, session); + SetReadyState(client, true); + + InvokePrivateMethod(client, "Process_Exited", session); + + Assert.IsFalse(client.IsReady); + Assert.IsNull(GetPrivateFieldAllowingNull(client, "_activeSession")); + } + + [TestMethod] + public async Task DisposeAsync_WaitsForDetachedFailedSessionCleanup() + { + var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + var queuedCleanup = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + SetPrivateField(client, "_queuedFailedSessionDisposal", queuedCleanup.Task); + + Task disposeTask = client.DisposeAsync().AsTask(); + Task completedTask = await Task.WhenAny(disposeTask, Task.Delay(TimeSpan.FromMilliseconds(200))).ConfigureAwait(false); + + Assert.AreNotSame(disposeTask, completedTask); + + queuedCleanup.TrySetResult(true); + await disposeTask.ConfigureAwait(false); + } + + [TestMethod] + public void SetCapabilityReadinessForGeneration_StaleGenerationDoesNotClearActiveSnapshot() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + 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); + + InvokePrivateMethod(client, "CaptureServerCapabilities", DeserializeInitializeResponse( + """ + { + "capabilities": { + "textDocumentSync": { + "change": 1 + }, + "documentFormattingProvider": true + } + } + """)); + + SetReadyState(client, true); + + InvokePrivateMethod(client, "SetCapabilityReadinessForGeneration", GetTransportGeneration(oldSession), false); + + Assert.AreEqual(2L, client.TransportGeneration); + Assert.IsTrue(client.IsReady); + Assert.AreEqual(TextDocumentSyncKind.Full, client.TextDocumentSyncKind); + Assert.IsTrue(client.SupportsFormatting); + } + + [TestMethod] + public void CaptureServerCapabilitiesForGeneration_StaleGenerationDoesNotOverwriteActiveCapabilities() + { + using var client = new LanguageServerClient(@"C:\Workspace", "lua-language-server.exe", DefaultClientOptions); + object oldSession = CreateTransportSession(client, 3, process: null, Stream.Null, Stream.Null); + object newSession = CreateTransportSession(client, 4, process: null, Stream.Null, Stream.Null); + + SetActiveSession(client, newSession); + + InvokePrivateMethod(client, "CaptureServerCapabilitiesForGeneration", + GetTransportGeneration(newSession), + DeserializeInitializeResponse( + """ + { + "capabilities": { + "textDocumentSync": { + "change": 1 + }, + "semanticTokensProvider": { + "full": { + "delta": true + }, + "legend": { + "tokenTypes": ["function"], + "tokenModifiers": ["declaration"] + } + } + } + } + """)); + + InvokePrivateMethod(client, "CaptureServerCapabilitiesForGeneration", + GetTransportGeneration(oldSession), + DeserializeInitializeResponse( + """ + { + "capabilities": { + "textDocumentSync": { + "change": 2 + }, + "renameProvider": true + } + } + """)); + + Assert.AreEqual(4L, client.TransportGeneration); + Assert.AreEqual(TextDocumentSyncKind.Full, client.TextDocumentSyncKind); + Assert.IsTrue(client.SupportsSemanticTokensDelta); + CollectionAssert.AreEqual(new[] { "function" }, client.SemanticTokenTypes.ToArray()); + CollectionAssert.AreEqual(new[] { "declaration" }, client.SemanticTokenModifiers.ToArray()); + Assert.IsFalse(client.SupportsRename); + } + + 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 Process StartShortLivedProcess() + { + var startInfo = new ProcessStartInfo + { + FileName = Path.Combine(Environment.SystemDirectory, "cmd.exe"), + Arguments = "/c ping 127.0.0.1 -n 1 -w 200 > nul", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + return Process.Start(startInfo) + ?? throw new InvalidOperationException("Unable to start the short-lived disposable test process."); + } + + private static string CreateJsonRpcResultMessage(int id, string resultJson) + { + string payload = "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"result\":" + resultJson + "}"; + int payloadLength = Encoding.UTF8.GetByteCount(payload); + return "Content-Length: " + payloadLength + "\r\n\r\n" + payload; + } + + private static string CreateJsonRpcErrorMessage(int id, int code, string message) + { + string payload = "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"error\":{\"code\":" + code + ",\"message\":" + JsonSerializer.Serialize(message) + "}}"; + int payloadLength = Encoding.UTF8.GetByteCount(payload); + return "Content-Length: " + payloadLength + "\r\n\r\n" + payload; + } + + private static SemaphoreSlim GetStartLock(LanguageServerClient client) + { + FieldInfo field = typeof(LanguageServerClient).GetField("_startLock", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("Private field '_startLock' was not found."); + + return (SemaphoreSlim)(field.GetValue(client) + ?? throw new InvalidOperationException("Client start lock was null.")); + } + + 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(LanguageServerClient client, long generation, Process? process, Stream serverOutputStream, Stream serverInputStream, bool startListening = false) + { + Type sessionType = typeof(LanguageServerClient).GetNestedType("LanguageServerTransportSession", BindingFlags.NonPublic) + ?? throw new InvalidOperationException("Nested type 'LanguageServerTransportSession' 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, serverOutputStream, serverInputStream]); + object messageHandler = InvokePrivateStaticMethodWithReturn(typeof(LanguageServerClient), "CreateMessageHandler", serverInputStream, serverOutputStream); + object rpcTarget = CreateRpcTarget(client, generation); + + 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(LanguageServerClient client, long generation = 0) + { + Type targetType = typeof(LanguageServerClient).GetNestedType("LanguageServerClientRpcTarget", BindingFlags.NonPublic) + ?? throw new InvalidOperationException("Nested type 'LanguageServerClientRpcTarget' was not found."); + + ConstructorInfo constructor = targetType.GetConstructor( + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + binder: null, + [typeof(LanguageServerClient), 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 object GetPrivateField(object instance, string fieldName) + { + FieldInfo field = instance.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Private field '{fieldName}' was not found."); + + return field.GetValue(instance) + ?? throw new InvalidOperationException($"Private field '{fieldName}' returned null."); + } + + private static object? GetPrivateFieldAllowingNull(object instance, string fieldName) + { + FieldInfo field = instance.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Private field '{fieldName}' was not found."); + + return field.GetValue(instance); + } + + 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(LanguageServerClient client, object session) + { + InvokePrivateMethod(client, "SetActiveSession", session); + } + + private static void SetReadyState(LanguageServerClient client, bool isReady) + { + InvokePrivateMethod(client, "SetCapabilityReadiness", isReady); + } + + 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 RecordStandardErrorLine(object session, string line) + { + MethodInfo method = session.GetType().GetMethod("RecordStandardErrorLine", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("Transport session method 'RecordStandardErrorLine' was not found."); + + method.Invoke(session, [line]); + } + + private static void InvokePrivateMethod(object instance, string methodName, params object?[] parameters) + { + MethodInfo method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.Public | 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.Public | 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? InvokePrivateMethodAllowingNull(object instance, string methodName, params object?[] parameters) + { + MethodInfo method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Private method '{methodName}' was not found."); + + return method.Invoke(instance, parameters); + } + + 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 InitializeResponse DeserializeInitializeResponse(string json) + => JsonSerializer.Deserialize(json) + ?? throw new InvalidOperationException("Failed to deserialize the Lua initialize response test payload."); + + private static PublishDiagnosticsParams CreateDiagnosticsParameters(string uri, string message) => new( + uri, + Version: null, + Diagnostics: + [ + new DiagnosticPayload( + new ProtocolRangePayload( + new ProtocolNullablePosition(0, 0), + new ProtocolNullablePosition(0, 1)), + Severity: null, + Message: message, + Source: null, + Code: null) + ]); + + private static void CancelLifetime(LanguageServerClient client) + { + FieldInfo field = typeof(LanguageServerClient).GetField("_lifetimeCts", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("Private field '_lifetimeCts' was not found."); + + ((CancellationTokenSource)field.GetValue(client)!).Cancel(); + } + + private static async Task WaitForStartupCancellationAsync(TaskCompletionSource sessionActivated, CancellationToken cancellationToken) + { + sessionActivated.TrySetResult(true); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); + } + + private static async Task AssertFaultedOrCanceledAsync(Task task) + { + try + { + await task.ConfigureAwait(false); + Assert.Fail("Expected the task to fault or be canceled."); + } + catch (OperationCanceledException) + { } + catch (Exception) + { } + } + + private static async Task WaitForProcessExitAsync(int processId) + { + for (int attempt = 0; attempt < 20; attempt++) + { + try + { + using Process process = Process.GetProcessById(processId); + + if (process.HasExited) + return true; + } + catch (ArgumentException) + { + return true; + } + + await Task.Delay(50).ConfigureAwait(false); + } + + return false; + } + + private static async Task WaitForRequestIdAsync(RecordingStream stream) + { + for (int attempt = 0; attempt < 20; attempt++) + { + byte[] writtenPayload = stream.GetWrittenBytes(); + + if (TryExtractJsonRpcRequestId(writtenPayload, out int requestId)) + return requestId; + + await Task.Delay(25).ConfigureAwait(false); + } + + throw new AssertFailedException("Timed out waiting for the JSON-RPC request payload to be written."); + } + + private static bool TryExtractJsonRpcRequestId(byte[] writtenPayload, out int requestId) + { + requestId = 0; + + if (writtenPayload.Length == 0) + return false; + + string payloadText = Encoding.UTF8.GetString(writtenPayload); + int bodySeparatorIndex = payloadText.IndexOf("\r\n\r\n", StringComparison.Ordinal); + + if (bodySeparatorIndex < 0) + return false; + + string headerText = payloadText[..bodySeparatorIndex]; + const string contentLengthPrefix = "Content-Length:"; + int contentLengthLineIndex = headerText.IndexOf(contentLengthPrefix, StringComparison.OrdinalIgnoreCase); + + if (contentLengthLineIndex < 0) + throw new AssertFailedException("The JSON-RPC request payload did not contain a Content-Length header."); + + int contentLengthValueStart = contentLengthLineIndex + contentLengthPrefix.Length; + int contentLengthValueEnd = headerText.IndexOf("\r\n", contentLengthValueStart, StringComparison.Ordinal); + string contentLengthText = (contentLengthValueEnd >= 0 + ? headerText[contentLengthValueStart..contentLengthValueEnd] + : headerText[contentLengthValueStart..]).Trim(); + + if (!int.TryParse(contentLengthText, out int contentLength) || contentLength < 0) + throw new AssertFailedException("The JSON-RPC request payload contained an invalid Content-Length header."); + + int bodyStartIndex = bodySeparatorIndex + 4; + + if (writtenPayload.Length < bodyStartIndex + contentLength) + return false; + + string jsonPayload = Encoding.UTF8.GetString(writtenPayload, bodyStartIndex, contentLength); + using JsonDocument document = JsonDocument.Parse(jsonPayload); + + if (!document.RootElement.TryGetProperty("id", out JsonElement idElement) + || !idElement.TryGetInt32(out requestId)) + { + throw new AssertFailedException("The JSON-RPC request payload did not contain an integer request id."); + } + + return true; + } + + private sealed class RecordingStream : Stream + { + private readonly object _syncRoot = new(); + 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 + { + lock (_syncRoot) + return _innerStream.Position; + } + set + { + lock (_syncRoot) + _innerStream.Position = value; + } + } + + public byte[] GetWrittenBytes() + { + lock (_syncRoot) + return _innerStream.ToArray(); + } + + public string GetWrittenText() + => Encoding.UTF8.GetString(GetWrittenBytes()); + + public override void Flush() + { + lock (_syncRoot) + _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) + { + lock (_syncRoot) + _innerStream.SetLength(value); + } + + public override void Write(byte[] buffer, int offset, int count) + { + lock (_syncRoot) + _innerStream.Write(buffer, offset, count); + } + + public override void Write(ReadOnlySpan buffer) + { + lock (_syncRoot) + _innerStream.Write(buffer); + } + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + lock (_syncRoot) + { + _innerStream.Write(buffer.Span); + return ValueTask.CompletedTask; + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + lock (_syncRoot) + _innerStream.Flush(); + } + + base.Dispose(disposing); + } + } + + private sealed class BlockingWriteStream : Stream + { + private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public void Release() + => _release.TrySetResult(true); + + 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) + => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); + + public override void Write(ReadOnlySpan buffer) + => throw new NotSupportedException(); + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + => new(WaitForReleaseAsync(cancellationToken)); + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => WaitForReleaseAsync(cancellationToken); + + public override void Flush() + { } + + public override Task FlushAsync(CancellationToken cancellationToken) + => Task.CompletedTask; + + private async Task WaitForReleaseAsync(CancellationToken cancellationToken) + { + if (_release.Task.IsCompleted) + return; + + if (!cancellationToken.CanBeCanceled) + { + await _release.Task.ConfigureAwait(false); + return; + } + + await _release.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + } + + 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(); + } + + private sealed class DelayedJsonRpcResponseStream : Stream + { + private readonly byte[] _payloadBytes; + private readonly TimeSpan _delay; + private int _position; + private int _delayApplied; + + public DelayedJsonRpcResponseStream(string payload, TimeSpan delay) + { + _payloadBytes = Encoding.UTF8.GetBytes(payload); + _delay = delay; + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => _payloadBytes.Length; + + public override long Position + { + get => _position; + 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) + { + if (Interlocked.Exchange(ref _delayApplied, 1) == 0 && _delay > TimeSpan.Zero) + await Task.Delay(_delay, cancellationToken).ConfigureAwait(false); + + if (_position >= _payloadBytes.Length) + return 0; + + int bytesToCopy = Math.Min(buffer.Length, _payloadBytes.Length - _position); + _payloadBytes.AsMemory(_position, bytesToCopy).CopyTo(buffer); + _position += bytesToCopy; + return bytesToCopy; + } + + 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(); + } + + private sealed class DeferredJsonRpcResponseStream : Stream + { + private readonly TaskCompletionSource _payloadSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + private byte[]? _payloadBytes; + private int _position; + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => _payloadBytes?.Length ?? 0; + + public override long Position + { + get => _position; + set => throw new NotSupportedException(); + } + + public void SetPayload(string payload) + => _payloadSource.TrySetResult(Encoding.UTF8.GetBytes(payload)); + + public override int Read(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + _payloadBytes ??= await _payloadSource.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + + if (_position >= _payloadBytes.Length) + return 0; + + int bytesToCopy = Math.Min(buffer.Length, _payloadBytes.Length - _position); + _payloadBytes.AsMemory(_position, bytesToCopy).CopyTo(buffer); + _position += bytesToCopy; + return bytesToCopy; + } + + 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(); + } + + private sealed class DeferredPersistentJsonRpcResponseStream : Stream + { + private readonly TaskCompletionSource _payloadSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _completionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + private byte[]? _payloadBytes; + private int _position; + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => _payloadBytes?.Length ?? 0; + + public override long Position + { + get => _position; + set => throw new NotSupportedException(); + } + + public void SetPayload(string payload) + => _payloadSource.TrySetResult(Encoding.UTF8.GetBytes(payload)); + + public override int Read(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + _payloadBytes ??= await _payloadSource.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + + if (_position < _payloadBytes.Length) + { + int bytesToCopy = Math.Min(buffer.Length, _payloadBytes.Length - _position); + _payloadBytes.AsMemory(_position, bytesToCopy).CopyTo(buffer); + _position += bytesToCopy; + return bytesToCopy; + } + + await _completionSource.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + 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(); + } + + private sealed class TestConfigurationRoot + { + public TestLuaConfiguration? Lua { get; init; } + } + + private sealed class TestLuaConfiguration + { + public TestLuaRuntimeConfiguration? Runtime { get; init; } + } + + private sealed class TestLuaRuntimeConfiguration + { + public string? Version { get; init; } + } +} diff --git a/Tests/TombLib.LanguageServer.Core.Tests/Completion/CompletionResponseJsonConverterTests.cs b/Tests/TombLib.LanguageServer.Core.Tests/Completion/CompletionResponseJsonConverterTests.cs new file mode 100644 index 0000000000..5136037b01 --- /dev/null +++ b/Tests/TombLib.LanguageServer.Core.Tests/Completion/CompletionResponseJsonConverterTests.cs @@ -0,0 +1,69 @@ +using System.Text.Json; + +namespace TombLib.LanguageServer.Core.Tests; + +[TestClass] +public class CompletionResponseJsonConverterTests +{ + [TestMethod] + public void DeserializeCompletionResponse_AppliesItemDefaultsToItems() + { + CompletionResponse? response = JsonSerializer.Deserialize( + """ + { + "isIncomplete": true, + "itemDefaults": { + "editRange": { + "insert": { + "start": { "line": 2, "character": 1 }, + "end": { "line": 2, "character": 3 } + }, + "replace": { + "start": { "line": 2, "character": 1 }, + "end": { "line": 2, "character": 8 } + } + }, + "insertTextFormat": 2, + "data": { + "origin": "defaults" + } + }, + "items": [ + { + "label": "call", + "textEditText": "call(${1:arg})" + }, + { + "label": "warn" + } + ] + } + """); + + Assert.IsNotNull(response); + Assert.IsTrue(response.IsIncomplete); + Assert.IsNotNull(response.Items); + Assert.AreEqual(2, response.Items.Count); + + CompletionItemPayload firstItem = response.Items[0]; + Assert.AreEqual(2, firstItem.InsertTextFormat); + Assert.IsNotNull(firstItem.TextEdit); + Assert.AreEqual("call(${1:arg})", firstItem.TextEdit.NewText); + Assert.IsNotNull(firstItem.TextEdit.Insert); + Assert.IsNotNull(firstItem.TextEdit.Replace); + Assert.AreEqual(2, firstItem.TextEdit.Insert?.Start?.Line); + Assert.AreEqual(1, firstItem.TextEdit.Insert?.Start?.Character); + Assert.AreEqual(8, firstItem.TextEdit.Replace?.End?.Character); + Assert.IsNotNull(firstItem.ExtensionData); + Assert.IsTrue(firstItem.ExtensionData.TryGetValue("data", out JsonElement firstItemData)); + Assert.AreEqual("defaults", firstItemData.GetProperty("origin").GetString()); + + CompletionItemPayload secondItem = response.Items[1]; + Assert.AreEqual(2, secondItem.InsertTextFormat); + Assert.IsNotNull(secondItem.TextEdit); + Assert.AreEqual("warn", secondItem.TextEdit.NewText); + Assert.IsNotNull(secondItem.ExtensionData); + Assert.IsTrue(secondItem.ExtensionData.TryGetValue("data", out JsonElement secondItemData)); + Assert.AreEqual("defaults", secondItemData.GetProperty("origin").GetString()); + } +} diff --git a/Tests/TombLib.LanguageServer.Core.Tests/Documents/DocumentIncrementalEditCalculatorTests.cs b/Tests/TombLib.LanguageServer.Core.Tests/Documents/DocumentIncrementalEditCalculatorTests.cs new file mode 100644 index 0000000000..83790731dd --- /dev/null +++ b/Tests/TombLib.LanguageServer.Core.Tests/Documents/DocumentIncrementalEditCalculatorTests.cs @@ -0,0 +1,47 @@ +namespace TombLib.LanguageServer.Core.Tests; + +[TestClass] +public class DocumentIncrementalEditCalculatorTests +{ + [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"; + + DocumentChangeRange range = DocumentIncrementalEditCalculator.Compute(oldText, newText, DocumentLineOffsets.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"; + + DocumentChangeRange range = DocumentIncrementalEditCalculator.Compute(oldText, newText, DocumentLineOffsets.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"; + + DocumentChangeRange range = DocumentIncrementalEditCalculator.Compute(text, text, DocumentLineOffsets.Build(text)); + + Assert.AreEqual(string.Empty, range.Text); + Assert.AreEqual(range.StartLine, range.EndLine); + Assert.AreEqual(range.StartCharacter, range.EndCharacter); + } +} diff --git a/Tests/TombLib.LanguageServer.Core.Tests/Documents/DocumentLineOffsetsTests.cs b/Tests/TombLib.LanguageServer.Core.Tests/Documents/DocumentLineOffsetsTests.cs new file mode 100644 index 0000000000..1085c6572c --- /dev/null +++ b/Tests/TombLib.LanguageServer.Core.Tests/Documents/DocumentLineOffsetsTests.cs @@ -0,0 +1,17 @@ +namespace TombLib.LanguageServer.Core.Tests; + +[TestClass] +public class DocumentLineOffsetsTests +{ + [TestMethod] + public void Accessors_ClampOutOfRangeLineIndices() + { + DocumentLineOffsets lineOffsets = DocumentLineOffsets.Build("one\ntwo"); + + Assert.AreEqual(3, lineOffsets.GetLineLength(-1)); + Assert.AreEqual(0, lineOffsets.GetLineStartOffset(-1)); + Assert.AreEqual(3, lineOffsets.GetLineLength(99)); + Assert.AreEqual(4, lineOffsets.GetLineStartOffset(99)); + Assert.AreEqual("two", lineOffsets.GetLineText(99)); + } +} diff --git a/Tests/TombLib.LanguageServer.Core.Tests/Documents/DocumentOperationSchedulerTests.cs b/Tests/TombLib.LanguageServer.Core.Tests/Documents/DocumentOperationSchedulerTests.cs new file mode 100644 index 0000000000..a02a4890dc --- /dev/null +++ b/Tests/TombLib.LanguageServer.Core.Tests/Documents/DocumentOperationSchedulerTests.cs @@ -0,0 +1,342 @@ +namespace TombLib.LanguageServer.Core.Tests; + +[TestClass] +public class DocumentOperationSchedulerTests +{ + [TestMethod] + public async Task EnqueueGlobalAsync_CanceledWhileWaiting_DoesNotInvokeDelegate() + { + var scheduler = new DocumentOperationScheduler(); + var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowFirstToFinish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var cancellationTokenSource = new CancellationTokenSource(); + + int canceledOperationCallCount = 0; + + Task firstTask = scheduler.EnqueueGlobalAsync(async _ => + { + firstStarted.TrySetResult(true); + await allowFirstToFinish.Task.ConfigureAwait(false); + return true; + }, CancellationToken.None); + + await firstStarted.Task.ConfigureAwait(false); + + Task canceledTask = scheduler.EnqueueGlobalAsync(_ => + { + Interlocked.Increment(ref canceledOperationCallCount); + return Task.FromResult(true); + }, cancellationTokenSource.Token); + + cancellationTokenSource.Cancel(); + allowFirstToFinish.TrySetResult(true); + + await firstTask.ConfigureAwait(false); + await Assert.ThrowsExceptionAsync(() => canceledTask).ConfigureAwait(false); + + Assert.AreEqual(0, canceledOperationCallCount); + } + + [TestMethod] + public async Task EnqueuePerDocumentAsync_CanceledWhileWaiting_DoesNotInvokeDelegate() + { + var scheduler = new DocumentOperationScheduler(); + var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowFirstToFinish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var cancellationTokenSource = new CancellationTokenSource(); + + int canceledOperationCallCount = 0; + + Task firstTask = scheduler.EnqueuePerDocumentAsync("test.lua", async _ => + { + firstStarted.TrySetResult(true); + await allowFirstToFinish.Task.ConfigureAwait(false); + return true; + }, CancellationToken.None); + + await firstStarted.Task.ConfigureAwait(false); + + Task canceledTask = scheduler.EnqueuePerDocumentAsync("test.lua", _ => + { + Interlocked.Increment(ref canceledOperationCallCount); + return Task.FromResult(true); + }, cancellationTokenSource.Token); + + cancellationTokenSource.Cancel(); + allowFirstToFinish.TrySetResult(true); + + await firstTask.ConfigureAwait(false); + await Assert.ThrowsExceptionAsync(() => canceledTask).ConfigureAwait(false); + + Assert.AreEqual(0, canceledOperationCallCount); + } + + [TestMethod] + public async Task EnqueuePerDocumentAsync_LaterWorkStillRunsAfterCanceledPredecessor() + { + var scheduler = new DocumentOperationScheduler(); + var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowFirstToFinish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var cancellationTokenSource = new CancellationTokenSource(); + + int canceledOperationCallCount = 0; + int laterOperationCallCount = 0; + + Task firstTask = scheduler.EnqueuePerDocumentAsync("test.lua", async _ => + { + firstStarted.TrySetResult(true); + await allowFirstToFinish.Task.ConfigureAwait(false); + return true; + }, CancellationToken.None); + + await firstStarted.Task.ConfigureAwait(false); + + Task canceledTask = scheduler.EnqueuePerDocumentAsync("test.lua", _ => + { + Interlocked.Increment(ref canceledOperationCallCount); + return Task.FromResult(true); + }, cancellationTokenSource.Token); + + Task laterTask = scheduler.EnqueuePerDocumentAsync("test.lua", _ => + { + Interlocked.Increment(ref laterOperationCallCount); + return Task.FromResult(true); + }, CancellationToken.None); + + cancellationTokenSource.Cancel(); + allowFirstToFinish.TrySetResult(true); + + await firstTask.ConfigureAwait(false); + await Assert.ThrowsExceptionAsync(() => canceledTask).ConfigureAwait(false); + Assert.IsTrue(await laterTask.ConfigureAwait(false)); + + Assert.AreEqual(0, canceledOperationCallCount); + Assert.AreEqual(1, laterOperationCallCount); + } + + [TestMethod] + public async Task EnqueuePerDocumentAsync_NormalizesEquivalentPathsIntoSameQueue() + { + var scheduler = new DocumentOperationScheduler(); + string canonicalFilePath = Path.Combine(Path.GetTempPath(), "DocumentOperationSchedulerTests", "test.lua"); + string aliasedFilePath = Path.Combine(Path.GetDirectoryName(canonicalFilePath)!, ".", Path.GetFileName(canonicalFilePath)); + var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowFirstToFinish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int secondOperationCallCount = 0; + + Task firstTask = scheduler.EnqueuePerDocumentAsync(canonicalFilePath, async _ => + { + firstStarted.TrySetResult(true); + await allowFirstToFinish.Task.ConfigureAwait(false); + return true; + }, CancellationToken.None); + + await firstStarted.Task.ConfigureAwait(false); + + Task secondTask = scheduler.EnqueuePerDocumentAsync(aliasedFilePath, _ => + { + Interlocked.Increment(ref secondOperationCallCount); + return Task.FromResult(true); + }, CancellationToken.None); + + await Task.Delay(50).ConfigureAwait(false); + + Assert.AreEqual(0, secondOperationCallCount); + + allowFirstToFinish.TrySetResult(true); + + Assert.IsTrue(await firstTask.ConfigureAwait(false)); + Assert.IsTrue(await secondTask.ConfigureAwait(false)); + Assert.AreEqual(1, secondOperationCallCount); + } + + [TestMethod] + public async Task QueueLatestUpdateAsync_SerializesRunningWorkAndSkipsSupersededPendingUpdates() + { + var scheduler = new DocumentOperationScheduler(); + var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowFirstToFinish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + int secondStarted = 0; + int thirdStarted = 0; + + Task firstTask = scheduler.QueueLatestUpdateAsync("test.lua", async _ => + { + firstStarted.TrySetResult(true); + await allowFirstToFinish.Task.ConfigureAwait(false); + }); + + await firstStarted.Task.ConfigureAwait(false); + + Task secondTask = scheduler.QueueLatestUpdateAsync("test.lua", _ => + { + Interlocked.Increment(ref secondStarted); + return Task.CompletedTask; + }); + + Task thirdTask = scheduler.QueueLatestUpdateAsync("test.lua", _ => + { + Interlocked.Increment(ref thirdStarted); + return Task.CompletedTask; + }); + + await Task.Delay(50).ConfigureAwait(false); + + Assert.AreEqual(0, secondStarted); + Assert.AreEqual(0, thirdStarted); + + allowFirstToFinish.TrySetResult(true); + + await Task.WhenAll(firstTask, secondTask, thirdTask).ConfigureAwait(false); + + Assert.AreEqual(0, secondStarted); + Assert.AreEqual(1, thirdStarted); + } + + [TestMethod] + public async Task QueueLatestUpdateAsync_SupersedingPendingWork_DoesNotCancelRunningUpdate() + { + var scheduler = new DocumentOperationScheduler(); + var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowFirstToFinish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var secondQueued = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int thirdStarted = 0; + + Task firstTask = scheduler.QueueLatestUpdateAsync("test.lua", async token => + { + firstStarted.TrySetResult(true); + await secondQueued.Task.ConfigureAwait(false); + + Assert.IsFalse(token.IsCancellationRequested, "A newer queued update should not cancel the already-running update."); + Assert.IsNotNull(token.WaitHandle, "The running update should keep owning its token source until it finishes."); + + await allowFirstToFinish.Task.ConfigureAwait(false); + }); + + await firstStarted.Task.ConfigureAwait(false); + + Task secondTask = scheduler.QueueLatestUpdateAsync("test.lua", _ => Task.CompletedTask); + Task thirdTask = scheduler.QueueLatestUpdateAsync("test.lua", _ => + { + Interlocked.Increment(ref thirdStarted); + return Task.CompletedTask; + }); + + secondQueued.TrySetResult(true); + await Task.Delay(50).ConfigureAwait(false); + + Assert.AreEqual(0, thirdStarted); + + allowFirstToFinish.TrySetResult(true); + + await Task.WhenAll(firstTask, secondTask, thirdTask).ConfigureAwait(false); + + Assert.AreEqual(1, thirdStarted); + } + + [TestMethod] + public async Task WaitForPerDocumentOperationsAsync_WaitsForQueuedLatestUpdatesForSamePath() + { + var scheduler = new DocumentOperationScheduler(); + var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowFirstToFinish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int secondStarted = 0; + + Task firstTask = scheduler.QueueLatestUpdateAsync("test.lua", async _ => + { + firstStarted.TrySetResult(true); + await allowFirstToFinish.Task.ConfigureAwait(false); + }); + + await firstStarted.Task.ConfigureAwait(false); + + Task secondTask = scheduler.QueueLatestUpdateAsync("test.lua", _ => + { + Interlocked.Increment(ref secondStarted); + return Task.CompletedTask; + }); + + Task waitTask = scheduler.WaitForPerDocumentOperationsAsync("test.lua", "test.lua"); + + await Task.Delay(50).ConfigureAwait(false); + + Assert.IsFalse(waitTask.IsCompleted, "Waiting for document operations should include queued latest-update work for the same path."); + + allowFirstToFinish.TrySetResult(true); + + await Task.WhenAll(firstTask, secondTask, waitTask).ConfigureAwait(false); + + Assert.AreEqual(1, secondStarted); + } + + [TestMethod] + public async Task EnqueueExclusivePerDocumentAsync_BlocksLaterPerDocumentWorkOnAffectedPath() + { + var scheduler = new DocumentOperationScheduler(); + var exclusiveStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowExclusiveToFinish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int laterOperationCallCount = 0; + + Task exclusiveTask = scheduler.EnqueueExclusivePerDocumentAsync( + "old.lua", + "new.lua", + async _ => + { + exclusiveStarted.TrySetResult(true); + await allowExclusiveToFinish.Task.ConfigureAwait(false); + return true; + }, + CancellationToken.None); + + await exclusiveStarted.Task.ConfigureAwait(false); + + Task laterTask = scheduler.EnqueuePerDocumentAsync("new.lua", _ => + { + Interlocked.Increment(ref laterOperationCallCount); + return Task.FromResult(true); + }, CancellationToken.None); + + await Task.Delay(50).ConfigureAwait(false); + + Assert.AreEqual(0, laterOperationCallCount); + + allowExclusiveToFinish.TrySetResult(true); + + Assert.IsTrue(await exclusiveTask.ConfigureAwait(false)); + Assert.IsTrue(await laterTask.ConfigureAwait(false)); + + Assert.AreEqual(1, laterOperationCallCount); + } + + [TestMethod] + public async Task WaitForPerDocumentOperationsAsync_WaitsForActiveExclusiveBarrier() + { + var scheduler = new DocumentOperationScheduler(); + var exclusiveStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowExclusiveToFinish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + Task exclusiveTask = scheduler.EnqueueExclusivePerDocumentAsync( + "old.lua", + "new.lua", + async _ => + { + exclusiveStarted.TrySetResult(true); + await allowExclusiveToFinish.Task.ConfigureAwait(false); + return true; + }, + CancellationToken.None); + + await exclusiveStarted.Task.ConfigureAwait(false); + + Task waitTask = scheduler.WaitForPerDocumentOperationsAsync("old.lua", "new.lua"); + + await Task.Delay(50).ConfigureAwait(false); + + Assert.IsFalse(waitTask.IsCompleted, "Waiting for document operations should include active exclusive barriers for the affected paths."); + + allowExclusiveToFinish.TrySetResult(true); + + Assert.IsTrue(await exclusiveTask.ConfigureAwait(false)); + await waitTask.ConfigureAwait(false); + } +} diff --git a/Tests/TombLib.LanguageServer.Core.Tests/Documents/DocumentRangeOffsetResolverTests.cs b/Tests/TombLib.LanguageServer.Core.Tests/Documents/DocumentRangeOffsetResolverTests.cs new file mode 100644 index 0000000000..6952a52d12 --- /dev/null +++ b/Tests/TombLib.LanguageServer.Core.Tests/Documents/DocumentRangeOffsetResolverTests.cs @@ -0,0 +1,23 @@ +namespace TombLib.LanguageServer.Core.Tests; + +[TestClass] +public class DocumentRangeOffsetResolverTests +{ + [TestMethod] + public void TryResolveOffsets_ClampsStaleLineIndicesWithoutThrowing() + { + DocumentLineOffsets lineOffsets = DocumentLineOffsets.Build("\nvalue"); + + bool resolved = DocumentRangeOffsetResolver.TryResolveOffsets(lineOffsets, + startLineIndex: 99, + startCharacter: 99, + endLineIndex: 99, + endCharacter: 99, + out int startOffset, + out int endOffset); + + Assert.IsTrue(resolved); + Assert.AreEqual(1, startOffset); + Assert.AreEqual(6, endOffset); + } +} diff --git a/Tests/TombLib.LanguageServer.Core.Tests/Documents/DocumentReferenceTrackerTests.cs b/Tests/TombLib.LanguageServer.Core.Tests/Documents/DocumentReferenceTrackerTests.cs new file mode 100644 index 0000000000..4e99f99ab4 --- /dev/null +++ b/Tests/TombLib.LanguageServer.Core.Tests/Documents/DocumentReferenceTrackerTests.cs @@ -0,0 +1,44 @@ +namespace TombLib.LanguageServer.Core.Tests; + +[TestClass] +public class DocumentReferenceTrackerTests +{ + [TestMethod] + public void AcquireAndRelease_OpenAndRequestReferences_RemainAccurateUnderConcurrency() + { + const int operationCount = 2000; + var tracker = new DocumentReferenceTracker(); + + Parallel.For(0, operationCount, _ => tracker.AcquireOpen()); + Parallel.For(0, operationCount, _ => tracker.AcquireRequest()); + + Assert.AreEqual(operationCount, tracker.OpenReferenceCount); + Assert.AreEqual(operationCount, tracker.RequestReferenceCount); + Assert.IsTrue(tracker.HasOpenReferences); + Assert.IsFalse(tracker.IsIdle); + + Parallel.For(0, operationCount, _ => tracker.ReleaseOpen()); + Parallel.For(0, operationCount, _ => tracker.ReleaseRequest()); + + Assert.AreEqual(0, tracker.OpenReferenceCount); + Assert.AreEqual(0, tracker.RequestReferenceCount); + Assert.IsFalse(tracker.HasOpenReferences); + Assert.IsTrue(tracker.IsIdle); + } + + [TestMethod] + public void ReleaseMethods_DoNotDriveCountsNegativeUnderConcurrency() + { + var tracker = new DocumentReferenceTracker(); + + Parallel.For(0, 2000, _ => + { + tracker.ReleaseOpen(); + tracker.ReleaseRequest(); + }); + + Assert.AreEqual(0, tracker.OpenReferenceCount); + Assert.AreEqual(0, tracker.RequestReferenceCount); + Assert.IsTrue(tracker.IsIdle); + } +} diff --git a/Tests/TombLib.LanguageServer.Core.Tests/Documents/ProtocolRangeHelperTests.cs b/Tests/TombLib.LanguageServer.Core.Tests/Documents/ProtocolRangeHelperTests.cs new file mode 100644 index 0000000000..82a843447a --- /dev/null +++ b/Tests/TombLib.LanguageServer.Core.Tests/Documents/ProtocolRangeHelperTests.cs @@ -0,0 +1,40 @@ +namespace TombLib.LanguageServer.Core.Tests; + +[TestClass] +public class ProtocolRangeHelperTests +{ + [TestMethod] + public void TryGetOneBasedLineAndColumn_ReturnsFalseForNegativeProtocolCoordinates() + { + bool resolved = ProtocolRangeHelper.TryGetOneBasedLineAndColumn( + new ProtocolNullablePosition(Line: -1, Character: 0), + out int lineNumber, + out int columnNumber); + + Assert.IsFalse(resolved); + Assert.AreEqual(1, lineNumber); + Assert.AreEqual(1, columnNumber); + + resolved = ProtocolRangeHelper.TryGetOneBasedLineAndColumn( + new ProtocolNullablePosition(Line: 0, Character: -1), + out lineNumber, + out columnNumber); + + Assert.IsFalse(resolved); + Assert.AreEqual(1, lineNumber); + Assert.AreEqual(1, columnNumber); + } + + [TestMethod] + public void TryGetOneBasedRange_ReturnsFalseWhenRangeContainsNegativeProtocolCoordinate() + { + bool resolved = ProtocolRangeHelper.TryGetOneBasedRange( + new ProtocolRangePayload( + new ProtocolNullablePosition(Line: -1, Character: 0), + new ProtocolNullablePosition(Line: 0, Character: 1)), + out OneBasedDocumentRange? range); + + Assert.IsFalse(resolved); + Assert.IsNull(range); + } +} diff --git a/Tests/TombLib.LanguageServer.Core.Tests/Documents/SemanticTokensDeltaParserTests.cs b/Tests/TombLib.LanguageServer.Core.Tests/Documents/SemanticTokensDeltaParserTests.cs new file mode 100644 index 0000000000..5f55bad0a3 --- /dev/null +++ b/Tests/TombLib.LanguageServer.Core.Tests/Documents/SemanticTokensDeltaParserTests.cs @@ -0,0 +1,30 @@ +using System.Text.Json; + +namespace TombLib.LanguageServer.Core.Tests; + +[TestClass] +public class SemanticTokensDeltaParserTests +{ + [TestMethod] + public void Parse_MissingStartReturnsEmptyDeltaPayload() + { + SemanticTokensWireResponse response = JsonSerializer.Deserialize( + """ + { + "resultId": "delta-1", + "edits": [ + { + "deleteCount": 2, + "data": [1, 2, 3] + } + ] + } + """); + + SemanticTokensDeltaResponse result = SemanticTokensDeltaParser.Parse(response); + + Assert.AreEqual("delta-1", result.ResultId); + Assert.IsNull(result.Data); + Assert.IsNull(result.Edits); + } +} diff --git a/Tests/TombLib.LanguageServer.Core.Tests/Documents/TrackedDocumentStateTests.cs b/Tests/TombLib.LanguageServer.Core.Tests/Documents/TrackedDocumentStateTests.cs new file mode 100644 index 0000000000..3f60141fac --- /dev/null +++ b/Tests/TombLib.LanguageServer.Core.Tests/Documents/TrackedDocumentStateTests.cs @@ -0,0 +1,90 @@ +namespace TombLib.LanguageServer.Core.Tests; + +[TestClass] +public class TrackedDocumentStateTests +{ + [TestMethod] + public void TrackedDocumentState_CreateSnapshot_CapturesCurrentCoreState() + { + var state = new TestTrackedDocumentState( + @"C:\Workspace\Scripts\start.lua", + "file:///C:/Workspace/Scripts/start.lua", + "return 1", + version: 4, + isOpen: true, + openReferenceCount: 1, + requestReferenceCount: 2, + lastAccessStamp: 3); + + DocumentSnapshot initialSnapshot = state.CreateSnapshot(); + state.Rename(@"C:\Workspace\Scripts\renamed.lua", "file:///C:/Workspace/Scripts/renamed.lua"); + string previousContent = state.Update("return 2"); + state.Close(); + + DocumentSnapshot updatedSnapshot = state.CreateSnapshot(); + + Assert.AreEqual(@"C:\Workspace\Scripts\start.lua", initialSnapshot.FilePath); + Assert.AreEqual("file:///C:/Workspace/Scripts/start.lua", initialSnapshot.Uri); + Assert.AreEqual("return 1", initialSnapshot.Content); + Assert.AreEqual(4, initialSnapshot.Version); + Assert.AreEqual("return 1", previousContent); + Assert.AreEqual(@"C:\Workspace\Scripts\renamed.lua", updatedSnapshot.FilePath); + Assert.AreEqual("file:///C:/Workspace/Scripts/renamed.lua", updatedSnapshot.Uri); + Assert.AreEqual("return 2", updatedSnapshot.Content); + Assert.AreEqual(5, updatedSnapshot.Version); + Assert.IsFalse(state.IsOpen); + } + + [TestMethod] + public async Task TrackedDocumentState_CreateSnapshot_DoesNotObserveMismatchedRenamePairsUnderConcurrency() + { + var state = new TestTrackedDocumentState( + @"C:\Workspace\Scripts\a.lua", + "file:///C:/Workspace/Scripts/a.lua", + "return 'a'", + version: 1, + isOpen: true, + openReferenceCount: 0, + requestReferenceCount: 0, + lastAccessStamp: 0); + + var mismatchMessages = new List(); + using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromMilliseconds(150)); + + Task writerTask = Task.Run(() => + { + while (!cancellationTokenSource.IsCancellationRequested) + { + state.Rename(@"C:\Workspace\Scripts\a.lua", "file:///C:/Workspace/Scripts/a.lua"); + state.Update("return 'a'"); + state.Rename(@"C:\Workspace\Scripts\b.lua", "file:///C:/Workspace/Scripts/b.lua"); + state.Update("return 'b'"); + } + }, cancellationTokenSource.Token); + + Task readerTask = Task.Run(() => + { + while (!cancellationTokenSource.IsCancellationRequested) + { + DocumentSnapshot snapshot = state.CreateSnapshot(); + + bool isA = string.Equals(snapshot.FilePath, @"C:\Workspace\Scripts\a.lua", StringComparison.Ordinal) + && string.Equals(snapshot.Uri, "file:///C:/Workspace/Scripts/a.lua", StringComparison.Ordinal); + + bool isB = string.Equals(snapshot.FilePath, @"C:\Workspace\Scripts\b.lua", StringComparison.Ordinal) + && string.Equals(snapshot.Uri, "file:///C:/Workspace/Scripts/b.lua", StringComparison.Ordinal); + + if (!isA && !isB) + { + lock (mismatchMessages) + mismatchMessages.Add(snapshot.FilePath + " | " + snapshot.Uri); + } + } + }, cancellationTokenSource.Token); + + await Task.WhenAll(writerTask, readerTask).ConfigureAwait(false); + + Assert.AreEqual(0, mismatchMessages.Count, + "Snapshots should not observe mixed file-path/URI rename pairs: " + string.Join(", ", mismatchMessages)); + } +} diff --git a/Tests/TombLib.LanguageServer.Core.Tests/Documents/TrackedDocumentStoreTests.cs b/Tests/TombLib.LanguageServer.Core.Tests/Documents/TrackedDocumentStoreTests.cs new file mode 100644 index 0000000000..30c7adf0c3 --- /dev/null +++ b/Tests/TombLib.LanguageServer.Core.Tests/Documents/TrackedDocumentStoreTests.cs @@ -0,0 +1,214 @@ +namespace TombLib.LanguageServer.Core.Tests; + +[TestClass] +public class TrackedDocumentStoreTests +{ + [TestMethod] + public void Rename_ReturnsNullAndPreservesTrackedDocuments_WhenDestinationIsAlreadyTracked() + { + var store = new TestTrackedDocumentStore(); + const string oldFilePath = @"C:\Workspace\Scripts\source.lua"; + const string newFilePath = @"C:\Workspace\Scripts\target.lua"; + + store.Synchronize(oldFilePath, "return 1", acquireOpenReference: true); + store.Synchronize(newFilePath, "return 2", acquireOpenReference: true); + + DocumentRenameRequest? renameRequest = store.Rename(oldFilePath, newFilePath, "return 1"); + + Assert.IsNull(renameRequest); + Assert.IsNotNull(store.GetDocumentSnapshot(oldFilePath)); + + DocumentSnapshot? destinationDocument = store.GetDocumentSnapshot(newFilePath); + + Assert.IsNotNull(destinationDocument); + Assert.AreEqual("return 2", destinationDocument.Content); + Assert.AreEqual(1, destinationDocument.Version); + Assert.IsTrue(store.TryClose(oldFilePath, out _)); + Assert.IsTrue(store.TryClose(newFilePath, out DocumentSnapshot? closedDestinationDocument)); + Assert.IsNotNull(closedDestinationDocument); + Assert.AreEqual("return 2", closedDestinationDocument.Content); + } + + [TestMethod] + public void Rename_PathCaseOnlyDifference_FollowsPlatformPathSensitivity() + { + var store = new TestTrackedDocumentStore(); + string directoryPath = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "TrackedDocumentStoreTests")); + string originalFilePath = LanguageServerPathHelper.NormalizeLocalPath(Path.Combine(directoryPath, "test.lua")); + string renamedFilePath = LanguageServerPathHelper.NormalizeLocalPath(Path.Combine(directoryPath, "TEST.lua")); + + store.Synchronize(originalFilePath, "return 1", acquireOpenReference: true); + + DocumentRenameRequest? renameRequest = store.Rename(originalFilePath, renamedFilePath, "return 1"); + + if (LanguageServerPathHelper.UsesCaseSensitiveLocalPaths) + { + Assert.IsNotNull(renameRequest); + Assert.IsNull(store.GetDocumentSnapshot(originalFilePath)); + Assert.IsNotNull(store.GetDocumentSnapshot(renamedFilePath)); + } + else + { + Assert.IsNull(renameRequest); + Assert.IsNotNull(store.GetDocumentSnapshot(originalFilePath)); + + DocumentSnapshot? aliasedDocument = store.GetDocumentSnapshot(renamedFilePath); + + Assert.IsNotNull(aliasedDocument); + Assert.AreEqual(originalFilePath, aliasedDocument.FilePath); + } + } + + [TestMethod] + public void Synchronize_AndLookup_NormalizeEquivalentPaths() + { + var store = new TestTrackedDocumentStore(); + string canonicalFilePath = Path.Combine(Path.GetTempPath(), "TrackedDocumentStoreTests", "scripts", "test.lua"); + string aliasedFilePath = Path.Combine(Path.GetDirectoryName(canonicalFilePath)!, ".", Path.GetFileName(canonicalFilePath)); + + DocumentSynchronizationRequest? synchronizationRequest = store.Synchronize(canonicalFilePath, "return 1", acquireOpenReference: true); + + Assert.IsNotNull(synchronizationRequest); + Assert.AreEqual(LanguageServerPathHelper.NormalizeLocalPath(canonicalFilePath), synchronizationRequest.Value.Document.FilePath); + + DocumentSnapshot? snapshot = store.GetDocumentSnapshot(aliasedFilePath); + + Assert.IsNotNull(snapshot); + Assert.AreEqual(LanguageServerPathHelper.NormalizeLocalPath(canonicalFilePath), snapshot.FilePath); + Assert.IsTrue(store.TryClose(aliasedFilePath, out DocumentSnapshot? closedDocument)); + Assert.IsNotNull(closedDocument); + Assert.IsNull(store.GetDocumentSnapshot(canonicalFilePath)); + } + + [TestMethod] + public void TryClose_RemovesTrackedDocumentWhileRestartReplayIsPending() + { + var store = new TestTrackedDocumentStore(); + const string filePath = @"C:\Workspace\Scripts\pending.lua"; + + store.Synchronize(filePath, "return 1", acquireOpenReference: true); + IReadOnlyList documentsToReopen = store.PrepareForRestart(); + + Assert.AreEqual(1, documentsToReopen.Count); + Assert.IsTrue(store.TryClose(filePath, out DocumentSnapshot? closingDocument)); + Assert.IsNull(closingDocument); + Assert.IsNull(store.GetDocumentSnapshot(filePath)); + } + + [TestMethod] + public void TryReleaseRequest_RemovesRequestOnlyTrackedDocument() + { + var store = new TestTrackedDocumentStore(); + const string filePath = @"C:\Workspace\Scripts\hover.lua"; + + store.Synchronize(filePath, "return 1", acquireRequestReference: true); + + Assert.IsTrue(store.TryReleaseRequest(filePath, out DocumentSnapshot? closingDocument)); + Assert.IsNotNull(closingDocument); + Assert.AreEqual(filePath, closingDocument.FilePath); + Assert.IsNull(store.GetDocumentSnapshot(filePath)); + } + + [TestMethod] + public void TryReleaseRequest_PreservesEditorOwnedTrackedDocument() + { + var store = new TestTrackedDocumentStore(); + const string filePath = @"C:\Workspace\Scripts\open.lua"; + + store.Synchronize(filePath, "return 1", acquireOpenReference: true); + store.Synchronize(filePath, "return 1", acquireRequestReference: true); + + Assert.IsFalse(store.TryReleaseRequest(filePath, out DocumentSnapshot? closingDocument)); + Assert.IsNull(closingDocument); + Assert.IsNotNull(store.GetDocumentSnapshot(filePath)); + Assert.IsTrue(store.TryClose(filePath, out DocumentSnapshot? closedDocument)); + Assert.IsNotNull(closedDocument); + } + + [TestMethod] + public void TryClose_PreservesRequestOwnedTrackedDocumentUntilRequestRelease() + { + var store = new TestTrackedDocumentStore(); + const string filePath = @"C:\Workspace\Scripts\request-owned.lua"; + + store.Synchronize(filePath, "return 1", acquireOpenReference: true); + store.Synchronize(filePath, "return 1", acquireRequestReference: true); + + Assert.IsFalse(store.TryClose(filePath, out DocumentSnapshot? closingDocument)); + Assert.IsNull(closingDocument); + Assert.IsNotNull(store.GetDocumentSnapshot(filePath)); + Assert.IsTrue(store.TryReleaseRequest(filePath, out DocumentSnapshot? closedDocument)); + Assert.IsNotNull(closedDocument); + Assert.IsNull(store.GetDocumentSnapshot(filePath)); + } + + [TestMethod] + public void Synchronize_ReopensTrackedDocumentAfterRestartPreparation() + { + var store = new TestTrackedDocumentStore(); + const string filePath = @"C:\Workspace\Scripts\reopen.lua"; + + DocumentSynchronizationRequest? initialRequest = store.Synchronize(filePath, "return 1", acquireOpenReference: true); + + Assert.IsNotNull(initialRequest); + Assert.AreEqual(DocumentSynchronizationKind.Open, initialRequest.Value.Kind); + + IReadOnlyList documentsToReopen = store.PrepareForRestart(); + DocumentSynchronizationRequest? reopenRequest = store.Synchronize(filePath, "return 2", acquireOpenReference: true); + + Assert.AreEqual(1, documentsToReopen.Count); + Assert.AreEqual(filePath, documentsToReopen[0].FilePath); + Assert.IsNotNull(reopenRequest); + Assert.AreEqual(DocumentSynchronizationKind.Open, reopenRequest.Value.Kind); + Assert.AreEqual(filePath, reopenRequest.Value.Document.FilePath); + Assert.AreEqual("return 2", reopenRequest.Value.Document.Content); + Assert.AreEqual(2, reopenRequest.Value.Document.Version); + + DocumentSnapshot? reopenedDocument = store.GetDocumentSnapshot(filePath); + + Assert.IsNotNull(reopenedDocument); + Assert.AreEqual("return 2", reopenedDocument.Content); + Assert.AreEqual(2, reopenedDocument.Version); + Assert.IsFalse(store.TryClose(filePath, out _)); + Assert.IsTrue(store.TryClose(filePath, out DocumentSnapshot? finalClosedDocument)); + Assert.IsNotNull(finalClosedDocument); + } + + [TestMethod] + public void TrimRequestOnlyDocuments_RemovesOldestIdleRequestOnlyDocuments() + { + var store = new TestTrackedDocumentStore(); + const string firstFilePath = @"C:\Workspace\Scripts\first.lua"; + const string secondFilePath = @"C:\Workspace\Scripts\second.lua"; + const string thirdFilePath = @"C:\Workspace\Scripts\third.lua"; + + store.Synchronize(firstFilePath, "return 1", acquireRequestReference: true); + store.ReleaseRequest(firstFilePath); + + store.Synchronize(secondFilePath, "return 2", acquireRequestReference: true); + store.ReleaseRequest(secondFilePath); + + store.Synchronize(thirdFilePath, "return 3", acquireRequestReference: true); + store.ReleaseRequest(thirdFilePath); + + IReadOnlyList trimmedDocuments = store.TrimRequestOnlyDocuments(1); + + Assert.AreEqual(2, trimmedDocuments.Count); + + CollectionAssert.AreEquivalent( + new[] { firstFilePath, secondFilePath }, + new[] { trimmedDocuments[0].FilePath, trimmedDocuments[1].FilePath }); + + Assert.IsNull(store.GetDocumentSnapshot(firstFilePath)); + Assert.IsNull(store.GetDocumentSnapshot(secondFilePath)); + Assert.IsNotNull(store.GetDocumentSnapshot(thirdFilePath)); + Assert.AreEqual(0, store.TrimRequestOnlyDocuments(1).Count); + } + + [TestMethod] + public void TrimRequestOnlyDocuments_NegativeMaxCount_ThrowsArgumentOutOfRangeException() + { + var store = new TestTrackedDocumentStore(); + Assert.ThrowsException(() => store.TrimRequestOnlyDocuments(-1)); + } +} diff --git a/Tests/TombLib.LanguageServer.Core.Tests/Infrastructure/Workspace/LanguageServerPathHelperTests.cs b/Tests/TombLib.LanguageServer.Core.Tests/Infrastructure/Workspace/LanguageServerPathHelperTests.cs new file mode 100644 index 0000000000..0aecd55430 --- /dev/null +++ b/Tests/TombLib.LanguageServer.Core.Tests/Infrastructure/Workspace/LanguageServerPathHelperTests.cs @@ -0,0 +1,66 @@ +namespace TombLib.LanguageServer.Core.Tests; + +[TestClass] +public class LanguageServerPathHelperTests +{ + [TestMethod] + public void CreateFileUri_AndTryGetFilePath_RoundTripNormalizedPath() + { + string expectedPath = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "Path Helper", "test file.lua")); + string rawPath = expectedPath.Replace('\\', '/'); + + string uri = LanguageServerPathHelper.CreateFileUri(rawPath); + + Assert.IsTrue(LanguageServerPathHelper.TryGetFilePath(uri, out string filePath)); + Assert.AreEqual(expectedPath, filePath); + } + + [TestMethod] + public void NormalizeLocalPath_TrimsTrailingDirectorySeparatorForNonRootPath() + { + string rawPath = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "Path Helper", "Folder")) + Path.DirectorySeparatorChar; + string normalizedPath = LanguageServerPathHelper.NormalizeLocalPath(rawPath); + + Assert.AreEqual(Path.TrimEndingDirectorySeparator(Path.GetFullPath(rawPath)), normalizedPath); + } + + [TestMethod] + public void AreLocalPathsEqual_FollowsConfiguredPlatformCaseSensitivity() + { + string directoryPath = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "Path Helper")); + string lowerCasePath = LanguageServerPathHelper.NormalizeLocalPath(Path.Combine(directoryPath, "case.lua")); + string upperCasePath = LanguageServerPathHelper.NormalizeLocalPath(Path.Combine(directoryPath, "CASE.lua")); + + Assert.AreEqual( + !LanguageServerPathHelper.UsesCaseSensitiveLocalPaths, + LanguageServerPathHelper.AreLocalPathsEqual(lowerCasePath, upperCasePath)); + } + + [TestMethod] + public void NormalizeLocalPath_Uri_HandlesUncPath() + { + if (!OperatingSystem.IsWindows()) + return; + + Uri uri = new("file://server/share/folder/test.lua"); + string expectedPath = Path.GetFullPath(@"\\server\share\folder\test.lua"); + + string normalizedPath = LanguageServerPathHelper.NormalizeLocalPath(uri); + + Assert.AreEqual(expectedPath, normalizedPath); + } + + [TestMethod] + public void TryGetFilePath_ReturnsFalseForNonFileUri() + { + Assert.IsFalse(LanguageServerPathHelper.TryGetFilePath("https://example.com/test.lua", out string filePath)); + Assert.AreEqual(string.Empty, filePath); + } + + [TestMethod] + public void TryNormalizeLocalPath_ReturnsFalseForBlankInput() + { + Assert.IsFalse(LanguageServerPathHelper.TryNormalizeLocalPath(" ", out string normalizedPath)); + Assert.AreEqual(string.Empty, normalizedPath); + } +} diff --git a/Tests/TombLib.LanguageServer.Core.Tests/Infrastructure/Workspace/WorkspaceChangeAccumulatorTests.cs b/Tests/TombLib.LanguageServer.Core.Tests/Infrastructure/Workspace/WorkspaceChangeAccumulatorTests.cs new file mode 100644 index 0000000000..2b1da2698c --- /dev/null +++ b/Tests/TombLib.LanguageServer.Core.Tests/Infrastructure/Workspace/WorkspaceChangeAccumulatorTests.cs @@ -0,0 +1,162 @@ +namespace TombLib.LanguageServer.Core.Tests; + +[TestClass] +public class WorkspaceChangeAccumulatorTests +{ + [TestMethod] + public void Add_DeleteThenCreateForSamePath_PreservesDeleteThenCreatePair() + { + var accumulator = new WorkspaceChangeAccumulator(); + + accumulator.Add(@"C:\Workspace\Scripts\test.lua", FileChangeKind.Deleted); + accumulator.Add(@"C:\Workspace\Scripts\test.lua", FileChangeKind.Created); + + List drainedChanges = accumulator.DrainChanges(); + + Assert.AreEqual(2, drainedChanges.Count); + Assert.AreEqual(@"C:\Workspace\Scripts\test.lua", drainedChanges[0].Path); + Assert.AreEqual(FileChangeKind.Deleted, drainedChanges[0].Kind); + Assert.AreEqual(@"C:\Workspace\Scripts\test.lua", drainedChanges[1].Path); + Assert.AreEqual(FileChangeKind.Created, drainedChanges[1].Kind); + Assert.IsTrue(accumulator.IsEmpty); + } + + [TestMethod] + public void Add_CreatedThenChangedForSamePath_PreservesCreated() + { + var accumulator = new WorkspaceChangeAccumulator(); + + accumulator.Add(@"C:\Workspace\Scripts\test.lua", FileChangeKind.Created); + accumulator.Add(@"C:\Workspace\Scripts\test.lua", FileChangeKind.Changed); + + List drainedChanges = accumulator.DrainChanges(); + + Assert.AreEqual(1, drainedChanges.Count); + Assert.AreEqual(FileChangeKind.Created, drainedChanges[0].Kind); + } + + [TestMethod] + public void Add_CreatedThenDeletedForSamePath_RemovesBufferedEntry() + { + var accumulator = new WorkspaceChangeAccumulator(); + + accumulator.Add(@"C:\Workspace\Scripts\test.lua", FileChangeKind.Created); + accumulator.Add(@"C:\Workspace\Scripts\test.lua", FileChangeKind.Deleted); + + List drainedChanges = accumulator.DrainChanges(); + + Assert.AreEqual(0, drainedChanges.Count); + Assert.IsTrue(accumulator.IsEmpty); + } + + [TestMethod] + public void Add_CreatedChangedThenDeletedForSamePath_RemovesBufferedEntry() + { + var accumulator = new WorkspaceChangeAccumulator(); + + accumulator.Add(@"C:\Workspace\Scripts\test.lua", FileChangeKind.Created); + accumulator.Add(@"C:\Workspace\Scripts\test.lua", FileChangeKind.Changed); + accumulator.Add(@"C:\Workspace\Scripts\test.lua", FileChangeKind.Deleted); + + List drainedChanges = accumulator.DrainChanges(); + + Assert.AreEqual(0, drainedChanges.Count); + Assert.IsTrue(accumulator.IsEmpty); + } + + [TestMethod] + public void Add_TreatsPathsCaseInsensitively() + { + var accumulator = new WorkspaceChangeAccumulator(); + + accumulator.Add(@"C:\Workspace\Scripts\Test.lua", FileChangeKind.Deleted); + accumulator.Add(@"c:\workspace\scripts\test.lua", FileChangeKind.Created); + + List drainedChanges = accumulator.DrainChanges(); + + Assert.AreEqual(2, drainedChanges.Count); + Assert.AreEqual(FileChangeKind.Deleted, drainedChanges[0].Kind); + Assert.AreEqual(FileChangeKind.Created, drainedChanges[1].Kind); + } + + [TestMethod] + public void DrainBatch_ClearsBufferedEntries() + { + var accumulator = new WorkspaceChangeAccumulator(); + + accumulator.Add(@"C:\Workspace\Scripts\first.lua", FileChangeKind.Changed); + accumulator.Add(@"C:\Workspace\Scripts\second.lua", FileChangeKind.Deleted); + + FileChangeBatch drainedBatch = accumulator.DrainBatch(); + FileChangeBatch drainedAgain = accumulator.DrainBatch(); + + Assert.AreEqual(2, drainedBatch.Count); + Assert.AreEqual(0, drainedAgain.Count); + Assert.IsTrue(accumulator.IsEmpty); + } + + [TestMethod] + public void DrainBatch_EntriesAreExposedAsReadOnlyView() + { + var accumulator = new WorkspaceChangeAccumulator(); + + accumulator.Add(@"C:\Workspace\Scripts\first.lua", FileChangeKind.Changed); + + FileChangeBatch drainedBatch = accumulator.DrainBatch(); + + Assert.ThrowsException(() => ((IList)drainedBatch.Entries)[0] = + new WorkspaceFileChange(@"C:\Workspace\Scripts\second.lua", FileChangeKind.Deleted)); + } + + [TestMethod] + public void DrainChanges_MultiplePaths_PreservesFirstPendingOccurrenceOrder() + { + var accumulator = new WorkspaceChangeAccumulator(); + + accumulator.Add(@"C:\Workspace\Scripts\first.lua", FileChangeKind.Created); + accumulator.Add(@"C:\Workspace\Scripts\second.lua", FileChangeKind.Changed); + accumulator.Add(@"C:\Workspace\Scripts\first.lua", FileChangeKind.Changed); + accumulator.Add(@"C:\Workspace\Scripts\third.lua", FileChangeKind.Deleted); + + List drainedChanges = accumulator.DrainChanges(); + + CollectionAssert.AreEqual( + new[] + { + @"C:\Workspace\Scripts\first.lua", + @"C:\Workspace\Scripts\second.lua", + @"C:\Workspace\Scripts\third.lua" + }, + drainedChanges.Select(change => change.Path).ToArray()); + + CollectionAssert.AreEqual( + new[] + { + FileChangeKind.Created, + FileChangeKind.Changed, + FileChangeKind.Deleted + }, + drainedChanges.Select(change => change.Kind).ToArray()); + } + + [TestMethod] + public void DrainChanges_PathRemovedAndReadded_GetsNewPendingOrder() + { + var accumulator = new WorkspaceChangeAccumulator(); + + accumulator.Add(@"C:\Workspace\Scripts\first.lua", FileChangeKind.Created); + accumulator.Add(@"C:\Workspace\Scripts\first.lua", FileChangeKind.Deleted); + accumulator.Add(@"C:\Workspace\Scripts\second.lua", FileChangeKind.Changed); + accumulator.Add(@"C:\Workspace\Scripts\first.lua", FileChangeKind.Created); + + List drainedChanges = accumulator.DrainChanges(); + + CollectionAssert.AreEqual( + new[] + { + @"C:\Workspace\Scripts\second.lua", + @"C:\Workspace\Scripts\first.lua" + }, + drainedChanges.Select(change => change.Path).ToArray()); + } +} diff --git a/Tests/TombLib.LanguageServer.Core.Tests/Infrastructure/Workspace/WorkspaceFileChangeForwarderTests.cs b/Tests/TombLib.LanguageServer.Core.Tests/Infrastructure/Workspace/WorkspaceFileChangeForwarderTests.cs new file mode 100644 index 0000000000..de809e99ad --- /dev/null +++ b/Tests/TombLib.LanguageServer.Core.Tests/Infrastructure/Workspace/WorkspaceFileChangeForwarderTests.cs @@ -0,0 +1,667 @@ +namespace TombLib.LanguageServer.Core.Tests; + +[TestClass] +public class WorkspaceFileChangeForwarderTests +{ + [TestMethod] + public async Task DispatchAsync_Success_ForwardsImmediatelyWithoutBuffering() + { + bool ensureStartedCalled = false; + int markTransportUnavailableCallCount = 0; + IReadOnlyList? forwardedChanges = null; + + var forwarder = new WorkspaceFileChangeForwarder( + canForwardAccessor: () => true, + isDisposedAccessor: () => false, + ensureStartedAsync: _ => + { + ensureStartedCalled = true; + return Task.FromResult(true); + }, + markTransportUnavailable: () => markTransportUnavailableCallCount++); + + WorkspaceFileChange[] changes = [new(@"C:\Workspace\Scripts\test.lua", FileChangeKind.Changed)]; + + await forwarder.DispatchAsync(changes, + (items, _) => + { + forwardedChanges = [.. items]; + return Task.CompletedTask; + }, + CancellationToken.None).ConfigureAwait(false); + + Assert.IsTrue(ensureStartedCalled); + Assert.IsNotNull(forwardedChanges); + Assert.AreEqual(1, forwardedChanges.Count); + Assert.AreEqual(0, markTransportUnavailableCallCount); + + await forwarder.ReplayDeferredAsync((_, _) => throw new AssertFailedException("No deferred changes should remain."), CancellationToken.None) + .ConfigureAwait(false); + } + + [TestMethod] + public async Task DispatchAsync_WhenForwardingNotCurrentlyAllowed_UsesExplicitDropModeWithoutBuffering() + { + bool ensureStartedCalled = false; + bool forwardCalled = false; + + var forwarder = new WorkspaceFileChangeForwarder( + canForwardAccessor: () => false, + isDisposedAccessor: () => false, + ensureStartedAsync: _ => + { + ensureStartedCalled = true; + return Task.FromResult(true); + }, + markTransportUnavailable: static () => { }, + bufferChangesWhileForwardingDisabled: false); + + WorkspaceFileChange[] changes = [new(@"C:\Workspace\Scripts\test.lua", FileChangeKind.Changed)]; + + await forwarder.DispatchAsync(changes, + (_, _) => + { + forwardCalled = true; + return Task.CompletedTask; + }, + CancellationToken.None).ConfigureAwait(false); + + await forwarder.ReplayDeferredAsync( + (_, _) => throw new AssertFailedException("Ignored changes should not be retained for replay."), + CancellationToken.None).ConfigureAwait(false); + + Assert.IsFalse(ensureStartedCalled); + Assert.IsFalse(forwardCalled); + } + + [TestMethod] + public async Task DispatchAsync_WhenForwardingNotCurrentlyAllowed_BuffersChangesByDefault() + { + bool canForward = false; + IReadOnlyList? replayedChanges = null; + + var forwarder = new WorkspaceFileChangeForwarder( + canForwardAccessor: () => canForward, + isDisposedAccessor: () => false, + ensureStartedAsync: _ => Task.FromResult(true), + markTransportUnavailable: static () => { }); + + WorkspaceFileChange[] changes = [new(@"C:\Workspace\Scripts\test.lua", FileChangeKind.Changed)]; + + await forwarder.DispatchAsync(changes, (_, _) => Task.CompletedTask, CancellationToken.None).ConfigureAwait(false); + canForward = true; + + await forwarder.ReplayDeferredAsync( + (items, _) => + { + replayedChanges = [.. items]; + return Task.CompletedTask; + }, + CancellationToken.None).ConfigureAwait(false); + + Assert.IsNotNull(replayedChanges); + Assert.AreEqual(1, replayedChanges.Count); + Assert.AreEqual(changes[0].Path, replayedChanges[0].Path); + Assert.AreEqual(changes[0].Kind, replayedChanges[0].Kind); + } + + [TestMethod] + public async Task DispatchAsync_WhenStartupFails_BuffersAndReplayDispatchesChanges() + { + bool startResult = false; + IReadOnlyList? replayedChanges = null; + + var forwarder = new WorkspaceFileChangeForwarder( + canForwardAccessor: () => true, + isDisposedAccessor: () => false, + ensureStartedAsync: _ => Task.FromResult(startResult), + markTransportUnavailable: static () => { }); + + WorkspaceFileChange[] changes = [new(@"C:\Workspace\Scripts\test.lua", FileChangeKind.Changed)]; + + await forwarder.DispatchAsync(changes, (_, _) => throw new AssertFailedException("Dispatch should be buffered while startup fails."), CancellationToken.None) + .ConfigureAwait(false); + + startResult = true; + + await forwarder.ReplayDeferredAsync( + (items, _) => + { + replayedChanges = [.. items]; + return Task.CompletedTask; + }, + CancellationToken.None).ConfigureAwait(false); + + Assert.IsNotNull(replayedChanges); + Assert.AreEqual(1, replayedChanges.Count); + Assert.AreEqual(changes[0].Path, replayedChanges[0].Path); + Assert.AreEqual(changes[0].Kind, replayedChanges[0].Kind); + } + + [TestMethod] + public async Task DispatchAsync_WhenForwardingThrowsIOException_BuffersMarksTransportUnavailableAndReplays() + { + int markTransportUnavailableCallCount = 0; + int logForwardingFailureCallCount = 0; + WorkspaceFileForwardingFailure? loggedFailure = null; + IReadOnlyList? replayedChanges = null; + + var forwarder = new WorkspaceFileChangeForwarder( + canForwardAccessor: () => true, + isDisposedAccessor: () => false, + ensureStartedAsync: _ => Task.FromResult(true), + markTransportUnavailable: () => markTransportUnavailableCallCount++, + logForwardingFailure: failure => + { + logForwardingFailureCallCount++; + loggedFailure = failure; + }); + + WorkspaceFileChange[] changes = [new(@"C:\Workspace\Scripts\test.lua", FileChangeKind.Changed)]; + + await forwarder.DispatchAsync(changes, (_, _) => throw new IOException("Simulated forwarding failure."), CancellationToken.None) + .ConfigureAwait(false); + + await forwarder.ReplayDeferredAsync( + (items, _) => + { + replayedChanges = [.. items]; + return Task.CompletedTask; + }, + CancellationToken.None).ConfigureAwait(false); + + Assert.AreEqual(1, markTransportUnavailableCallCount); + Assert.AreEqual(1, logForwardingFailureCallCount); + Assert.IsNotNull(loggedFailure); + Assert.IsInstanceOfType(loggedFailure.Value.Exception, typeof(IOException)); + Assert.AreEqual(1, loggedFailure.Value.BatchCount); + Assert.AreEqual(changes[0].Path, loggedFailure.Value.FirstPath); + Assert.IsFalse(loggedFailure.Value.WasDropped); + Assert.IsNotNull(replayedChanges); + Assert.AreEqual(1, replayedChanges.Count); + } + + [TestMethod] + public async Task DispatchAsync_WhenEnsureStartedReplaysDeferredChanges_CompletesCurrentDispatchWithoutDeadlock() + { + bool startupSucceeds = false; + var forwardedBatches = new List>(); + WorkspaceFileChangeForwarder? forwarder = null; + + forwarder = new WorkspaceFileChangeForwarder( + canForwardAccessor: () => true, + isDisposedAccessor: () => false, + ensureStartedAsync: async cancellationToken => + { + if (!startupSucceeds) + return false; + + await forwarder!.ReplayDeferredAsync( + (items, _) => + { + forwardedBatches.Add([.. items]); + return Task.CompletedTask; + }, + cancellationToken).ConfigureAwait(false); + + return true; + }, + markTransportUnavailable: static () => { }); + + WorkspaceFileChange[] deferredChanges = [new(@"C:\Workspace\Scripts\deferred.lua", FileChangeKind.Changed)]; + WorkspaceFileChange[] currentChanges = [new(@"C:\Workspace\Scripts\current.lua", FileChangeKind.Changed)]; + + await forwarder.DispatchAsync( + deferredChanges, + (_, _) => throw new AssertFailedException("Deferred changes should be buffered while startup fails."), + CancellationToken.None).ConfigureAwait(false); + + startupSucceeds = true; + + await forwarder.DispatchAsync( + currentChanges, + (items, _) => + { + forwardedBatches.Add([.. items]); + return Task.CompletedTask; + }, + CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(1)).ConfigureAwait(false); + + Assert.AreEqual(2, forwardedBatches.Count); + Assert.AreEqual(deferredChanges[0].Path, forwardedBatches[0][0].Path); + Assert.AreEqual(deferredChanges[0].Kind, forwardedBatches[0][0].Kind); + Assert.AreEqual(currentChanges[0].Path, forwardedBatches[1][0].Path); + Assert.AreEqual(currentChanges[0].Kind, forwardedBatches[1][0].Kind); + } + + [TestMethod] + public async Task DispatchAsync_WhenForwardingThrowsObjectDisposedExceptionWhileOwnerAlive_BuffersMarksTransportUnavailableAndReplays() + { + int markTransportUnavailableCallCount = 0; + IReadOnlyList? replayedChanges = null; + + var forwarder = new WorkspaceFileChangeForwarder( + canForwardAccessor: () => true, + isDisposedAccessor: () => false, + ensureStartedAsync: _ => Task.FromResult(true), + markTransportUnavailable: () => markTransportUnavailableCallCount++); + + WorkspaceFileChange[] changes = [new(@"C:\Workspace\Scripts\test.lua", FileChangeKind.Changed)]; + + await forwarder.DispatchAsync(changes, + (_, _) => throw new ObjectDisposedException("transport"), + CancellationToken.None).ConfigureAwait(false); + + await forwarder.ReplayDeferredAsync( + (items, _) => + { + replayedChanges = [.. items]; + return Task.CompletedTask; + }, + CancellationToken.None).ConfigureAwait(false); + + Assert.AreEqual(1, markTransportUnavailableCallCount); + Assert.IsNotNull(replayedChanges); + Assert.AreEqual(1, replayedChanges.Count); + } + + [TestMethod] + public async Task DispatchAsync_WhenForwardingThrowsUnexpectedException_LogsAndIntentionallyDropsChangesWithoutReplay() + { + int markTransportUnavailableCallCount = 0; + int logForwardingFailureCallCount = 0; + WorkspaceFileForwardingFailure? loggedFailure = null; + + var forwarder = new WorkspaceFileChangeForwarder( + canForwardAccessor: () => true, + isDisposedAccessor: () => false, + ensureStartedAsync: _ => Task.FromResult(true), + markTransportUnavailable: () => markTransportUnavailableCallCount++, + logForwardingFailure: failure => + { + logForwardingFailureCallCount++; + loggedFailure = failure; + }); + + WorkspaceFileChange[] changes = [new(@"C:\Workspace\Scripts\test.lua", FileChangeKind.Changed)]; + + await forwarder.DispatchAsync(changes, + (_, _) => throw new InvalidOperationException("Simulated unexpected forwarding failure."), + CancellationToken.None).ConfigureAwait(false); + + await forwarder.ReplayDeferredAsync( + (_, _) => throw new AssertFailedException("Unexpected forwarding failures should not be replayed."), + CancellationToken.None).ConfigureAwait(false); + + Assert.AreEqual(0, markTransportUnavailableCallCount); + Assert.AreEqual(1, logForwardingFailureCallCount); + Assert.IsNotNull(loggedFailure); + Assert.IsInstanceOfType(loggedFailure.Value.Exception, typeof(InvalidOperationException)); + Assert.AreEqual(1, loggedFailure.Value.BatchCount); + Assert.AreEqual(changes[0].Path, loggedFailure.Value.FirstPath); + Assert.IsTrue(loggedFailure.Value.WasDropped); + } + + [TestMethod] + public async Task DispatchAsync_WhenOwnerAlreadyDisposed_DoesNotBufferObjectDisposedFailure() + { + int markTransportUnavailableCallCount = 0; + + var forwarder = new WorkspaceFileChangeForwarder( + canForwardAccessor: () => true, + isDisposedAccessor: () => true, + ensureStartedAsync: _ => Task.FromResult(true), + markTransportUnavailable: () => markTransportUnavailableCallCount++); + + WorkspaceFileChange[] changes = [new(@"C:\Workspace\Scripts\test.lua", FileChangeKind.Changed)]; + + await forwarder.DispatchAsync(changes, + (_, _) => throw new ObjectDisposedException("transport"), + CancellationToken.None).ConfigureAwait(false); + + await forwarder.ReplayDeferredAsync( + (_, _) => throw new AssertFailedException("Disposed owners should not retain deferred changes."), + CancellationToken.None).ConfigureAwait(false); + + Assert.AreEqual(0, markTransportUnavailableCallCount); + } + + [TestMethod] + public async Task ReplayDeferredAsync_WhenCallerCancels_RetainsDeferredChangesForLaterReplay() + { + bool startResult = false; + int markTransportUnavailableCallCount = 0; + IReadOnlyList? replayedChanges = null; + + var forwarder = new WorkspaceFileChangeForwarder( + canForwardAccessor: () => true, + isDisposedAccessor: () => false, + ensureStartedAsync: _ => Task.FromResult(startResult), + markTransportUnavailable: () => markTransportUnavailableCallCount++); + + WorkspaceFileChange[] changes = [new(@"C:\Workspace\Scripts\test.lua", FileChangeKind.Changed)]; + + await forwarder.DispatchAsync(changes, (_, _) => Task.CompletedTask, CancellationToken.None).ConfigureAwait(false); + + startResult = true; + + using (var cancellationTokenSource = new CancellationTokenSource()) + { + cancellationTokenSource.Cancel(); + + await forwarder.ReplayDeferredAsync( + (_, token) => Task.FromCanceled(token), + cancellationTokenSource.Token).ConfigureAwait(false); + } + + await forwarder.ReplayDeferredAsync( + (items, _) => + { + replayedChanges = [.. items]; + return Task.CompletedTask; + }, + CancellationToken.None).ConfigureAwait(false); + + Assert.AreEqual(0, markTransportUnavailableCallCount); + Assert.IsNotNull(replayedChanges); + Assert.AreEqual(1, replayedChanges.Count); + } + + [TestMethod] + public async Task ReplayDeferredAsync_WhenForwardingNotCurrentlyAllowed_RetainsDeferredChangesForLaterReplay() + { + bool canForward = true; + bool startResult = false; + IReadOnlyList? replayedChanges = null; + + var forwarder = new WorkspaceFileChangeForwarder( + canForwardAccessor: () => canForward, + isDisposedAccessor: () => false, + ensureStartedAsync: _ => Task.FromResult(startResult), + markTransportUnavailable: static () => { }); + + WorkspaceFileChange[] changes = [new(@"C:\Workspace\Scripts\test.lua", FileChangeKind.Changed)]; + + await forwarder.DispatchAsync(changes, (_, _) => Task.CompletedTask, CancellationToken.None).ConfigureAwait(false); + + canForward = false; + startResult = true; + + await forwarder.ReplayDeferredAsync( + (_, _) => throw new AssertFailedException("Deferred changes should remain buffered while forwarding is not allowed."), + CancellationToken.None).ConfigureAwait(false); + + canForward = true; + + await forwarder.ReplayDeferredAsync( + (items, _) => + { + replayedChanges = [.. items]; + return Task.CompletedTask; + }, + CancellationToken.None).ConfigureAwait(false); + + Assert.IsNotNull(replayedChanges); + Assert.AreEqual(1, replayedChanges.Count); + Assert.AreEqual(changes[0].Path, replayedChanges[0].Path); + Assert.AreEqual(changes[0].Kind, replayedChanges[0].Kind); + } + + [TestMethod] + public async Task ReplayDeferredAsync_WhenReplayIsInFlight_DoesNotLetNewDispatchPassIt() + { + bool startResult = false; + var forwardedBatches = new List>(); + var replayEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowReplayToFinish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var dispatchObserved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var forwarder = new WorkspaceFileChangeForwarder( + canForwardAccessor: () => true, + isDisposedAccessor: () => false, + ensureStartedAsync: _ => Task.FromResult(startResult), + markTransportUnavailable: static () => { }); + + WorkspaceFileChange[] deferredChanges = [new(@"C:\Workspace\Scripts\deferred.lua", FileChangeKind.Changed)]; + WorkspaceFileChange[] liveChanges = [new(@"C:\Workspace\Scripts\live.lua", FileChangeKind.Created)]; + + await forwarder.DispatchAsync( + deferredChanges, + (_, _) => throw new AssertFailedException("Deferred changes should be buffered while startup is unavailable."), + CancellationToken.None).ConfigureAwait(false); + + startResult = true; + + Task replayTask = forwarder.ReplayDeferredAsync( + async (changes, _) => + { + forwardedBatches.Add([.. changes]); + replayEntered.TrySetResult(true); + await allowReplayToFinish.Task.ConfigureAwait(false); + }, + CancellationToken.None); + + await replayEntered.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + Task dispatchTask = forwarder.DispatchAsync( + liveChanges, + (items, _) => + { + forwardedBatches.Add([.. items]); + dispatchObserved.TrySetResult(true); + return Task.CompletedTask; + }, + CancellationToken.None); + + Task completedTask = await Task.WhenAny(dispatchObserved.Task, Task.Delay(TimeSpan.FromMilliseconds(150))).ConfigureAwait(false); + + Assert.AreNotSame(dispatchObserved.Task, completedTask, + "A live dispatch should not overtake an older deferred replay while the replay is still in flight."); + + allowReplayToFinish.TrySetResult(true); + + await replayTask.ConfigureAwait(false); + await dispatchTask.ConfigureAwait(false); + + Assert.AreEqual(2, forwardedBatches.Count); + Assert.AreEqual(deferredChanges[0].Path, forwardedBatches[0][0].Path); + Assert.AreEqual(liveChanges[0].Path, forwardedBatches[1][0].Path); + } + + [TestMethod] + public async Task ReplayDeferredAsync_ReplaysBufferedPathsInFirstPendingOccurrenceOrder() + { + bool startResult = false; + IReadOnlyList? replayedChanges = null; + + var forwarder = new WorkspaceFileChangeForwarder( + canForwardAccessor: () => true, + isDisposedAccessor: () => false, + ensureStartedAsync: _ => Task.FromResult(startResult), + markTransportUnavailable: static () => { }); + + await forwarder.DispatchAsync( + [ + new WorkspaceFileChange(@"C:\Workspace\Scripts\first.lua", FileChangeKind.Created), + new WorkspaceFileChange(@"C:\Workspace\Scripts\second.lua", FileChangeKind.Changed) + ], + (_, _) => throw new AssertFailedException("Deferred changes should be buffered while startup is unavailable."), + CancellationToken.None).ConfigureAwait(false); + + await forwarder.DispatchAsync( + [new WorkspaceFileChange(@"C:\Workspace\Scripts\first.lua", FileChangeKind.Changed)], + (_, _) => throw new AssertFailedException("Deferred changes should still be buffered while startup is unavailable."), + CancellationToken.None).ConfigureAwait(false); + + startResult = true; + + await forwarder.ReplayDeferredAsync( + (items, _) => + { + replayedChanges = [.. items]; + return Task.CompletedTask; + }, + CancellationToken.None).ConfigureAwait(false); + + Assert.IsNotNull(replayedChanges); + + CollectionAssert.AreEqual( + new[] + { + @"C:\Workspace\Scripts\first.lua", + @"C:\Workspace\Scripts\second.lua" + }, + replayedChanges.Select(change => change.Path).ToArray()); + + CollectionAssert.AreEqual( + new[] + { + FileChangeKind.Created, + FileChangeKind.Changed + }, + replayedChanges.Select(change => change.Kind).ToArray()); + } + + [TestMethod] + public async Task Dispose_AfterDisposal_IgnoresDispatchAndReplay() + { + bool ensureStartedCalled = false; + bool forwardCalled = false; + + var forwarder = new WorkspaceFileChangeForwarder( + canForwardAccessor: () => true, + isDisposedAccessor: () => false, + ensureStartedAsync: _ => + { + ensureStartedCalled = true; + return Task.FromResult(true); + }, + markTransportUnavailable: static () => { }); + + forwarder.Dispose(); + + WorkspaceFileChange[] changes = [new(@"C:\Workspace\Scripts\test.lua", FileChangeKind.Changed)]; + + await forwarder.DispatchAsync( + changes, + (_, _) => + { + forwardCalled = true; + return Task.CompletedTask; + }, + CancellationToken.None).ConfigureAwait(false); + + await forwarder.ReplayDeferredAsync( + (_, _) => + { + forwardCalled = true; + return Task.CompletedTask; + }, + CancellationToken.None).ConfigureAwait(false); + + Assert.IsFalse(ensureStartedCalled); + Assert.IsFalse(forwardCalled); + } + + [TestMethod] + public async Task Dispose_WhileReplayIsInFlight_AllowsReplayToFinishAndBlocksNewDispatch() + { + bool startResult = false; + var forwardedBatches = new List>(); + var replayEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowReplayToFinish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var forwarder = new WorkspaceFileChangeForwarder( + canForwardAccessor: () => true, + isDisposedAccessor: () => false, + ensureStartedAsync: _ => Task.FromResult(startResult), + markTransportUnavailable: static () => { }); + + WorkspaceFileChange[] deferredChanges = [new(@"C:\Workspace\Scripts\deferred.lua", FileChangeKind.Changed)]; + WorkspaceFileChange[] liveChanges = [new(@"C:\Workspace\Scripts\live.lua", FileChangeKind.Created)]; + + await forwarder.DispatchAsync( + deferredChanges, + (_, _) => throw new AssertFailedException("Deferred changes should be buffered while startup is unavailable."), + CancellationToken.None).ConfigureAwait(false); + + startResult = true; + + Task replayTask = forwarder.ReplayDeferredAsync( + async (changes, _) => + { + forwardedBatches.Add([.. changes]); + replayEntered.TrySetResult(true); + await allowReplayToFinish.Task.ConfigureAwait(false); + }, + CancellationToken.None); + + await replayEntered.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + forwarder.Dispose(); + + await forwarder.DispatchAsync( + liveChanges, + (items, _) => + { + forwardedBatches.Add([.. items]); + return Task.CompletedTask; + }, + CancellationToken.None).ConfigureAwait(false); + + allowReplayToFinish.TrySetResult(true); + + await replayTask.ConfigureAwait(false); + + Assert.AreEqual(1, forwardedBatches.Count); + Assert.AreEqual(deferredChanges[0].Path, forwardedBatches[0][0].Path); + } + + [TestMethod] + public async Task Dispose_WhileDispatchWaitsForGate_DoesNotStartForwardingAfterGateOpens() + { + bool ensureStartedCalled = false; + bool forwardCalled = false; + var firstForwardEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowFirstForwardToFinish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var forwarder = new WorkspaceFileChangeForwarder( + canForwardAccessor: () => true, + isDisposedAccessor: () => false, + ensureStartedAsync: _ => + { + ensureStartedCalled = true; + return Task.FromResult(true); + }, + markTransportUnavailable: static () => { }); + + Task firstDispatchTask = forwarder.DispatchAsync( + [new WorkspaceFileChange(@"C:\Workspace\Scripts\first.lua", FileChangeKind.Changed)], + async (_, _) => + { + firstForwardEntered.TrySetResult(true); + await allowFirstForwardToFinish.Task.ConfigureAwait(false); + }, + CancellationToken.None); + + await firstForwardEntered.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + Task blockedDispatchTask = forwarder.DispatchAsync( + [new WorkspaceFileChange(@"C:\Workspace\Scripts\blocked.lua", FileChangeKind.Created)], + (_, _) => + { + forwardCalled = true; + return Task.CompletedTask; + }, + CancellationToken.None); + + await Task.Delay(100).ConfigureAwait(false); + forwarder.Dispose(); + allowFirstForwardToFinish.TrySetResult(true); + + await firstDispatchTask.ConfigureAwait(false); + await blockedDispatchTask.ConfigureAwait(false); + + Assert.IsTrue(ensureStartedCalled); + Assert.IsFalse(forwardCalled); + } +} diff --git a/Tests/TombLib.LanguageServer.Core.Tests/Infrastructure/Workspace/WorkspaceFileWatcherTests.cs b/Tests/TombLib.LanguageServer.Core.Tests/Infrastructure/Workspace/WorkspaceFileWatcherTests.cs new file mode 100644 index 0000000000..85aa9999e3 --- /dev/null +++ b/Tests/TombLib.LanguageServer.Core.Tests/Infrastructure/Workspace/WorkspaceFileWatcherTests.cs @@ -0,0 +1,796 @@ +using NLog; + +namespace TombLib.LanguageServer.Core.Tests; + +[TestClass] +public class WorkspaceFileWatcherTests +{ + [TestMethod] + public async Task DispatchPendingChangesForTestAsync_PreservesDeleteThenCreatePairForSamePath() + { + using var workspace = new TemporaryWorkspaceRoot("LuaWatcherCoalesce_"); + string workspaceRoot = workspace.DirectoryPath; + WorkspaceWatchSpecification[] watchSpecifications = [new("*.lua", IncludeSubdirectories: true)]; + FileChangeBatch? dispatchedBatch = null; + + await using var watcher = new WorkspaceFileWatcher(workspaceRoot, (batch, _) => + { + dispatchedBatch = batch; + return Task.CompletedTask; + }, watchSpecifications); + + string filePath = Path.Combine(workspaceRoot, "test.lua"); + + QueueChangeForTest(watcher, filePath, FileChangeKind.Deleted); + QueueChangeForTest(watcher, filePath, FileChangeKind.Created); + await DispatchPendingChangesForTestAsync(watcher).ConfigureAwait(false); + + Assert.IsNotNull(dispatchedBatch); + Assert.AreEqual(2, dispatchedBatch.Count); + Assert.AreEqual(filePath, dispatchedBatch.Entries[0].Path); + Assert.AreEqual(FileChangeKind.Deleted, dispatchedBatch.Entries[0].Kind); + Assert.AreEqual(filePath, dispatchedBatch.Entries[1].Path); + Assert.AreEqual(FileChangeKind.Created, dispatchedBatch.Entries[1].Kind); + } + + [TestMethod] + public async Task DispatchPendingChangesForTestAsync_NormalizesEquivalentPathFormsBeforeCoalescing() + { + using var workspace = new TemporaryWorkspaceRoot("LuaWatcherNormalize_"); + string workspaceRoot = workspace.DirectoryPath; + WorkspaceWatchSpecification[] watchSpecifications = [new("*.lua", IncludeSubdirectories: true)]; + FileChangeBatch? dispatchedBatch = null; + + Directory.CreateDirectory(Path.Combine(workspaceRoot, "Scripts")); + + await using var watcher = new WorkspaceFileWatcher(workspaceRoot, (batch, _) => + { + dispatchedBatch = batch; + return Task.CompletedTask; + }, watchSpecifications); + + string normalizedPath = Path.Combine(workspaceRoot, "Scripts", "test.lua"); + string alternatePath = normalizedPath.Replace(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + QueueChangeForTest(watcher, normalizedPath, FileChangeKind.Changed); + QueueChangeForTest(watcher, alternatePath, FileChangeKind.Changed); + await DispatchPendingChangesForTestAsync(watcher).ConfigureAwait(false); + + Assert.IsNotNull(dispatchedBatch); + Assert.AreEqual(1, dispatchedBatch.Count); + Assert.AreEqual(LanguageServerPathHelper.NormalizeLocalPath(normalizedPath), dispatchedBatch.Entries[0].Path); + Assert.AreEqual(FileChangeKind.Changed, dispatchedBatch.Entries[0].Kind); + } + + [TestMethod] + public async Task DispatchPendingChangesForTestAsync_WhenDeleteCreateRetryIsNeeded_PreservesBothEntries() + { + using var workspace = new TemporaryWorkspaceRoot("LuaWatcherRetryPair_"); + string workspaceRoot = workspace.DirectoryPath; + WorkspaceWatchSpecification[] watchSpecifications = [new("*.lua", IncludeSubdirectories: true)]; + FileChangeBatch? dispatchedBatch = null; + int dispatchAttemptCount = 0; + + await using var watcher = new WorkspaceFileWatcher(workspaceRoot, (batch, _) => + { + dispatchAttemptCount++; + + if (dispatchAttemptCount == 1) + throw new IOException("Simulated dispatch failure."); + + dispatchedBatch = batch; + return Task.CompletedTask; + }, watchSpecifications); + + string filePath = Path.Combine(workspaceRoot, "test.lua"); + + QueueChangeForTest(watcher, filePath, FileChangeKind.Deleted); + QueueChangeForTest(watcher, filePath, FileChangeKind.Created); + await DispatchPendingChangesForTestAsync(watcher).ConfigureAwait(false); + await DispatchPendingChangesForTestAsync(watcher).ConfigureAwait(false); + + Assert.AreEqual(2, dispatchAttemptCount); + Assert.IsNotNull(dispatchedBatch); + Assert.AreEqual(2, dispatchedBatch.Count); + Assert.AreEqual(FileChangeKind.Deleted, dispatchedBatch.Entries[0].Kind); + Assert.AreEqual(FileChangeKind.Created, dispatchedBatch.Entries[1].Kind); + } + + [TestMethod] + public void Start_MissingWorkspaceRoot_ReturnsWorkspaceRootMissingWithoutException() + { + string workspaceRoot = Path.Combine(Path.GetTempPath(), "WorkspaceWatcherMissing_" + Guid.NewGuid().ToString("N")); + + using var watcher = new WorkspaceFileWatcher( + workspaceRoot, + (_, _) => Task.CompletedTask, + [new WorkspaceWatchSpecification("*.lua", IncludeSubdirectories: true)]); + + WorkspaceWatcherStartStatus startStatus = watcher.Start(out Exception? startupException); + + Assert.AreEqual(WorkspaceWatcherStartStatus.WorkspaceRootMissing, startStatus); + Assert.IsNull(startupException); + Assert.IsFalse(watcher.HasActiveWatchers); + } + + [TestMethod] + public void Start_FileSystemWatcherFactoryThrows_ReturnsStartupFailedAndException() + { + using var workspace = new TemporaryWorkspaceRoot("WorkspaceWatcherFactoryThrow_"); + string workspaceRoot = workspace.DirectoryPath; + + using var watcher = new WorkspaceFileWatcher( + workspaceRoot, + (_, _) => Task.CompletedTask, + [new WorkspaceWatchSpecification("*.lua", IncludeSubdirectories: false)], + fileSystemWatcherFactory: static (_, _) => throw new InvalidOperationException("Simulated watcher creation failure.")); + + WorkspaceWatcherStartStatus startStatus = watcher.Start(out Exception? startupException); + + Assert.AreEqual(WorkspaceWatcherStartStatus.StartupFailed, startStatus); + Assert.IsNotNull(startupException); + Assert.IsTrue(watcher.IsDisposed); + } + + [TestMethod] + public void Start_AfterStartupFailureOnSameInstance_ReturnsDisposed() + { + using var workspace = new TemporaryWorkspaceRoot("WorkspaceWatcherRetryAfterFailure_"); + string workspaceRoot = workspace.DirectoryPath; + + using var watcher = new WorkspaceFileWatcher( + workspaceRoot, + (_, _) => Task.CompletedTask, + [new WorkspaceWatchSpecification("*.lua", IncludeSubdirectories: false)], + fileSystemWatcherFactory: static (_, _) => throw new InvalidOperationException("Simulated watcher creation failure.")); + + WorkspaceWatcherStartStatus firstStartStatus = watcher.Start(out Exception? startupException); + WorkspaceWatcherStartStatus retryStatus = watcher.Start(out Exception? retryException); + + Assert.AreEqual(WorkspaceWatcherStartStatus.StartupFailed, firstStartStatus); + Assert.IsNotNull(startupException); + Assert.AreEqual(WorkspaceWatcherStartStatus.Disposed, retryStatus); + Assert.IsNull(retryException); + Assert.IsTrue(watcher.IsDisposed); + } + + [TestMethod] + public async Task DispatchPendingChangesForTestAsync_WhenDispatchFails_RetainsBatchForRetry() + { + using var workspace = new TemporaryWorkspaceRoot("LuaWatcherRetry_"); + string workspaceRoot = workspace.DirectoryPath; + WorkspaceWatchSpecification[] watchSpecifications = [new("*.lua", IncludeSubdirectories: true)]; + FileChangeBatch? dispatchedBatch = null; + int dispatchAttemptCount = 0; + + using var logScope = new NLogMemoryScope(LogLevel.Debug); + + await using var watcher = new WorkspaceFileWatcher(workspaceRoot, (batch, _) => + { + dispatchAttemptCount++; + + if (dispatchAttemptCount == 1) + throw new IOException("Simulated dispatch failure."); + + dispatchedBatch = batch; + return Task.CompletedTask; + }, watchSpecifications); + + string filePath = Path.Combine(workspaceRoot, "test.lua"); + + QueueChangeForTest(watcher, filePath, FileChangeKind.Changed); + await DispatchPendingChangesForTestAsync(watcher).ConfigureAwait(false); + await DispatchPendingChangesForTestAsync(watcher).ConfigureAwait(false); + + Assert.AreEqual(2, dispatchAttemptCount); + Assert.IsNotNull(dispatchedBatch); + Assert.AreEqual(1, dispatchedBatch.Count); + Assert.AreEqual(filePath, dispatchedBatch.Entries[0].Path); + Assert.AreEqual(FileChangeKind.Changed, dispatchedBatch.Entries[0].Kind); + + Assert.IsTrue(logScope.Logs.Any(log => log.Contains("Workspace file watcher dispatch failed", StringComparison.OrdinalIgnoreCase) + && log.Contains("Simulated dispatch failure.", StringComparison.Ordinal) + && log.Contains(workspaceRoot, StringComparison.OrdinalIgnoreCase) + && log.Contains("1 queued change", StringComparison.OrdinalIgnoreCase)), + string.Join(Environment.NewLine, logScope.Logs)); + } + + [TestMethod] + public async Task DispatchPendingChangesForTestAsync_WhenDispatchKeepsFailing_EscalatesLogLevelAndBackoff() + { + using var workspace = new TemporaryWorkspaceRoot("LuaWatcherBackoff_"); + string workspaceRoot = workspace.DirectoryPath; + WorkspaceWatchSpecification[] watchSpecifications = [new("*.lua", IncludeSubdirectories: true)]; + + using var logScope = new NLogMemoryScope(LogLevel.Debug); + + await using var watcher = new WorkspaceFileWatcher(workspaceRoot, (_, _) => throw new IOException("Persistent dispatch failure."), watchSpecifications); + + string filePath = Path.Combine(workspaceRoot, "test.lua"); + + QueueChangeForTest(watcher, filePath, FileChangeKind.Changed); + await DispatchPendingChangesForTestAsync(watcher).ConfigureAwait(false); + await DispatchPendingChangesForTestAsync(watcher).ConfigureAwait(false); + await DispatchPendingChangesForTestAsync(watcher).ConfigureAwait(false); + + Assert.IsTrue(logScope.Logs.Any(log => log.StartsWith("Debug|", StringComparison.Ordinal) + && log.Contains("retrying in 250 ms", StringComparison.OrdinalIgnoreCase)), + string.Join(Environment.NewLine, logScope.Logs)); + + Assert.IsTrue(logScope.Logs.Any(log => log.StartsWith("Debug|", StringComparison.Ordinal) + && log.Contains("retrying in 500 ms", StringComparison.OrdinalIgnoreCase)), + string.Join(Environment.NewLine, logScope.Logs)); + + Assert.IsTrue(logScope.Logs.Any(log => log.StartsWith("Warn|", StringComparison.Ordinal) + && log.Contains("3 times in a row", StringComparison.OrdinalIgnoreCase) + && log.Contains("retrying in 1000 ms", StringComparison.OrdinalIgnoreCase) + && log.Contains("backoff", StringComparison.OrdinalIgnoreCase)), + string.Join(Environment.NewLine, logScope.Logs)); + } + + [TestMethod] + public async Task DispatchPendingChangesForTestAsync_WhenDispatchKeepsFailing_ReportsWatcherFailureAfterBoundedRetries() + { + using var workspace = new TemporaryWorkspaceRoot("LuaWatcherEscalate_"); + string workspaceRoot = workspace.DirectoryPath; + WorkspaceWatchSpecification[] watchSpecifications = [new("*.lua", IncludeSubdirectories: true)]; + int watcherFailedCallCount = 0; + Exception? reportedException = null; + + await using var watcher = new WorkspaceFileWatcher( + workspaceRoot, + (_, _) => throw new IOException("Persistent dispatch failure."), + watchSpecifications, + (_, exception) => + { + watcherFailedCallCount++; + reportedException = exception; + }); + + Assert.IsTrue(watcher.Start()); + + string filePath = Path.Combine(workspaceRoot, "test.lua"); + + QueueChangeForTest(watcher, filePath, FileChangeKind.Changed); + + for (int i = 0; i < 5; i++) + await DispatchPendingChangesForTestAsync(watcher).ConfigureAwait(false); + + Assert.AreEqual(1, watcherFailedCallCount); + Assert.IsInstanceOfType(reportedException, typeof(IOException)); + Assert.IsFalse(watcher.HasActiveWatchers); + } + + [TestMethod] + public async Task DispatchPendingChangesForTestAsync_WhenDispatchEscalates_PreservesFinalBatchForRecoveryDispatch() + { + using var workspace = new TemporaryWorkspaceRoot("LuaWatcherRecoveryBatch_"); + string workspaceRoot = workspace.DirectoryPath; + WorkspaceWatchSpecification[] watchSpecifications = [new("*.lua", IncludeSubdirectories: true)]; + var recoveredBatch = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int watcherFailedCallCount = 0; + int dispatchAttemptCount = 0; + int allowRecoveryDispatch = 0; + + await using var watcher = new WorkspaceFileWatcher( + workspaceRoot, + (batch, _) => + { + Interlocked.Increment(ref dispatchAttemptCount); + + if (Volatile.Read(ref allowRecoveryDispatch) == 0) + throw new IOException("Persistent dispatch failure."); + + recoveredBatch.TrySetResult(batch); + return Task.CompletedTask; + }, + watchSpecifications, + (_, _) => + { + Interlocked.Increment(ref watcherFailedCallCount); + Interlocked.Exchange(ref allowRecoveryDispatch, 1); + }); + + Assert.IsTrue(watcher.Start()); + + string filePath = Path.Combine(workspaceRoot, "test.lua"); + + QueueChangeForTest(watcher, filePath, FileChangeKind.Changed); + + for (int i = 0; i < 5; i++) + await DispatchPendingChangesForTestAsync(watcher).ConfigureAwait(false); + + FileChangeBatch dispatchedBatch = await recoveredBatch.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + Assert.AreEqual(1, watcherFailedCallCount); + Assert.AreEqual(6, dispatchAttemptCount); + Assert.IsFalse(watcher.HasActiveWatchers); + Assert.AreEqual(1, dispatchedBatch.Count); + Assert.AreEqual(filePath, dispatchedBatch.Entries[0].Path); + Assert.AreEqual(FileChangeKind.Changed, dispatchedBatch.Entries[0].Kind); + } + + [TestMethod] + public async Task Dispose_DuringActiveDispatch_DoesNotFaultDispatch() + { + using var workspace = new TemporaryWorkspaceRoot("LuaWatcherDispose_"); + string workspaceRoot = workspace.DirectoryPath; + WorkspaceWatchSpecification[] watchSpecifications = [new("*.lua", IncludeSubdirectories: true)]; + var dispatchStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowDispatchToFinish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var watcher = new WorkspaceFileWatcher(workspaceRoot, async (_, _) => + { + dispatchStarted.TrySetResult(true); + await allowDispatchToFinish.Task.ConfigureAwait(false); + }, watchSpecifications); + + QueueChangeForTest(watcher, Path.Combine(workspaceRoot, "test.lua"), FileChangeKind.Changed); + Task dispatchTask = DispatchPendingChangesForTestAsync(watcher); + + Task completedTask = await Task.WhenAny(dispatchStarted.Task, Task.Delay(TimeSpan.FromSeconds(1))).ConfigureAwait(false); + Assert.AreSame(dispatchStarted.Task, completedTask); + + Task disposeTask = Task.Run(watcher.Dispose); + Assert.IsFalse(disposeTask.IsCompleted); + + allowDispatchToFinish.TrySetResult(true); + + await Task.WhenAll(dispatchTask, disposeTask).ConfigureAwait(false); + } + + [TestMethod] + public async Task DisposeWithoutFinalFlush_DuringActiveDispatch_DropsRequeuedBatch() + { + using var workspace = new TemporaryWorkspaceRoot("LuaWatcherDisposeNoFlush_"); + string workspaceRoot = workspace.DirectoryPath; + WorkspaceWatchSpecification[] watchSpecifications = [new("*.lua", IncludeSubdirectories: true)]; + var dispatchStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowFirstDispatchToFinish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + FileChangeBatch? dispatchedBatch = null; + int dispatchAttemptCount = 0; + + await using var watcher = new WorkspaceFileWatcher(workspaceRoot, async (batch, _) => + { + dispatchAttemptCount++; + + if (dispatchAttemptCount == 1) + { + dispatchStarted.TrySetResult(true); + await allowFirstDispatchToFinish.Task.ConfigureAwait(false); + throw new IOException("Simulated dispatch failure during no-flush disposal."); + } + + dispatchedBatch = batch; + }, watchSpecifications); + + string filePath = Path.Combine(workspaceRoot, "test.lua"); + + QueueChangeForTest(watcher, filePath, FileChangeKind.Changed); + Task dispatchTask = DispatchPendingChangesForTestAsync(watcher); + + await dispatchStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + Task disposeTask = Task.Run(watcher.DisposeWithoutFinalFlush); + Assert.IsFalse(disposeTask.IsCompleted); + + allowFirstDispatchToFinish.TrySetResult(true); + + await Task.WhenAll(dispatchTask, disposeTask).ConfigureAwait(false); + + Assert.AreEqual(1, dispatchAttemptCount); + Assert.IsNull(dispatchedBatch); + } + + [TestMethod] + public async Task DisposeAsync_DuringActiveDispatch_PreservesRequeuedBatchForFinalFlush() + { + using var workspace = new TemporaryWorkspaceRoot("LuaWatcherDisposeRetry_"); + string workspaceRoot = workspace.DirectoryPath; + WorkspaceWatchSpecification[] watchSpecifications = [new("*.lua", IncludeSubdirectories: true)]; + var dispatchStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowFirstDispatchToFinish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + FileChangeBatch? dispatchedBatch = null; + int dispatchAttemptCount = 0; + + await using var watcher = new WorkspaceFileWatcher(workspaceRoot, async (batch, _) => + { + dispatchAttemptCount++; + + if (dispatchAttemptCount == 1) + { + dispatchStarted.TrySetResult(true); + await allowFirstDispatchToFinish.Task.ConfigureAwait(false); + throw new IOException("Simulated dispatch failure during disposal."); + } + + dispatchedBatch = batch; + }, watchSpecifications); + + string filePath = Path.Combine(workspaceRoot, "test.lua"); + + QueueChangeForTest(watcher, filePath, FileChangeKind.Changed); + Task dispatchTask = DispatchPendingChangesForTestAsync(watcher); + + await dispatchStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + Task disposeTask = watcher.DisposeAsync().AsTask(); + + Assert.IsFalse(disposeTask.IsCompleted); + + allowFirstDispatchToFinish.TrySetResult(true); + + await Task.WhenAll(dispatchTask, disposeTask).ConfigureAwait(false); + + Assert.AreEqual(2, dispatchAttemptCount); + Assert.IsNotNull(dispatchedBatch); + Assert.AreEqual(1, dispatchedBatch.Count); + Assert.AreEqual(filePath, dispatchedBatch.Entries[0].Path); + Assert.AreEqual(FileChangeKind.Changed, dispatchedBatch.Entries[0].Kind); + } + + [TestMethod] + public async Task DisposeAsync_WhenFinalFlushStalls_CompletesWithoutWaitingIndefinitely() + { + using var workspace = new TemporaryWorkspaceRoot("LuaWatcherDisposeTimedFlush_"); + string workspaceRoot = workspace.DirectoryPath; + WorkspaceWatchSpecification[] watchSpecifications = [new("*.lua", IncludeSubdirectories: true)]; + var dispatchStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowFirstDispatchToFinish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var finalFlushStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var finalFlushExited = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int finalFlushCancellationObserved = 0; + + await using var watcher = new WorkspaceFileWatcher(workspaceRoot, async (_, cancellationToken) => + { + if (!dispatchStarted.Task.IsCompleted) + { + dispatchStarted.TrySetResult(true); + await allowFirstDispatchToFinish.Task.ConfigureAwait(false); + throw new IOException("Simulated dispatch failure during disposal."); + } + + finalFlushStarted.TrySetResult(true); + + using CancellationTokenRegistration cancellationRegistration = cancellationToken.Register( + () => Interlocked.Exchange(ref finalFlushCancellationObserved, 1)); + + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + Interlocked.Exchange(ref finalFlushCancellationObserved, 1); + } + finally + { + finalFlushExited.TrySetResult(true); + } + }, watchSpecifications); + + string filePath = Path.Combine(workspaceRoot, "test.lua"); + + QueueChangeForTest(watcher, filePath, FileChangeKind.Changed); + Task dispatchTask = DispatchPendingChangesForTestAsync(watcher); + + await dispatchStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + Task disposeTask = watcher.DisposeAsync().AsTask(); + + allowFirstDispatchToFinish.TrySetResult(true); + + await finalFlushStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + await Task.WhenAll(dispatchTask, disposeTask.WaitAsync(TimeSpan.FromSeconds(5))).ConfigureAwait(false); + + Assert.IsTrue(finalFlushExited.Task.IsCompleted); + Assert.AreEqual(1, Volatile.Read(ref finalFlushCancellationObserved)); + } + + [TestMethod] + public async Task ReportErrorForTest_WithPendingChanges_WaitsForFailureHandlerBeforeRecoveryDispatch() + { + using var workspace = new TemporaryWorkspaceRoot("WorkspaceWatcherRecoveryOrdering_"); + string workspaceRoot = workspace.DirectoryPath; + WorkspaceWatchSpecification[] watchSpecifications = [new("*.lua", IncludeSubdirectories: true)]; + var failureHandlerEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowFailureHandlerToFinish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var recoveryDispatchStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int dispatchObservedBeforeFailureHandlerFinished = 0; + + await using var watcher = new WorkspaceFileWatcher( + workspaceRoot, + (_, _) => + { + if (!allowFailureHandlerToFinish.Task.IsCompleted) + Interlocked.Exchange(ref dispatchObservedBeforeFailureHandlerFinished, 1); + + recoveryDispatchStarted.TrySetResult(true); + return Task.CompletedTask; + }, + watchSpecifications, + (_, _) => + { + failureHandlerEntered.TrySetResult(true); + allowFailureHandlerToFinish.Task.GetAwaiter().GetResult(); + }); + + Assert.IsTrue(watcher.Start()); + + QueueChangeForTest(watcher, Path.Combine(workspaceRoot, "test.lua"), FileChangeKind.Changed); + Task errorTask = Task.Run(() => ReportErrorForTest(watcher, new IOException("Simulated watcher failure."))); + + await failureHandlerEntered.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + Task completedTask = await Task.WhenAny(recoveryDispatchStarted.Task, Task.Delay(TimeSpan.FromMilliseconds(200))).ConfigureAwait(false); + Assert.AreNotSame(recoveryDispatchStarted.Task, completedTask); + Assert.AreEqual(0, Volatile.Read(ref dispatchObservedBeforeFailureHandlerFinished)); + + allowFailureHandlerToFinish.TrySetResult(true); + + await errorTask.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + await recoveryDispatchStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + Assert.AreEqual(0, Volatile.Read(ref dispatchObservedBeforeFailureHandlerFinished)); + } + + [TestMethod] + public void Dispose_WhenPendingChangesExistAndNoDispatchIsActive_DropsBufferedBatch() + { + using var workspace = new TemporaryWorkspaceRoot("LuaWatcherDisposeFlush_"); + string workspaceRoot = workspace.DirectoryPath; + WorkspaceWatchSpecification[] watchSpecifications = [new("*.lua", IncludeSubdirectories: true)]; + FileChangeBatch? dispatchedBatch = null; + + using var watcher = new WorkspaceFileWatcher(workspaceRoot, (batch, _) => + { + dispatchedBatch = batch; + return Task.CompletedTask; + }, watchSpecifications); + + QueueChangeForTest(watcher, Path.Combine(workspaceRoot, "test.lua"), FileChangeKind.Changed); + + watcher.Dispose(); + + Assert.IsNull(dispatchedBatch); + } + + [TestMethod] + public void DisposeWithoutFinalFlush_WhenPendingChangesExistAndNoDispatchIsActive_DropsBufferedBatch() + { + using var workspace = new TemporaryWorkspaceRoot("LuaWatcherDisposeNoFlushPending_"); + string workspaceRoot = workspace.DirectoryPath; + WorkspaceWatchSpecification[] watchSpecifications = [new("*.lua", IncludeSubdirectories: true)]; + FileChangeBatch? dispatchedBatch = null; + + using var watcher = new WorkspaceFileWatcher(workspaceRoot, (batch, _) => + { + dispatchedBatch = batch; + return Task.CompletedTask; + }, watchSpecifications); + + QueueChangeForTest(watcher, Path.Combine(workspaceRoot, "test.lua"), FileChangeKind.Changed); + + watcher.DisposeWithoutFinalFlush(); + + Assert.IsNull(dispatchedBatch); + } + + [TestMethod] + public void Dispose_WhenBufferedChangesExist_DoesNotDeadlockCallerContext() + { + using var workspace = new TemporaryWorkspaceRoot("LuaWatcherDisposeContext_"); + string workspaceRoot = workspace.DirectoryPath; + WorkspaceWatchSpecification[] watchSpecifications = [new("*.lua", IncludeSubdirectories: true)]; + var disposeCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Exception? failure = null; + + var thread = new Thread(() => + { + SynchronizationContext.SetSynchronizationContext(new NonPumpingSynchronizationContext()); + + try + { + using var watcher = new WorkspaceFileWatcher( + workspaceRoot, + async (_, _) => await Task.Yield(), + watchSpecifications); + + QueueChangeForTest(watcher, Path.Combine(workspaceRoot, "test.lua"), FileChangeKind.Changed); + + watcher.Dispose(); + disposeCompleted.TrySetResult(true); + } + catch (Exception exception) + { + failure = exception; + disposeCompleted.TrySetException(exception); + } + finally + { + SynchronizationContext.SetSynchronizationContext(null); + } + }) + { + IsBackground = true + }; + + thread.Start(); + + Assert.IsTrue(disposeCompleted.Task.Wait(TimeSpan.FromSeconds(5))); + Assert.IsTrue(thread.Join(TimeSpan.FromSeconds(1))); + Assert.IsNull(failure); + } + + [TestMethod] + public void Start_UsesConfiguredWatchSpecifications() + { + using var workspace = new TemporaryWorkspaceRoot("WorkspaceWatcherSpecs_"); + string workspaceRoot = workspace.DirectoryPath; + + using var watcher = new WorkspaceFileWatcher( + workspaceRoot, + (_, _) => Task.CompletedTask, + watchSpecifications: + [ + new WorkspaceWatchSpecification("*.lua", IncludeSubdirectories: true), + new WorkspaceWatchSpecification(".luarc.*", IncludeSubdirectories: false) + ]); + + Assert.IsTrue(watcher.Start()); + Assert.AreEqual(2, watcher.ActiveWatcherCount); + Assert.IsTrue(watcher.HasActiveWatchers); + } + + [TestMethod] + public async Task ReportErrorForTest_ConcurrentWithDispose_LeavesNoActiveWatchers() + { + using var workspace = new TemporaryWorkspaceRoot("WorkspaceWatcherErrorDispose_"); + string workspaceRoot = workspace.DirectoryPath; + + for (int i = 0; i < 50; i++) + { + await using var watcher = new WorkspaceFileWatcher( + workspaceRoot, + (_, _) => Task.CompletedTask, + [new WorkspaceWatchSpecification("*.lua", IncludeSubdirectories: true)]); + + Assert.IsTrue(watcher.Start()); + + Task errorTask = Task.Run(() => ReportErrorForTest(watcher, new IOException("Simulated watcher failure."))); + Task disposeTask = Task.Run(watcher.Dispose); + + await Task.WhenAll(errorTask, disposeTask).ConfigureAwait(false); + + Assert.IsTrue(watcher.IsDisposed); + Assert.AreEqual(0, watcher.ActiveWatcherCount); + Assert.IsFalse(watcher.HasActiveWatchers); + } + } + + [TestMethod] + public void ReportErrorForTest_WhenFailureHandlerThrows_LogsWarningAndStopsWatching() + { + using var workspace = new TemporaryWorkspaceRoot("WorkspaceWatcherFailureCallback_"); + string workspaceRoot = workspace.DirectoryPath; + using var logScope = new NLogMemoryScope(LogLevel.Warn); + + using var watcher = new WorkspaceFileWatcher( + workspaceRoot, + (_, _) => Task.CompletedTask, + [new WorkspaceWatchSpecification("*.lua", IncludeSubdirectories: true)], + (_, _) => throw new InvalidOperationException("Simulated watcher failure callback exception.")); + + Assert.IsTrue(watcher.Start()); + + ReportErrorForTest(watcher, new IOException("Simulated watcher failure.")); + + Assert.IsFalse(watcher.HasActiveWatchers); + Assert.AreEqual(0, watcher.ActiveWatcherCount); + Assert.IsTrue(logScope.Logs.Any(log => log.Contains("Workspace watcher failure handler threw.", StringComparison.OrdinalIgnoreCase) + && log.Contains("Simulated watcher failure callback exception.", StringComparison.Ordinal)), + string.Join(Environment.NewLine, logScope.Logs)); + } + + [TestMethod] + public void Start_AfterFailure_ResetsFailureReportingForNextFailureSequence() + { + using var workspace = new TemporaryWorkspaceRoot("WorkspaceWatcherRestart_"); + string workspaceRoot = workspace.DirectoryPath; + int failureCount = 0; + + using var watcher = new WorkspaceFileWatcher( + workspaceRoot, + (_, _) => Task.CompletedTask, + [new WorkspaceWatchSpecification("*.lua", IncludeSubdirectories: true)], + (_, _) => failureCount++); + + Assert.IsTrue(watcher.Start()); + + ReportErrorForTest(watcher, new IOException("Simulated watcher failure 1.")); + + Assert.AreEqual(1, failureCount); + Assert.IsFalse(watcher.HasActiveWatchers); + + Assert.IsTrue(watcher.Start()); + + ReportErrorForTest(watcher, new IOException("Simulated watcher failure 2.")); + + Assert.AreEqual(2, failureCount); + Assert.IsFalse(watcher.HasActiveWatchers); + } + + [TestMethod] + public async Task Start_ConcurrentWithDispose_DoesNotLeaveOwnedWatchersBehind() + { + using var workspace = new TemporaryWorkspaceRoot("WorkspaceWatcherStartDispose_"); + string workspaceRoot = workspace.DirectoryPath; + + for (int i = 0; i < 50; i++) + { + var watcher = new WorkspaceFileWatcher( + workspaceRoot, + (_, _) => Task.CompletedTask, + [new WorkspaceWatchSpecification("*.lua", IncludeSubdirectories: true)]); + + Task startTask = Task.Run(watcher.Start); + Task disposeTask = Task.Run(watcher.Dispose); + + await Task.WhenAll(startTask, disposeTask).ConfigureAwait(false); + + Assert.IsTrue(watcher.IsDisposed); + Assert.AreEqual(0, watcher.ActiveWatcherCount); + Assert.IsFalse(watcher.HasActiveWatchers); + + watcher.Dispose(); + } + } + + private static void QueueChangeForTest(WorkspaceFileWatcher watcher, string filePath, FileChangeKind changeKind) + { +#pragma warning disable CS0618 + watcher.QueueChangeForTest(filePath, changeKind); +#pragma warning restore CS0618 + } + + private static Task DispatchPendingChangesForTestAsync(WorkspaceFileWatcher watcher) + { +#pragma warning disable CS0618 + return watcher.DispatchPendingChangesForTestAsync(); +#pragma warning restore CS0618 + } + + private static void ReportErrorForTest(WorkspaceFileWatcher watcher, Exception exception) + { +#pragma warning disable CS0618 + watcher.ReportErrorForTest(exception); +#pragma warning restore CS0618 + } + + private sealed class TemporaryWorkspaceRoot : IDisposable + { + public TemporaryWorkspaceRoot(string namePrefix) + { + DirectoryPath = Path.Combine(Path.GetTempPath(), namePrefix + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(DirectoryPath); + } + + public string DirectoryPath { get; } + + public void Dispose() + { + if (Directory.Exists(DirectoryPath)) + Directory.Delete(DirectoryPath, recursive: true); + } + } + + private sealed class NonPumpingSynchronizationContext : SynchronizationContext + { + public override void Post(SendOrPostCallback d, object? state) + { + // Intentionally never pumps posted continuations. + } + + public override void Send(SendOrPostCallback d, object? state) + => d(state); + } +} diff --git a/Tests/TombLib.LanguageServer.Core.Tests/Support/NLogMemoryScope.cs b/Tests/TombLib.LanguageServer.Core.Tests/Support/NLogMemoryScope.cs new file mode 100644 index 0000000000..105cd77b27 --- /dev/null +++ b/Tests/TombLib.LanguageServer.Core.Tests/Support/NLogMemoryScope.cs @@ -0,0 +1,39 @@ +using NLog; +using NLog.Config; +using NLog.Targets; + +namespace TombLib.LanguageServer.Core.Tests; + +internal sealed class NLogMemoryScope : IDisposable +{ + private readonly LoggingConfiguration? _previousConfiguration; + + public NLogMemoryScope(LogLevel minLevel) + { + _previousConfiguration = LogManager.Configuration; + + var target = new MemoryTarget("TestLogs") + { + Layout = "${level}|${message}|${exception:format=Message}" + }; + + var configuration = new LoggingConfiguration(); + configuration.AddTarget(target); + configuration.AddRule(minLevel, LogLevel.Fatal, target); + + LogManager.Configuration = configuration; + LogManager.ReconfigExistingLoggers(); + + Target = target; + } + + public MemoryTarget Target { get; } + + public IList Logs => Target.Logs; + + public void Dispose() + { + LogManager.Configuration = _previousConfiguration; + LogManager.ReconfigExistingLoggers(); + } +} diff --git a/Tests/TombLib.LanguageServer.Core.Tests/Support/TrackedDocumentTestSupport.cs b/Tests/TombLib.LanguageServer.Core.Tests/Support/TrackedDocumentTestSupport.cs new file mode 100644 index 0000000000..2c07467ebb --- /dev/null +++ b/Tests/TombLib.LanguageServer.Core.Tests/Support/TrackedDocumentTestSupport.cs @@ -0,0 +1,56 @@ +namespace TombLib.LanguageServer.Core.Tests; + +internal sealed class TestTrackedDocumentStore : TrackedDocumentStore +{ + protected override TestTrackedDocumentState CreateTrackedDocumentState( + string filePath, + string uri, + string content, + int version, + bool isOpen, + int openReferenceCount, + int requestReferenceCount, + long lastAccessStamp) + => new(filePath, uri, content, version, isOpen, openReferenceCount, requestReferenceCount, lastAccessStamp); + + protected override long GetLastAccessStamp(TestTrackedDocumentState state) + => state.LastAccessStamp; + + protected override void TouchTrackedDocumentState(TestTrackedDocumentState state, long lastAccessStamp) + => state.Touch(lastAccessStamp); + + protected override void ReopenTrackedDocumentState(TestTrackedDocumentState state, string content) + => state.Reopen(content); + + protected override string ReplaceTrackedDocumentContent(TestTrackedDocumentState state, string content) + => state.Update(content); + + protected override void RenameTrackedDocumentState(TestTrackedDocumentState state, string filePath, string uri) + => state.Rename(filePath, uri); + + protected override void MarkTrackedDocumentClosed(TestTrackedDocumentState state) + => state.Close(); +} + +internal sealed class TestTrackedDocumentState : TrackedDocumentState +{ + public TestTrackedDocumentState(string filePath, string uri, string content, int version, bool isOpen, + int openReferenceCount, int requestReferenceCount, long lastAccessStamp) + : base(filePath, uri, content, version, isOpen, openReferenceCount, requestReferenceCount, lastAccessStamp) + { } + + public void Touch(long lastAccessStamp) + => SetLastAccessStamp(lastAccessStamp); + + public void Reopen(string content) + => ReopenDocument(content); + + public string Update(string content) + => ReplaceContent(content); + + public void Rename(string filePath, string uri) + => RenameDocument(filePath, uri); + + public void Close() + => MarkDocumentClosed(); +} diff --git a/Tests/TombLib.LanguageServer.Core.Tests/TombLib.LanguageServer.Core.Tests.csproj b/Tests/TombLib.LanguageServer.Core.Tests/TombLib.LanguageServer.Core.Tests.csproj new file mode 100644 index 0000000000..e1d82c5fee --- /dev/null +++ b/Tests/TombLib.LanguageServer.Core.Tests/TombLib.LanguageServer.Core.Tests.csproj @@ -0,0 +1,25 @@ + + + + enable + false + true + enable + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Tests/TombLib.LanguageServer.Lua.Tests/Diagnostics/LuaLanguageServerDiagnosticsParserTests.cs b/Tests/TombLib.LanguageServer.Lua.Tests/Diagnostics/LuaLanguageServerDiagnosticsParserTests.cs new file mode 100644 index 0000000000..03890b6586 --- /dev/null +++ b/Tests/TombLib.LanguageServer.Lua.Tests/Diagnostics/LuaLanguageServerDiagnosticsParserTests.cs @@ -0,0 +1,99 @@ +namespace TombLib.LanguageServer.Lua.Tests; + +[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); + } + + [TestMethod] + public void TryParse_IgnoresMalformedDiagnosticEntriesAndPreservesValidEntries() + { + const string filePath = @"C:\Workspace\test.lua"; + const string content = "local value = 1"; + + bool parsed = LuaLanguageServerDiagnosticsParser.TryParse( + new PublishDiagnosticsParams( + Uri: null, + Version: 1, + Diagnostics: + [ + new DiagnosticPayload( + Range: null, + Severity: 1, + Message: "Broken payload.", + Source: null, + Code: null), + new DiagnosticPayload( + new ProtocolRangePayload( + new ProtocolNullablePosition(0, 6), + new ProtocolNullablePosition(0, 11)), + 1, + "Valid payload.", + null, + null) + ]), + filePath, + content, + documentVersion: 1, + out LuaPublishedDiagnostics? publishedDiagnostics); + + Assert.IsTrue(parsed); + Assert.IsNotNull(publishedDiagnostics); + Assert.AreEqual(1, publishedDiagnostics.Diagnostics.Count); + Assert.AreEqual(6, publishedDiagnostics.Diagnostics[0].StartOffset); + Assert.AreEqual(11, publishedDiagnostics.Diagnostics[0].EndOffset); + } + + private static PublishDiagnosticsParams CreateDiagnostics(int line, int startCharacter, int endLine, int endCharacter) => new( + Uri: null, + Version: 1, + Diagnostics: + [ + new DiagnosticPayload( + new ProtocolRangePayload( + new ProtocolNullablePosition(line, startCharacter), + new ProtocolNullablePosition(endLine, endCharacter)), + 1, + "Syntax error.", + null, + null) + ]); +} diff --git a/Tests/TombLib.LanguageServer.Lua.Tests/Documents/LuaDocumentStoreTests.cs b/Tests/TombLib.LanguageServer.Lua.Tests/Documents/LuaDocumentStoreTests.cs new file mode 100644 index 0000000000..e65be2a178 --- /dev/null +++ b/Tests/TombLib.LanguageServer.Lua.Tests/Documents/LuaDocumentStoreTests.cs @@ -0,0 +1,204 @@ +using TombLib.Scripting.Diagnostics; +using TombLib.Scripting.Core.Lua; + +namespace TombLib.LanguageServer.Lua.Tests; + +[TestClass] +public class LuaDocumentStoreTests +{ + [TestMethod] + public void Rename_ReturnsNullAndPreservesTrackedDocuments_WhenDestinationIsAlreadyTracked() + { + var manager = new LuaDocumentStore(); + 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); + + DocumentRenameRequest? renameRequest = manager.Rename(oldFilePath, newFilePath, "return 1"); + + Assert.IsNull(renameRequest); + Assert.IsNotNull(manager.GetDocumentSnapshot(oldFilePath)); + + DocumentSnapshot? 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 DocumentSnapshot? closedDestinationDocument)); + Assert.IsNotNull(closedDestinationDocument); + Assert.AreEqual("return 2", closedDestinationDocument.Content); + } + + [TestMethod] + public void TryClose_RemovesTrackedDocumentWhileRestartReplayIsPending() + { + var manager = new LuaDocumentStore(); + 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 DocumentSnapshot? closingDocument)); + Assert.IsNull(closingDocument); + Assert.IsNull(manager.GetDocumentSnapshot(filePath)); + } + + [TestMethod] + public void TryReleaseRequest_RemovesRequestOnlyTrackedDocument() + { + var manager = new LuaDocumentStore(); + const string filePath = @"C:\Workspace\Scripts\hover.lua"; + + manager.Synchronize(filePath, "return 1", acquireRequestReference: true); + + Assert.IsTrue(manager.TryReleaseRequest(filePath, out DocumentSnapshot? closingDocument)); + Assert.IsNotNull(closingDocument); + Assert.AreEqual(filePath, closingDocument.FilePath); + Assert.IsNull(manager.GetDocumentSnapshot(filePath)); + } + + [TestMethod] + public void TryReleaseRequest_PreservesEditorOwnedTrackedDocument() + { + var manager = new LuaDocumentStore(); + const string filePath = @"C:\Workspace\Scripts\open.lua"; + + manager.Synchronize(filePath, "return 1", acquireOpenReference: true); + manager.Synchronize(filePath, "return 1", acquireRequestReference: true); + + Assert.IsFalse(manager.TryReleaseRequest(filePath, out DocumentSnapshot? closingDocument)); + Assert.IsNull(closingDocument); + Assert.IsNotNull(manager.GetDocumentSnapshot(filePath)); + Assert.IsTrue(manager.TryClose(filePath, out DocumentSnapshot? closedDocument)); + Assert.IsNotNull(closedDocument); + } + + [TestMethod] + public void TryClose_PreservesRequestOwnedTrackedDocumentUntilRequestRelease() + { + var manager = new LuaDocumentStore(); + const string filePath = @"C:\Workspace\Scripts\request-owned.lua"; + + manager.Synchronize(filePath, "return 1", acquireOpenReference: true); + manager.Synchronize(filePath, "return 1", acquireRequestReference: true); + + Assert.IsFalse(manager.TryClose(filePath, out DocumentSnapshot? closingDocument)); + Assert.IsNull(closingDocument); + Assert.IsNotNull(manager.GetDocumentSnapshot(filePath)); + Assert.IsTrue(manager.TryReleaseRequest(filePath, out DocumentSnapshot? closedDocument)); + Assert.IsNotNull(closedDocument); + Assert.IsNull(manager.GetDocumentSnapshot(filePath)); + } + + [TestMethod] + public void DiagnosticsCache_StoresReadOnlyCopyDetachedFromSourceCollection() + { + var manager = new LuaDocumentStore(); + const string filePath = @"C:\Workspace\Scripts\diagnostics.lua"; + + manager.Synchronize(filePath, "return 1", acquireOpenReference: true); + DocumentSnapshot? trackedDocument = manager.GetDocumentSnapshot(filePath); + + var originalDiagnostic = new TextEditorDiagnostic(TextEditorDiagnosticSeverity.Warning, "Original", 0, 1); + var replacementDiagnostic = new TextEditorDiagnostic(TextEditorDiagnosticSeverity.Warning, "Replacement", 1, 2); + TextEditorDiagnostic[] sourceDiagnostics = [originalDiagnostic]; + + Assert.IsNotNull(trackedDocument); + + Assert.IsTrue(manager.TryStoreDiagnostics( + new LuaPublishedDiagnostics(filePath, sourceDiagnostics, version: trackedDocument.Version), + expectedDocumentVersion: trackedDocument.Version)); + + sourceDiagnostics[0] = replacementDiagnostic; + + IReadOnlyList storedDiagnostics = manager.GetDiagnostics(filePath); + + Assert.AreEqual(1, storedDiagnostics.Count); + Assert.AreSame(originalDiagnostic, storedDiagnostics[0]); + Assert.ThrowsException(() => ((IList)storedDiagnostics)[0] = replacementDiagnostic); + } + + [TestMethod] + public void DiagnosticsCache_DoesNotStorePayloadWhenTrackedDocumentVersionAdvanced() + { + var manager = new LuaDocumentStore(); + const string filePath = @"C:\Workspace\Scripts\diagnostics.lua"; + + manager.Synchronize(filePath, "return 1", acquireOpenReference: true); + DocumentSnapshot? staleDocument = manager.GetDocumentSnapshot(filePath); + manager.Synchronize(filePath, "return 2"); + + Assert.IsNotNull(staleDocument); + + bool stored = manager.TryStoreDiagnostics( + new LuaPublishedDiagnostics( + filePath, + [new TextEditorDiagnostic(TextEditorDiagnosticSeverity.Warning, "Stale", 0, 1)], + version: staleDocument.Version), + expectedDocumentVersion: staleDocument.Version); + + Assert.IsFalse(stored); + Assert.AreEqual(0, manager.GetDiagnostics(filePath).Count); + } + + [TestMethod] + public void SemanticTokensCache_ClonesStoredCollectionsAndDeltaState() + { + var manager = new LuaDocumentStore(); + const string filePath = @"C:\Workspace\Scripts\semantic.lua"; + + manager.Synchronize(filePath, "return 1", acquireOpenReference: true); + + var originalToken = new LuaSemanticToken(0, 0, 6, "variable", []); + var replacementToken = new LuaSemanticToken(1, 0, 6, "function", []); + LuaSemanticToken[] sourceTokens = [originalToken]; + int[] sourceDeltaData = [1, 2, 3]; + + Assert.IsTrue(manager.TryStoreSemanticTokens(filePath, version: 1, sourceTokens)); + manager.StoreSemanticTokensDeltaState(filePath, "tokens-1", sourceDeltaData); + + sourceTokens[0] = replacementToken; + sourceDeltaData[0] = 99; + + IReadOnlyList storedTokens = manager.GetSemanticTokens(filePath); + SemanticTokensDeltaState deltaState = manager.GetSemanticTokensDeltaState(filePath); + + Assert.AreEqual(1, storedTokens.Count); + Assert.AreSame(originalToken, storedTokens[0]); + Assert.ThrowsException(() => ((IList)storedTokens)[0] = replacementToken); + Assert.IsNotNull(deltaState.PreviousData); + Assert.AreEqual(1, deltaState.PreviousData[0]); + + deltaState.PreviousData[1] = 77; + + SemanticTokensDeltaState reloadedDeltaState = manager.GetSemanticTokensDeltaState(filePath); + + Assert.IsNotNull(reloadedDeltaState.PreviousData); + Assert.AreEqual(2, reloadedDeltaState.PreviousData[1]); + } + + [TestMethod] + public void SemanticTokensCache_DoesNotStoreTokensWhenTrackedDocumentVersionAdvanced() + { + var manager = new LuaDocumentStore(); + const string filePath = @"C:\Workspace\Scripts\semantic.lua"; + + manager.Synchronize(filePath, "return 1", acquireOpenReference: true); + DocumentSnapshot? staleDocument = manager.GetDocumentSnapshot(filePath); + manager.Synchronize(filePath, "return 2"); + + Assert.IsNotNull(staleDocument); + + bool stored = manager.TryStoreSemanticTokens( + filePath, + version: staleDocument.Version, + [new LuaSemanticToken(0, 0, 6, "variable", [])]); + + Assert.IsFalse(stored); + Assert.AreEqual(0, manager.GetSemanticTokens(filePath).Count); + } +} diff --git a/Tests/TombLib.LanguageServer.Lua.Tests/Documents/LuaDocumentVersionHelperTests.cs b/Tests/TombLib.LanguageServer.Lua.Tests/Documents/LuaDocumentVersionHelperTests.cs new file mode 100644 index 0000000000..fa3793e5cb --- /dev/null +++ b/Tests/TombLib.LanguageServer.Lua.Tests/Documents/LuaDocumentVersionHelperTests.cs @@ -0,0 +1,32 @@ +namespace TombLib.LanguageServer.Lua.Tests; + +[TestClass] +public class LuaDocumentVersionHelperTests +{ + [TestMethod] + public void TryAccept_RejectsOlderPositiveVersion() + { + bool accepted = LuaDocumentVersionHelper.TryAccept(currentVersion: 5, incomingVersion: 4, out int acceptedVersion); + + Assert.IsFalse(accepted); + Assert.AreEqual(5, acceptedVersion); + } + + [TestMethod] + public void TryAccept_PreservesCurrentVersionForUnversionedPayload() + { + bool accepted = LuaDocumentVersionHelper.TryAccept(currentVersion: 5, incomingVersion: 0, out int acceptedVersion); + + Assert.IsTrue(accepted); + Assert.AreEqual(5, acceptedVersion); + } + + [TestMethod] + public void TryAccept_AdvancesToNewerPositiveVersion() + { + bool accepted = LuaDocumentVersionHelper.TryAccept(currentVersion: 5, incomingVersion: 6, out int acceptedVersion); + + Assert.IsTrue(accepted); + Assert.AreEqual(6, acceptedVersion); + } +} diff --git a/Tests/TombLib.LanguageServer.Lua.Tests/GlobalUsings.cs b/Tests/TombLib.LanguageServer.Lua.Tests/GlobalUsings.cs new file mode 100644 index 0000000000..4f0df5267f --- /dev/null +++ b/Tests/TombLib.LanguageServer.Lua.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using TombLib.Scripting.Core.Lua;global using TombLib.LanguageServer.Core; diff --git a/Tests/TombLib.LanguageServer.Lua.Tests/Integration/LuaLanguageServerRealIntegrationTests.cs b/Tests/TombLib.LanguageServer.Lua.Tests/Integration/LuaLanguageServerRealIntegrationTests.cs new file mode 100644 index 0000000000..52e630a848 --- /dev/null +++ b/Tests/TombLib.LanguageServer.Lua.Tests/Integration/LuaLanguageServerRealIntegrationTests.cs @@ -0,0 +1,525 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO.Compression; +using System.Reflection; +using TombLib.Scripting.Completion; +using TombLib.Scripting.Core.Lua; +using TombLib.Scripting.Editing; +using TombLib.Scripting.Hover; +using TombLib.Scripting.Navigation; + +namespace TombLib.LanguageServer.Lua.Tests; + +/// +/// Live integration coverage for the bundled Lua language server. +/// These tests require TombIDE/TombIDE.Shared/TIDE/LuaLS.zip to be present in the repository layout +/// reachable from the test output directory, and they typically take several seconds each because they +/// launch a real language-server process, wait for diagnostics and semantic-token round-trips, and +/// exercise restart or shutdown behavior. +/// +[TestClass] +public class LuaLanguageServerRealIntegrationTests +{ + private static readonly TimeSpan IntegrationTimeout = TimeSpan.FromSeconds(20); + private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(150); + + [TestMethod] + [TestCategory("Integration")] + public async Task Provider_WithBundledLuaLanguageServer_HandlesLiveWorkflowConfigurationReloadAndShutdown() + { + using var session = new RealLuaLanguageServerTestSession(); + + string filePath = Path.Combine(session.WorkspaceRoot, "Scripts", "test.lua"); + string apiDirectoryPath = Path.Combine(session.WorkspaceRoot, ".API"); + string generatedApiFilePath = Path.Combine(apiDirectoryPath, "Generated.lua"); + + const string initialContent = "local stable_local =\r\nreturn stable_local\r\n"; + const string updatedContent = "local stable_local = 1\r\nlocal updated_local = stable_local + 1\r\nreturn updated_local\r\nupd"; + const string libraryAwareContent = updatedContent + "\r\ngen"; + + Directory.CreateDirectory(Path.GetDirectoryName(filePath) ?? session.WorkspaceRoot); + File.WriteAllText(filePath, initialContent); + + using var provider = new LuaLanguageServerIntellisenseProvider(session.WorkspaceRoot, session.ExecutablePath); + + provider.OpenDocument(filePath, initialContent); + + await WaitForConditionAsync( + () => provider.GetDiagnostics(filePath).Count > 0, + IntegrationTimeout, + "Expected bundled LuaLS to publish diagnostics for the syntax error in the opened document."); + + provider.UpdateDocument(filePath, updatedContent); + + await WaitForConditionAsync( + () => provider.GetSemanticTokens(filePath).Any(token => token.Line >= 2), + IntegrationTimeout, + "Expected semantic tokens to reflect the updated live document content."); + + IReadOnlyList completionItems = await WaitForCompletionItemsAsync( + () => provider.GetCompletionItemsAsync(filePath, updatedContent, 3, 3), + items => items.Any(item => string.Equals(item.Label, "updated_local", StringComparison.Ordinal)), + IntegrationTimeout, + "Expected bundled LuaLS to return completion items for the updated local variable."); + + Assert.IsTrue(completionItems.Any(item => string.Equals(item.Label, "updated_local", StringComparison.Ordinal))); + + TextHoverInfo hover = await WaitForHoverAsync( + () => provider.GetHoverAsync(filePath, updatedContent, 2, 8), + IntegrationTimeout, + "Expected bundled LuaLS to return hover information for the updated document."); + + Assert.IsFalse(string.IsNullOrWhiteSpace(hover.Content)); + + Directory.CreateDirectory(apiDirectoryPath); + + File.WriteAllText(generatedApiFilePath, + "---@meta\r\n" + + "function generated_function() end\r\n"); + + await DispatchWorkspaceFileChangeAsync(provider, generatedApiFilePath, FileChangeKind.Created, CancellationToken.None); + + provider.UpdateDocument(filePath, libraryAwareContent); + + IReadOnlyList libraryItems = await WaitForCompletionItemsAsync( + () => provider.GetCompletionItemsAsync(filePath, libraryAwareContent, 4, 3), + items => items.Any(item => item.Label.StartsWith("generated_function", StringComparison.Ordinal)), + IntegrationTimeout, + "Expected a newly forwarded .API library symbol to appear in completions after the live workspace change."); + + Assert.IsTrue(libraryItems.Any(item => item.Label.StartsWith("generated_function", StringComparison.Ordinal))); + + int processId = GetRequiredServerProcessId(provider); + + provider.Dispose(); + + await WaitForProcessExitAsync( + processId, + IntegrationTimeout, + "Expected disposing the provider to stop the live Lua language-server process."); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task Provider_WithBundledLuaLanguageServer_RestartsAfterLiveServerCrashAndResumesRequests() + { + using var session = new RealLuaLanguageServerTestSession(); + + string filePath = Path.Combine(session.WorkspaceRoot, "Scripts", "restart.lua"); + + const string initialContent = "local restart_probe = 1\r\nres"; + const string restartedContent = "local restart_probe = 1\r\nlocal after_restart = restart_probe + 1\r\naft"; + + Directory.CreateDirectory(Path.GetDirectoryName(filePath) ?? session.WorkspaceRoot); + File.WriteAllText(filePath, initialContent); + + using var provider = new LuaLanguageServerIntellisenseProvider(session.WorkspaceRoot, session.ExecutablePath); + + provider.OpenDocument(filePath, initialContent); + + IReadOnlyList initialItems = await WaitForCompletionItemsAsync( + () => provider.GetCompletionItemsAsync(filePath, initialContent, 1, 3), + items => items.Any(item => string.Equals(item.Label, "restart_probe", StringComparison.Ordinal)), + IntegrationTimeout, + "Expected bundled LuaLS to return the initial completion before restart."); + + Assert.IsTrue(initialItems.Any(item => string.Equals(item.Label, "restart_probe", StringComparison.Ordinal))); + + LanguageServerClient client = GetRequiredClient(provider); + long initialGeneration = client.TransportGeneration; + Process initialProcess = GetRequiredServerProcess(client); + int initialProcessId = initialProcess.Id; + + initialProcess.Kill(entireProcessTree: true); + + await WaitForProcessExitAsync( + initialProcessId, + IntegrationTimeout, + "Expected the live Lua language-server process to exit after the simulated crash."); + + await WaitForConditionAsync( + () => !client.IsReady, + IntegrationTimeout, + "Expected the client to observe the live LuaLS transport disconnect."); + + provider.UpdateDocument(filePath, restartedContent); + + IReadOnlyList restartedItems = await WaitForCompletionItemsAsync( + () => provider.GetCompletionItemsAsync(filePath, restartedContent, 2, 3), + items => items.Any(item => string.Equals(item.Label, "after_restart", StringComparison.Ordinal)), + IntegrationTimeout, + "Expected the provider to restart the live server and resume completions after the crash."); + + Assert.IsTrue(restartedItems.Any(item => string.Equals(item.Label, "after_restart", StringComparison.Ordinal))); + + long restartedGeneration = client.TransportGeneration; + int restartedProcessId = GetRequiredServerProcessId(provider); + + Assert.IsTrue(restartedGeneration > initialGeneration); + Assert.AreNotEqual(initialProcessId, restartedProcessId); + + provider.Dispose(); + + await WaitForProcessExitAsync( + restartedProcessId, + IntegrationTimeout, + "Expected the restarted live Lua language-server process to stop on provider disposal."); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task Provider_WithBundledLuaLanguageServer_ResolvesDefinitionAndReferencesForLocalSymbol() + { + using var session = new RealLuaLanguageServerTestSession(); + + string filePath = Path.Combine(session.WorkspaceRoot, "Scripts", "navigation.lua"); + const string content = + "local tracked_value = 1\r\n" + + "local combined = tracked_value + tracked_value\r\n" + + "return combined\r\n"; + + Directory.CreateDirectory(Path.GetDirectoryName(filePath) ?? session.WorkspaceRoot); + File.WriteAllText(filePath, content); + + using var provider = new LuaLanguageServerIntellisenseProvider(session.WorkspaceRoot, session.ExecutablePath); + + provider.OpenDocument(filePath, content); + + await WaitForConditionAsync( + () => provider.SupportsReferences, + IntegrationTimeout, + "Expected the bundled Lua language server to advertise reference support."); + + TextDefinitionLocation definition = await WaitForDefinitionAsync( + () => provider.GetDefinitionAsync(filePath, content, 1, 19), + IntegrationTimeout, + "Expected the bundled Lua language server to resolve the local symbol definition."); + + Assert.IsTrue(string.Equals(filePath, definition.FilePath, StringComparison.OrdinalIgnoreCase)); + Assert.AreEqual(1, definition.LineNumber); + Assert.AreEqual(7, definition.ColumnNumber); + + IReadOnlyList references = await WaitForReferencesAsync( + () => provider.GetReferencesAsync(filePath, content, 1, 19), + referenceLocations => referenceLocations.Count >= 3, + IntegrationTimeout, + "Expected the bundled Lua language server to return declaration and usage references for the local symbol."); + + Assert.AreEqual(3, references.Count(location => string.Equals(location.FilePath, filePath, StringComparison.OrdinalIgnoreCase))); + Assert.IsTrue(references.Any(location => location.StartLineNumber == 1 && location.StartColumnNumber == 7)); + Assert.AreEqual(2, references.Count(location => location.StartLineNumber == 2)); + } + + [TestMethod] + [TestCategory("Integration")] + public async Task Provider_WithBundledLuaLanguageServer_ReturnsWorkspaceEditForRename() + { + using var session = new RealLuaLanguageServerTestSession(); + + string filePath = Path.Combine(session.WorkspaceRoot, "Scripts", "rename.lua"); + const string content = + "local tracked_value = 1\r\n" + + "local result = tracked_value + 2\r\n" + + "return tracked_value, result\r\n"; + + Directory.CreateDirectory(Path.GetDirectoryName(filePath) ?? session.WorkspaceRoot); + File.WriteAllText(filePath, content); + + using var provider = new LuaLanguageServerIntellisenseProvider(session.WorkspaceRoot, session.ExecutablePath); + + provider.OpenDocument(filePath, content); + + await WaitForConditionAsync( + () => provider.SupportsRename, + IntegrationTimeout, + "Expected the bundled Lua language server to advertise rename support."); + + TextWorkspaceEdit workspaceEdit = await WaitForWorkspaceEditAsync( + () => provider.RenameSymbolAsync(new TextRenameRequest(filePath, content, 0, 8, "renamed_value")), + IntegrationTimeout, + "Expected the bundled Lua language server to return a rename workspace edit for the local symbol."); + + Assert.IsTrue(workspaceEdit.HasEdits); + Assert.AreEqual(1, workspaceEdit.DocumentEdits.Count); + + TextDocumentEdit documentEdit = workspaceEdit.DocumentEdits[0]; + + Assert.IsTrue(string.Equals(filePath, documentEdit.FilePath, StringComparison.OrdinalIgnoreCase)); + Assert.IsTrue(documentEdit.TextEdits.Count >= 3); + Assert.IsTrue(documentEdit.TextEdits.All(edit => edit.NewText == "renamed_value")); + } + + private static async Task DispatchWorkspaceFileChangeAsync( + LuaLanguageServerIntellisenseProvider provider, + string filePath, + FileChangeKind kind, + CancellationToken cancellationToken) + { + var batch = new FileChangeBatch( + [ + new WorkspaceFileChange(filePath, kind) + ]); + + await LuaLanguageServerIntellisenseProviderTestAccess.DispatchWorkspaceFileChangesAsync(provider, batch, cancellationToken).ConfigureAwait(false); + } + + private static LanguageServerClient GetRequiredClient(LuaLanguageServerIntellisenseProvider provider) + { + FieldInfo field = typeof(LuaLanguageServerIntellisenseProvider).GetField("_client", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("Private field '_client' was not found."); + + return (LanguageServerClient)(field.GetValue(provider) + ?? throw new InvalidOperationException("The live integration test expected a real language-server client instance.")); + } + + private static Process GetRequiredServerProcess(LanguageServerClient client) + { + FieldInfo sessionField = typeof(LanguageServerClient).GetField("_activeSession", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("Private field '_activeSession' was not found."); + + object session = sessionField.GetValue(client) + ?? throw new InvalidOperationException("The live language-server client has no active transport session."); + + PropertyInfo processProperty = session.GetType().GetProperty("Process", BindingFlags.Instance | BindingFlags.Public) + ?? throw new InvalidOperationException("Active transport session property 'Process' was not found."); + + return (Process)(processProperty.GetValue(session) + ?? throw new InvalidOperationException("The active transport session did not expose a live server process.")); + } + + private static int GetRequiredServerProcessId(LuaLanguageServerIntellisenseProvider provider) + => GetRequiredServerProcess(GetRequiredClient(provider)).Id; + + private static async Task> WaitForCompletionItemsAsync( + Func>> action, + Func, bool> predicate, + TimeSpan timeout, + string failureMessage) + { + Stopwatch stopwatch = Stopwatch.StartNew(); + IReadOnlyList lastResult = []; + + while (stopwatch.Elapsed < timeout) + { + lastResult = await action().ConfigureAwait(false); + + if (predicate(lastResult)) + return lastResult; + + await Task.Delay(PollInterval).ConfigureAwait(false); + } + + Assert.Fail(failureMessage + Environment.NewLine + "Last completion labels: " + + string.Join(", ", lastResult.Select(item => item.Label))); + + return []; + } + + private static async Task WaitForDefinitionAsync( + Func> action, + TimeSpan timeout, + string failureMessage) + { + Stopwatch stopwatch = Stopwatch.StartNew(); + TextDefinitionLocation? lastResult = null; + + while (stopwatch.Elapsed < timeout) + { + lastResult = await action().ConfigureAwait(false); + + if (lastResult is not null) + return lastResult; + + await Task.Delay(PollInterval).ConfigureAwait(false); + } + + Assert.Fail(failureMessage); + return null; + } + + private static async Task WaitForHoverAsync( + Func> action, + TimeSpan timeout, + string failureMessage) + { + Stopwatch stopwatch = Stopwatch.StartNew(); + TextHoverInfo? lastResult = null; + + while (stopwatch.Elapsed < timeout) + { + lastResult = await action().ConfigureAwait(false); + + if (lastResult is not null) + return lastResult; + + await Task.Delay(PollInterval).ConfigureAwait(false); + } + + Assert.Fail(failureMessage); + return null; + } + + private static async Task> WaitForReferencesAsync( + Func>> action, + Func, bool> predicate, + TimeSpan timeout, + string failureMessage) + { + Stopwatch stopwatch = Stopwatch.StartNew(); + IReadOnlyList lastResult = []; + + while (stopwatch.Elapsed < timeout) + { + lastResult = await action().ConfigureAwait(false); + + if (predicate(lastResult)) + return lastResult; + + await Task.Delay(PollInterval).ConfigureAwait(false); + } + + Assert.Fail(failureMessage + Environment.NewLine + "Last reference count: " + lastResult.Count); + return []; + } + + private static async Task WaitForWorkspaceEditAsync( + Func> action, + TimeSpan timeout, + string failureMessage) + { + Stopwatch stopwatch = Stopwatch.StartNew(); + TextWorkspaceEdit? lastResult = null; + + while (stopwatch.Elapsed < timeout) + { + lastResult = await action().ConfigureAwait(false); + + if (lastResult?.HasEdits == true) + return lastResult; + + await Task.Delay(PollInterval).ConfigureAwait(false); + } + + Assert.Fail(failureMessage); + return null; + } + + private static async Task WaitForConditionAsync( + Func predicate, + TimeSpan timeout, + string failureMessage) + { + Stopwatch stopwatch = Stopwatch.StartNew(); + + while (stopwatch.Elapsed < timeout) + { + if (predicate()) + return; + + await Task.Delay(PollInterval).ConfigureAwait(false); + } + + Assert.Fail(failureMessage); + } + + private static async Task WaitForProcessExitAsync(int processId, TimeSpan timeout, string failureMessage) + { + Stopwatch stopwatch = Stopwatch.StartNew(); + + while (stopwatch.Elapsed < timeout) + { + if (!TryGetProcess(processId, out Process? process)) + return; + + using (process) + { + if (process.HasExited) + return; + } + + await Task.Delay(PollInterval).ConfigureAwait(false); + } + + Assert.Fail(failureMessage); + } + + private static bool TryGetProcess(int processId, [NotNullWhen(true)] out Process? process) + { + try + { + process = Process.GetProcessById(processId); + return true; + } + catch (ArgumentException) + { + process = null; + return false; + } + } + + private sealed class RealLuaLanguageServerTestSession : IDisposable + { + private readonly string _extractionRoot; + + public RealLuaLanguageServerTestSession() + { + string archivePath = TryFindRepositoryFile(Path.Combine("TombIDE", "TombIDE.Shared", "TIDE", "LuaLS.zip")) + ?? throw new AssertInconclusiveException( + "Could not locate TombIDE/TombIDE.Shared/TIDE/LuaLS.zip from the test output directory. " + + "See Tests/TombLib.LanguageServer.Lua.Tests/Integration/LuaLanguageServerIntegrationTests.md for prerequisites."); + + _extractionRoot = Path.Combine(Path.GetTempPath(), "LuaLsExtract_" + Guid.NewGuid().ToString("N")); + WorkspaceRoot = Path.Combine(Path.GetTempPath(), "LuaLsWorkspace_" + Guid.NewGuid().ToString("N")); + + ZipFile.ExtractToDirectory(archivePath, _extractionRoot); + Directory.CreateDirectory(WorkspaceRoot); + + string executablePath = Path.Combine(_extractionRoot, "bin", "lua-language-server.exe"); + + if (!File.Exists(executablePath)) + { + throw new AssertInconclusiveException( + "The bundled LuaLS archive was found, but bin/lua-language-server.exe was missing after extraction."); + } + + ExecutablePath = executablePath; + } + + public string ExecutablePath { get; } + public string WorkspaceRoot { get; } + + public void Dispose() + { + TryDeleteDirectory(WorkspaceRoot); + TryDeleteDirectory(_extractionRoot); + } + } + + 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) + { } + } +} diff --git a/Tests/TombLib.LanguageServer.Lua.Tests/Integration/README.md b/Tests/TombLib.LanguageServer.Lua.Tests/Integration/README.md new file mode 100644 index 0000000000..ed5a9c0fb0 --- /dev/null +++ b/Tests/TombLib.LanguageServer.Lua.Tests/Integration/README.md @@ -0,0 +1,18 @@ +# Lua Language-Server Integration Tests + +These tests launch the bundled Lua language server from `TombIDE/TombIDE.Shared/TIDE/LuaLS.zip` and run live end-to-end provider scenarios against it. + +Prerequisites: + +- The repository layout must still include `TombIDE/TombIDE.Shared/TIDE/LuaLS.zip` relative to the built test output. +- The extracted archive must contain `bin/lua-language-server.exe`. +- The tests are Windows-oriented because the bundled archive currently provides the Windows executable. + +Runtime expectations: + +- Each integration test can take several seconds because it extracts the LuaLS bundle, launches a real process, waits for diagnostics and semantic-token round-trips, and may simulate a transport crash plus restart. +- Run them as focused integration slices rather than as part of every tight inner-loop unit-test run. + +Skip behavior: + +- When the bundled archive or executable is unavailable, the tests mark themselves inconclusive instead of failing unrelated development environments. \ No newline at end of file diff --git a/Tests/TombLib.LanguageServer.Lua.Tests/Provider/FakeLanguageServerClient.cs b/Tests/TombLib.LanguageServer.Lua.Tests/Provider/FakeLanguageServerClient.cs new file mode 100644 index 0000000000..fd92b05f7f --- /dev/null +++ b/Tests/TombLib.LanguageServer.Lua.Tests/Provider/FakeLanguageServerClient.cs @@ -0,0 +1,479 @@ +using System.Text.Json; + +namespace TombLib.LanguageServer.Lua.Tests; + +internal sealed class FakeLanguageServerClient : ILanguageServerClient +{ + 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? _hoverRequestGate; + private TaskCompletionSource? _openNotificationGate; + private TaskCompletionSource? _startGate; + private TaskCompletionSource? _changeNotificationGate; + private TaskCompletionSource? _semanticTokensFullRequestGate; + private TaskCompletionSource? _watchedFilesNotificationGate; + 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 TextDocumentSyncKind TextDocumentSyncKind { get; set; } = TextDocumentSyncKind.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 SupportsSemanticTokensFull { get; set; } = true; + public bool SupportsSemanticTokensDelta { get; set; } + public bool FailStartWhenCancellationRequested { get; set; } + public bool CancelNextHoverRequestWithoutTimeout { get; set; } + public int StartCallCount { get; private set; } + public int MarkTransportUnhealthyCallCount { get; private set; } + public int DisposeCallCount { get; private set; } + public int TimedOutHoverRequestsRemaining { get; set; } + public int TransportChangedRequestFailuresRemaining { get; set; } + public string? ThrowIOExceptionOnNextRequestMethod { get; set; } + public string? ThrowInvalidOperationOnNextRequestMethod { get; set; } + public bool ThrowIOExceptionOnNextDidChange { get; set; } + public bool ThrowInvalidOperationOnNextWatchedFilesNotification { get; set; } + public bool ThrowIOExceptionOnNextWatchedFilesNotification { get; set; } + public bool ThrowIOExceptionAfterWatchedFilesNotificationGateRelease { get; set; } + public List StartCancellationTokenCanBeCanceled { get; } = []; + + public event Action? DiagnosticsPublished; + + public event Action? SemanticTokensRefreshRequested; + + public async Task StartAsync(CancellationToken cancellationToken) + { + StartCallCount++; + StartCancellationTokenCanBeCanceled.Add(cancellationToken.CanBeCanceled); + + TaskCompletionSource? startGate = _startGate; + + if (startGate is not null) + await startGate.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + + if (FailStartWhenCancellationRequested && cancellationToken.IsCancellationRequested) + throw new OperationCanceledException(cancellationToken); + + IsReady = StartResult; + + if (StartResult) + TransportGeneration++; + + return StartResult; + } + + public void MarkTransportUnhealthy() + { + MarkTransportUnhealthyCallCount++; + IsReady = false; + } + + public bool TryMarkTransportUnhealthy(long transportGeneration) + { + if (transportGeneration != TransportGeneration) + return false; + + MarkTransportUnhealthy(); + return true; + } + + public Task SendNotificationAsync(string method, object parameters, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + return Task.FromCanceled(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."); + } + + TaskCompletionSource? changeNotificationGate = _changeNotificationGate; + + if (changeNotificationGate is not null) + return changeNotificationGate.Task; + } + + if (method == "workspace/didChangeWatchedFiles" && ThrowIOExceptionOnNextWatchedFilesNotification) + { + ThrowIOExceptionOnNextWatchedFilesNotification = false; + throw new IOException("Simulated workspace watcher transport failure."); + } + + if (method == "workspace/didChangeWatchedFiles" && ThrowInvalidOperationOnNextWatchedFilesNotification) + { + ThrowInvalidOperationOnNextWatchedFilesNotification = false; + throw new InvalidOperationException("Simulated unexpected workspace watcher transport failure."); + } + + if (method == "workspace/didChangeWatchedFiles" && _watchedFilesNotificationGate is not null) + return WaitForWatchedFilesNotificationGateAsync(); + + 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 (string.Equals(ThrowIOExceptionOnNextRequestMethod, method, StringComparison.Ordinal)) + { + ThrowIOExceptionOnNextRequestMethod = null; + IsReady = false; + throw new LanguageServerTransportUnavailableException($"Simulated {method} transport failure."); + } + + if (string.Equals(ThrowInvalidOperationOnNextRequestMethod, method, StringComparison.Ordinal)) + { + ThrowInvalidOperationOnNextRequestMethod = null; + throw new InvalidOperationException($"Simulated {method} request failure."); + } + + if (TransportChangedRequestFailuresRemaining > 0) + { + TransportChangedRequestFailuresRemaining--; + IsReady = false; + throw new LanguageServerTransportChangedException(); + } + + if (method == "textDocument/hover") + { + if (_hoverRequestGate is not null) + return WaitForHoverRequestGateAsync(cancellationToken); + + if (CancelNextHoverRequestWithoutTimeout) + { + CancelNextHoverRequestWithoutTimeout = false; + throw new OperationCanceledException("Simulated internal hover cancellation."); + } + + 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 (_semanticTokensFullRequestGate is not null) + return WaitForSemanticTokensFullRequestGateAsync(); + + 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 BlockNextStartAsync() + => _startGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + public void BlockNextHoverRequest() + => _hoverRequestGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + public void ReleaseOpenNotification() + => _openNotificationGate?.TrySetResult(true); + + public void ReleaseStartAsync() + { + TaskCompletionSource? startGate = _startGate; + _startGate = null; + startGate?.TrySetResult(true); + } + + public void ReleaseHoverRequest() + { + TaskCompletionSource? hoverRequestGate = _hoverRequestGate; + _hoverRequestGate = null; + hoverRequestGate?.TrySetResult(true); + } + + public void BlockNextChangeNotification() + => _changeNotificationGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + public void BlockNextWatchedFilesNotification() + => _watchedFilesNotificationGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + public void BlockNextSemanticTokensFullRequest() + => _semanticTokensFullRequestGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + public void ReleaseChangeNotification() + { + TaskCompletionSource? changeNotificationGate = _changeNotificationGate; + _changeNotificationGate = null; + changeNotificationGate?.TrySetResult(true); + } + + public void ReleaseWatchedFilesNotification() + { + TaskCompletionSource? watchedFilesNotificationGate = _watchedFilesNotificationGate; + _watchedFilesNotificationGate = null; + watchedFilesNotificationGate?.TrySetResult(true); + } + + public void ReleaseSemanticTokensFullRequest() + { + TaskCompletionSource? semanticTokensFullRequestGate = _semanticTokensFullRequestGate; + _semanticTokensFullRequestGate = null; + semanticTokensFullRequestGate?.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(PublishDiagnosticsParams 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); + + TResult result = DeserializeResponse(response); + return Task.FromResult(result); + } + + private static TResult DeserializeResponse(JsonElement response) + { + TResult? result = JsonSerializer.Deserialize(response.GetRawText()); + + if (result is null) + throw new InvalidOperationException("Expected a non-null JSON response."); + + return result; + } + + private static TResult CreateDefaultResponse() + => default!; + + private async Task WaitForWatchedFilesNotificationGateAsync() + { + TaskCompletionSource? watchedFilesNotificationGate = _watchedFilesNotificationGate; + + if (watchedFilesNotificationGate is not null) + await watchedFilesNotificationGate.Task.ConfigureAwait(false); + + if (ThrowIOExceptionAfterWatchedFilesNotificationGateRelease) + { + ThrowIOExceptionAfterWatchedFilesNotificationGateRelease = false; + throw new IOException("Simulated delayed workspace watcher transport failure."); + } + } + + private static async Task WaitForCancellationAsync(CancellationToken cancellationToken) + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); + return CreateDefaultResponse(); + } + + private async Task WaitForSemanticTokensFullRequestGateAsync() + { + TaskCompletionSource? semanticTokensFullRequestGate = _semanticTokensFullRequestGate; + + if (semanticTokensFullRequestGate is not null) + await semanticTokensFullRequestGate.Task.ConfigureAwait(false); + + if (_semanticTokensFullResponses.Count > 0) + return DeserializeResponse(_semanticTokensFullResponses.Dequeue()); + + return DeserializeResponse(JsonSerializer.SerializeToElement(new + { + data = new[] { 0, 6, 5, 0, 0 }, + resultId = "tokens-1" + })); + } + + private async Task WaitForHoverRequestGateAsync(CancellationToken cancellationToken) + { + TaskCompletionSource? hoverRequestGate = _hoverRequestGate; + + if (hoverRequestGate is not null) + await hoverRequestGate.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + + if (HoverResponse.ValueKind != JsonValueKind.Undefined) + return DeserializeResponse(HoverResponse); + + return CreateDefaultResponse(); + } + + public void Dispose() + => DisposeCallCount++; + + public ValueTask DisposeAsync() + => ValueTask.CompletedTask; +} diff --git a/Tests/TombLib.LanguageServer.Lua.Tests/Provider/LuaLanguageServerIntellisenseProviderTestAccess.cs b/Tests/TombLib.LanguageServer.Lua.Tests/Provider/LuaLanguageServerIntellisenseProviderTestAccess.cs new file mode 100644 index 0000000000..d28b1c810b --- /dev/null +++ b/Tests/TombLib.LanguageServer.Lua.Tests/Provider/LuaLanguageServerIntellisenseProviderTestAccess.cs @@ -0,0 +1,60 @@ +using System.Reflection; + +namespace TombLib.LanguageServer.Lua.Tests; + +internal static class LuaLanguageServerIntellisenseProviderTestAccess +{ + public static Task DispatchWorkspaceFileChangesAsync( + LuaLanguageServerIntellisenseProvider provider, + FileChangeBatch batch, + CancellationToken cancellationToken) + => GetWorkspaceChangeCoordinator(provider).DispatchWorkspaceFileChangesAsync(batch, cancellationToken); + + public static WorkspaceFileWatcher? GetWorkspaceWatcher(LuaLanguageServerIntellisenseProvider provider) + { + LuaWorkspaceChangeCoordinator coordinator = GetWorkspaceChangeCoordinator(provider); + + PropertyInfo currentWatcherProperty = coordinator.GetType().GetProperty("CurrentWatcher", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("Property 'CurrentWatcher' was not found on the workspace change coordinator."); + + return currentWatcherProperty.GetValue(coordinator) as WorkspaceFileWatcher; + } + + public static SemaphoreSlim GetProviderStartLock(LuaLanguageServerIntellisenseProvider provider) + { + FieldInfo field = typeof(LuaLanguageServerIntellisenseProvider).GetField("_startLock", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("Private field '_startLock' was not found."); + + return (SemaphoreSlim)(field.GetValue(provider) + ?? throw new InvalidOperationException("Provider start lock was null.")); + } + + public static CancellationTokenSource GetProviderDisposeCancellationTokenSource(LuaLanguageServerIntellisenseProvider provider) + { + FieldInfo field = typeof(LuaLanguageServerIntellisenseProvider).GetField("_disposeCts", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("Private field '_disposeCts' was not found."); + + return (CancellationTokenSource)(field.GetValue(provider) + ?? throw new InvalidOperationException("Provider dispose token source was null.")); + } + + public static int GetTrackedDocumentCount(LuaLanguageServerIntellisenseProvider provider) + { + FieldInfo field = typeof(LuaLanguageServerIntellisenseProvider).GetField("_documents", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("Private field '_documents' was not found."); + + var documentStore = (LuaDocumentStore)(field.GetValue(provider) + ?? throw new InvalidOperationException("Provider document store was null.")); + + return documentStore.TrackedDocumentCount; + } + + public static LuaWorkspaceChangeCoordinator GetWorkspaceChangeCoordinator(LuaLanguageServerIntellisenseProvider provider) + { + FieldInfo coordinatorField = typeof(LuaLanguageServerIntellisenseProvider).GetField("_workspaceChanges", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("Private field '_workspaceChanges' was not found."); + + return (LuaWorkspaceChangeCoordinator)(coordinatorField.GetValue(provider) + ?? throw new InvalidOperationException("Provider workspace change coordinator was null.")); + } +} diff --git a/Tests/TombLib.LanguageServer.Lua.Tests/Provider/LuaLanguageServerIntellisenseProviderTests.DocumentSynchronization.cs b/Tests/TombLib.LanguageServer.Lua.Tests/Provider/LuaLanguageServerIntellisenseProviderTests.DocumentSynchronization.cs new file mode 100644 index 0000000000..389e490b30 --- /dev/null +++ b/Tests/TombLib.LanguageServer.Lua.Tests/Provider/LuaLanguageServerIntellisenseProviderTests.DocumentSynchronization.cs @@ -0,0 +1,964 @@ +using System.Text.Json; +using TombLib.Scripting.Completion; +using TombLib.Scripting.Diagnostics; +using TombLib.Scripting.Core.Lua; +using TombLib.Scripting.Hover; + +namespace TombLib.LanguageServer.Lua.Tests; + +public partial class LuaLanguageServerIntellisenseProviderTests +{ + [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 FakeLanguageServerClient + { + SupportsSemanticTokensFull = true, + 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 FakeLanguageServerClient(); + 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 RenameDocument_UpdateOnNewPath_WaitsForRenameReopenToFinish() + { + const string workspaceRoot = @"C:\Workspace"; + const string oldFilePath = @"C:\Workspace\Scripts\test.lua"; + const string newFilePath = @"C:\Workspace\Scripts\renamed.lua"; + const string originalContent = "local value = 1"; + const string updatedContent = "local value = 2"; + + using var client = new FakeLanguageServerClient + { + SupportsSemanticTokensFull = false + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + provider.OpenDocument(oldFilePath, originalContent); + Assert.IsTrue(await client.WaitForMethodCountAsync("textDocument/didOpen", 1, TimeSpan.FromSeconds(1))); + + client.BlockNextOpenNotification(); + + provider.RenameDocument(oldFilePath, newFilePath, originalContent); + + Assert.IsTrue(await client.WaitForMethodCountAsync("textDocument/didClose", 1, TimeSpan.FromSeconds(1))); + Assert.IsTrue(await client.WaitForMethodCountAsync("textDocument/didOpen", 2, TimeSpan.FromSeconds(1))); + + provider.UpdateDocument(newFilePath, updatedContent); + + Assert.IsFalse(await client.WaitForMethodCountAsync("textDocument/didChange", 1, TimeSpan.FromMilliseconds(250))); + + client.ReleaseOpenNotification(); + + Assert.IsTrue(await client.WaitForMethodCountAsync("textDocument/didChange", 1, TimeSpan.FromSeconds(1))); + + CollectionAssert.AreEqual( + new[] + { + "textDocument/didOpen", + "textDocument/didClose", + "textDocument/didOpen", + "textDocument/didChange" + }, + client.GetSentMethodNames()); + } + + [TestMethod] + public async Task RenameDocument_DisposeDuringBlockedRenameReopen_DoesNotRaiseMovedDiagnosticsUpdated() + { + 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 FakeLanguageServerClient + { + SupportsSemanticTokensFull = false + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + int movedDiagnosticsUpdatedCount = 0; + + provider.DiagnosticsUpdated += (filePath, _) => + { + if (string.Equals(filePath, newFilePath, StringComparison.OrdinalIgnoreCase)) + movedDiagnosticsUpdatedCount++; + }; + + provider.OpenDocument(oldFilePath, content); + Assert.IsTrue(await client.WaitForMethodCountAsync("textDocument/didOpen", 1, TimeSpan.FromSeconds(1)).ConfigureAwait(false)); + + client.PublishDiagnostics(CreateDiagnostics(oldFilePath, 1, 6, 11, "Current warning.")); + Assert.AreEqual(1, provider.GetDiagnostics(oldFilePath).Count); + + client.BlockNextOpenNotification(); + provider.RenameDocument(oldFilePath, newFilePath, content); + + Assert.IsTrue(await client.WaitForMethodCountAsync("textDocument/didClose", 1, TimeSpan.FromSeconds(1)).ConfigureAwait(false)); + Assert.IsTrue(await client.WaitForMethodCountAsync("textDocument/didOpen", 2, TimeSpan.FromSeconds(1)).ConfigureAwait(false)); + + provider.Dispose(); + client.ReleaseOpenNotification(); + + await Task.Delay(250).ConfigureAwait(false); + + Assert.AreEqual(0, movedDiagnosticsUpdatedCount); + } + + [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 FakeLanguageServerClient + { + 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); + + TextHoverInfo? firstHover = await provider.GetHoverAsync(filePath, content, 0, 0); + TextHoverInfo? secondHover = await provider.GetHoverAsync(filePath, content, 0, 0); + TextHoverInfo? 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; + + TextHoverInfo? 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_TimeoutFromSupersededGeneration_DoesNotInvalidateReplacementTransport() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + const string content = "local value = 1"; + + using var client = new FakeLanguageServerClient + { + TimedOutHoverRequestsRemaining = 1, + HoverResponse = JsonSerializer.SerializeToElement(new + { + contents = new + { + kind = "markdown", + value = "Hover docs." + } + }) + }; + + using var provider = new LuaLanguageServerIntellisenseProvider( + workspaceRoot, + client, + requestTimeout: TimeSpan.FromMilliseconds(200), + requestTimeoutRestartThreshold: 1); + + Task timedOutHoverTask = provider.GetHoverAsync(filePath, content, 0, 0); + + Assert.IsTrue(await client.WaitForMethodCountAsync("textDocument/hover", 1, TimeSpan.FromSeconds(1))); + + client.MarkTransportUnhealthy(); + + TextHoverInfo? restartedHover = await provider.GetHoverAsync(filePath, content, 0, 0); + TextHoverInfo? timedOutHover = await timedOutHoverTask; + TextHoverInfo? thirdHover = await provider.GetHoverAsync(filePath, content, 0, 0); + + Assert.IsNotNull(restartedHover); + Assert.AreEqual("Hover docs.", restartedHover.Content); + Assert.IsNull(timedOutHover); + Assert.IsNotNull(thirdHover); + Assert.AreEqual("Hover docs.", thirdHover.Content); + Assert.IsTrue(client.IsReady); + Assert.AreEqual(2, client.StartCallCount); + + CollectionAssert.AreEqual( + new[] + { + "textDocument/didOpen", + "textDocument/hover", + "textDocument/didOpen", + "textDocument/hover", + "textDocument/hover" + }, + client.GetSentMethodNames()); + } + + [TestMethod] + public async Task GetHoverAsync_RequestCancellationAfterConnectionDrop_DoesNotForceRestartAttempt() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + const string content = "local value = 1"; + + using var client = new FakeLanguageServerClient + { + 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(); + + TextHoverInfo? hover = await provider.GetHoverAsync(filePath, content, 0, 0, cancellationTokenSource.Token); + + Assert.IsNull(hover); + Assert.AreEqual(1, client.StartCallCount); + Assert.AreEqual(1, client.StartCancellationTokenCanBeCanceled.Count); + } + + [TestMethod] + public async Task GetHoverAsync_InternalRequestCancellation_DoesNotCountAsTimeoutOrRestart() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + const string content = "local value = 1"; + + using var client = new FakeLanguageServerClient + { + CancelNextHoverRequestWithoutTimeout = true, + HoverResponse = JsonSerializer.SerializeToElement(new + { + contents = new + { + kind = "markdown", + value = "Hover docs." + } + }) + }; + + using var provider = new LuaLanguageServerIntellisenseProvider( + workspaceRoot, + client, + requestTimeout: TimeSpan.FromMilliseconds(50), + requestTimeoutRestartThreshold: 1); + + TextHoverInfo? canceledHover = await provider.GetHoverAsync(filePath, content, 0, 0); + TextHoverInfo? recoveredHover = await provider.GetHoverAsync(filePath, content, 0, 0); + + Assert.IsNull(canceledHover); + Assert.IsNotNull(recoveredHover); + Assert.AreEqual("Hover docs.", recoveredHover.Content); + Assert.AreEqual(0, client.MarkTransportUnhealthyCallCount); + Assert.AreEqual(1, client.StartCallCount); + + CollectionAssert.AreEqual( + new[] + { + "textDocument/didOpen", + "textDocument/hover", + "textDocument/hover" + }, + client.GetSentMethodNames()); + } + + [TestMethod] + public async Task GetHoverAsync_UserCancellation_DoesNotBlockClosingOpenDocument() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + const string content = "local value = 1"; + + using var client = new FakeLanguageServerClient + { + HoverResponse = JsonSerializer.SerializeToElement(new + { + contents = new + { + kind = "markdown", + value = "Hover docs." + } + }) + }; + + client.BlockNextHoverRequest(); + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + using var cancellationTokenSource = new CancellationTokenSource(); + + provider.OpenDocument(filePath, content); + Assert.IsTrue(await client.WaitForMethodCountAsync("textDocument/didOpen", 1, TimeSpan.FromSeconds(1)).ConfigureAwait(false)); + + Task hoverTask = provider.GetHoverAsync(filePath, content, 0, 0, cancellationTokenSource.Token); + + Assert.IsTrue(await client.WaitForMethodCountAsync("textDocument/hover", 1, TimeSpan.FromSeconds(1)).ConfigureAwait(false)); + + cancellationTokenSource.Cancel(); + + await Assert.ThrowsExceptionAsync(() => hoverTask).ConfigureAwait(false); + + provider.CloseDocument(filePath); + Assert.IsTrue(await client.WaitForMethodCountAsync("textDocument/didClose", 1, TimeSpan.FromSeconds(1)).ConfigureAwait(false)); + + Assert.AreEqual(0, GetTrackedDocumentCount(provider)); + } + + [TestMethod] + public async Task UpdateDocument_SendsFullTextChangeWhenServerAdvertisesFullSync() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + + using var client = new FakeLanguageServerClient + { + TextDocumentSyncKind = TextDocumentSyncKind.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 FakeLanguageServerClient(); + 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_CoalescesSupersededQueuedChanges() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + + using var client = new FakeLanguageServerClient + { + TextDocumentSyncKind = TextDocumentSyncKind.Full + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + provider.OpenDocument(filePath, "local value = 1"); + Assert.IsTrue(await client.WaitForMethodCountAsync("textDocument/didOpen", 1, TimeSpan.FromSeconds(1))); + + client.BlockNextChangeNotification(); + + provider.UpdateDocument(filePath, "local value = 2"); + Assert.IsTrue(await client.WaitForMethodCountAsync("textDocument/didChange", 1, TimeSpan.FromSeconds(1))); + + provider.UpdateDocument(filePath, "local value = 3"); + provider.UpdateDocument(filePath, "local value = 4"); + + client.ReleaseChangeNotification(); + + Assert.IsTrue(await client.WaitForMethodCountAsync("textDocument/didChange", 2, TimeSpan.FromSeconds(1))); + Assert.IsFalse(await client.WaitForMethodCountAsync("textDocument/didChange", 3, TimeSpan.FromMilliseconds(250))); + + JsonElement parameters = client.GetLastNotificationParameters("textDocument/didChange"); + JsonElement change = parameters.GetProperty("contentChanges")[0]; + + Assert.AreEqual("local value = 4", change.GetProperty("text").GetString()); + + CollectionAssert.AreEqual( + new[] { "textDocument/didOpen", "textDocument/didChange", "textDocument/didChange" }, + client.GetSentMethodNames()); + } + + [TestMethod] + public async Task UpdateDocument_BlockedChangeOnOneFile_DoesNotStallOtherFileHover() + { + const string workspaceRoot = @"C:\Workspace"; + const string firstFilePath = @"C:\Workspace\Scripts\first.lua"; + const string secondFilePath = @"C:\Workspace\Scripts\second.lua"; + + using var client = new FakeLanguageServerClient + { + HoverResponse = JsonSerializer.SerializeToElement(new + { + contents = new + { + kind = "markdown", + value = "Hover docs." + } + }) + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + provider.OpenDocument(firstFilePath, "local first = 1"); + Assert.IsTrue(await client.WaitForMethodCountAsync("textDocument/didOpen", 1, TimeSpan.FromSeconds(1))); + + client.BlockNextChangeNotification(); + provider.UpdateDocument(firstFilePath, "local first = 2"); + Assert.IsTrue(await client.WaitForMethodCountAsync("textDocument/didChange", 1, TimeSpan.FromSeconds(1))); + + Task hoverTask = provider.GetHoverAsync(secondFilePath, "local second = 1", 0, 0); + Task completedTask = await Task.WhenAny(hoverTask, Task.Delay(TimeSpan.FromSeconds(1))).ConfigureAwait(false); + + Assert.AreSame(hoverTask, completedTask); + Assert.IsNotNull(await hoverTask.ConfigureAwait(false)); + Assert.AreEqual(2, CountSentMethods(client, "textDocument/didOpen")); + Assert.AreEqual(1, CountSentMethods(client, "textDocument/hover")); + + client.ReleaseChangeNotification(); + Assert.IsTrue(await client.WaitForMethodCountAsync("textDocument/didChange", 1, TimeSpan.FromSeconds(1))); + } + + [TestMethod] + public async Task UpdateDocument_WithUnchangedContentAfterTransportFailure_ReopensWithFullSemanticTokensRefresh() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + + using var client = new FakeLanguageServerClient + { + SupportsSemanticTokensFull = true, + 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 FakeLanguageServerClient(); + 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 OpenDocument_DuringStartupFailure_ReplaysTrackedDocumentAfterRecovery() + { + const string workspaceRoot = @"C:\Workspace"; + const string openedFilePath = @"C:\Workspace\Scripts\opened.lua"; + const string requestFilePath = @"C:\Workspace\Scripts\request.lua"; + const string openedContent = "local opened = 1"; + + using var client = new FakeLanguageServerClient + { + StartResult = false, + HoverResponse = JsonSerializer.SerializeToElement(new + { + contents = new + { + kind = "markdown", + value = "Hover docs." + } + }) + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + provider.OpenDocument(openedFilePath, openedContent); + + await Task.Delay(100).ConfigureAwait(false); + + Assert.AreEqual(1, client.StartCallCount); + Assert.AreEqual(0, CountSentMethods(client, "textDocument/didOpen")); + Assert.AreEqual(1, GetTrackedDocumentCount(provider)); + + client.StartResult = true; + + TextHoverInfo? hover = await provider.GetHoverAsync(requestFilePath, "local request = 1", 0, 0); + + Assert.IsNotNull(hover); + Assert.AreEqual(2, client.StartCallCount); + + CollectionAssert.AreEqual( + new[] + { + "textDocument/didOpen", + "textDocument/didOpen", + "textDocument/hover" + }, + client.GetSentMethodNames()); + + JsonElement firstDidOpen = client.GetLastNotificationParameters("textDocument/didOpen"); + Assert.AreEqual(new Uri(requestFilePath).AbsoluteUri, firstDidOpen.GetProperty("textDocument").GetProperty("uri").GetString()); + } + + [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 FakeLanguageServerClient + { + 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; + + TextHoverInfo? failedHover = await provider.GetHoverAsync(firstFilePath, firstContent, 0, 0); + + Assert.IsNull(failedHover); + Assert.AreEqual(2, client.StartCallCount); + + client.StartResult = true; + + TextHoverInfo? 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 FakeLanguageServerClient(); + 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_WithoutVersion_StoresFallbackDiagnosticsForTrackedDocument() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + const string content = "local value = 1"; + + using var client = new FakeLanguageServerClient(); + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + int diagnosticsUpdatedCount = 0; + + provider.DiagnosticsUpdated += (_, _) => diagnosticsUpdatedCount++; + + await provider.GetHoverAsync(filePath, content, 0, 0); + + client.PublishDiagnostics(CreateDiagnostics(filePath, version: null, 6, 11, "Fallback warning.")); + + IReadOnlyList diagnostics = provider.GetDiagnostics(filePath); + + Assert.AreEqual(1, diagnosticsUpdatedCount); + Assert.AreEqual(1, diagnostics.Count); + Assert.AreEqual(TextEditorDiagnosticSeverity.Warning, diagnostics[0].Severity); + Assert.AreEqual(6, diagnostics[0].StartOffset); + Assert.AreEqual(11, 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 FakeLanguageServerClient(); + 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 DiagnosticsPublished_DisposeInFirstSubscriber_DoesNotNotifyLaterSubscribers() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + const string content = "local value = 1"; + + using var client = new FakeLanguageServerClient(); + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + int firstSubscriberCalls = 0; + int secondSubscriberCalls = 0; + + provider.DiagnosticsUpdated += (_, _) => + { + firstSubscriberCalls++; + provider.Dispose(); + }; + + provider.DiagnosticsUpdated += (_, _) => secondSubscriberCalls++; + + await provider.GetHoverAsync(filePath, content, 0, 0); + client.PublishDiagnostics(CreateDiagnostics(filePath, 1, 6, 12, "Current warning.")); + + Assert.AreEqual(1, firstSubscriberCalls); + Assert.AreEqual(0, secondSubscriberCalls); + } + + [TestMethod] + public async Task UpdateDocument_WaitsForEarlierOpenNotificationToFinish() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + + using var client = new FakeLanguageServerClient(); + 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 UpdateDocument_DisposeBeforeQueuedLatestUpdateRuns_DoesNotSendLateDidChange() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + + using var client = new FakeLanguageServerClient(); + 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)).ConfigureAwait(false)); + + provider.Dispose(); + client.ReleaseOpenNotification(); + + Assert.IsFalse(await client.WaitForNotificationAsync("textDocument/didChange", TimeSpan.FromMilliseconds(250)).ConfigureAwait(false)); + } + + [TestMethod] + public async Task GetHoverAsync_ReopensDocumentAfterIncrementalChangeTransportFailure() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + + using var client = new FakeLanguageServerClient + { + 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 FakeLanguageServerClient(); + 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 FakeLanguageServerClient(); + 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()); + } +} diff --git a/Tests/TombLib.LanguageServer.Lua.Tests/Provider/LuaLanguageServerIntellisenseProviderTests.Support.cs b/Tests/TombLib.LanguageServer.Lua.Tests/Provider/LuaLanguageServerIntellisenseProviderTests.Support.cs new file mode 100644 index 0000000000..cc3edf8dfd --- /dev/null +++ b/Tests/TombLib.LanguageServer.Lua.Tests/Provider/LuaLanguageServerIntellisenseProviderTests.Support.cs @@ -0,0 +1,62 @@ +using System.Reflection; + +namespace TombLib.LanguageServer.Lua.Tests; + +public partial class LuaLanguageServerIntellisenseProviderTests +{ + private static PublishDiagnosticsParams CreateDiagnostics(string filePath, int? version, int startCharacter, int endCharacter, string message) => new( + new Uri(filePath).AbsoluteUri, + version, + [ + new DiagnosticPayload( + new ProtocolRangePayload( + new ProtocolNullablePosition(0, startCharacter), + new ProtocolNullablePosition(0, endCharacter)), + 2, + message, + null, + null) + ]); + + private static WorkspaceFileWatcher? GetWorkspaceWatcher(LuaLanguageServerIntellisenseProvider provider) + => LuaLanguageServerIntellisenseProviderTestAccess.GetWorkspaceWatcher(provider); + + private static SemaphoreSlim GetProviderStartLock(LuaLanguageServerIntellisenseProvider provider) + => LuaLanguageServerIntellisenseProviderTestAccess.GetProviderStartLock(provider); + + private static CancellationTokenSource GetProviderDisposeCancellationTokenSource(LuaLanguageServerIntellisenseProvider provider) + => LuaLanguageServerIntellisenseProviderTestAccess.GetProviderDisposeCancellationTokenSource(provider); + + private static int GetTrackedDocumentCount(LuaLanguageServerIntellisenseProvider provider) + => LuaLanguageServerIntellisenseProviderTestAccess.GetTrackedDocumentCount(provider); + + private static int CountSentMethods(FakeLanguageServerClient client, string method) + { + int count = 0; + string[] methods = client.GetSentMethodNames(); + + for (int i = 0; i < methods.Length; i++) + { + if (string.Equals(methods[i], method, StringComparison.Ordinal)) + count++; + } + + return count; + } + + private static T 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."); + + object? result = method.Invoke(instance, parameters); + + if (result is T typedResult) + return typedResult; + + throw new InvalidOperationException($"Private method '{methodName}' returned '{result?.GetType().FullName ?? "null"}' instead of '{typeof(T).FullName}'."); + } + + private static Task DispatchWorkspaceFileChangesAsync(LuaLanguageServerIntellisenseProvider provider, FileChangeBatch batch, CancellationToken cancellationToken) + => LuaLanguageServerIntellisenseProviderTestAccess.DispatchWorkspaceFileChangesAsync(provider, batch, cancellationToken); +} diff --git a/Tests/TombLib.LanguageServer.Lua.Tests/Provider/LuaLanguageServerIntellisenseProviderTests.WatcherAndSemanticTokens.cs b/Tests/TombLib.LanguageServer.Lua.Tests/Provider/LuaLanguageServerIntellisenseProviderTests.WatcherAndSemanticTokens.cs new file mode 100644 index 0000000000..35e3d3c384 --- /dev/null +++ b/Tests/TombLib.LanguageServer.Lua.Tests/Provider/LuaLanguageServerIntellisenseProviderTests.WatcherAndSemanticTokens.cs @@ -0,0 +1,904 @@ +using System.Text.Json; +using TombLib.Scripting.Core.Lua; + +namespace TombLib.LanguageServer.Lua.Tests; + +public partial class LuaLanguageServerIntellisenseProviderTests +{ + [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 FakeLanguageServerClient(); + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + var failures = new List(); + + provider.WorkspaceWatcherFailed += failure => failures.Add(failure); + + await provider.GetHoverAsync(filePath, content, 0, 0); + + Assert.IsNull(GetWorkspaceWatcher(provider)); + Assert.AreEqual(0, failures.Count); + + Directory.CreateDirectory(workspaceRoot); + + await provider.GetHoverAsync(filePath, content, 0, 0); + + Assert.IsNotNull(GetWorkspaceWatcher(provider)); + Assert.AreEqual(0, failures.Count); + } + finally + { + if (Directory.Exists(workspaceRoot)) + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [TestMethod] + public async Task GetHoverAsync_WatcherStartupFailure_RaisesWorkspaceWatcherFailureOnce() + { + string workspaceRoot = Path.Combine(Path.GetTempPath(), "LuaWatcherStartupFailure_" + 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 FakeLanguageServerClient(); + + using var provider = new LuaLanguageServerIntellisenseProvider( + workspaceRoot, + client, + workspaceFileWatcherFactory: (rootPath, dispatchAsync, watcherFailed) => new WorkspaceFileWatcher( + rootPath, + dispatchAsync, + LuaLanguageServerIntellisenseProvider.WorkspaceWatchSpecifications, + watcherFailed, + static (_, _) => throw new InvalidOperationException("Simulated watcher creation failure."))); + + var failures = new List(); + + provider.WorkspaceWatcherFailed += failures.Add; + + await provider.GetHoverAsync(filePath, content, 0, 0); + await provider.GetHoverAsync(filePath, content, 0, 0); + + Assert.IsNull(GetWorkspaceWatcher(provider)); + Assert.AreEqual(1, failures.Count); + Assert.IsTrue(failures[0].Message.Contains("could not be started", StringComparison.OrdinalIgnoreCase)); + } + finally + { + if (Directory.Exists(workspaceRoot)) + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [TestMethod] + public async Task WorkspaceWatcherFailure_AutomaticallyRestartsWithoutRaisingFailure() + { + 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 FakeLanguageServerClient(); + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + var failures = new List(); + + provider.WorkspaceWatcherFailed += failures.Add; + + await provider.GetHoverAsync(filePath, content, 0, 0); + + WorkspaceFileWatcher watcher = GetWorkspaceWatcher(provider) + ?? throw new AssertFailedException("Expected the workspace watcher to start."); + + Assert.IsTrue(watcher.HasActiveWatchers); + +#pragma warning disable CS0618 + watcher.ReportErrorForTest(new IOException("Simulated watcher failure.")); +#pragma warning restore CS0618 + + WorkspaceFileWatcher replacementWatcher = GetWorkspaceWatcher(provider) + ?? throw new AssertFailedException("Expected the workspace watcher to restart."); + + Assert.AreNotSame(watcher, replacementWatcher); + Assert.IsFalse(watcher.HasActiveWatchers); + Assert.IsTrue(replacementWatcher.HasActiveWatchers); + Assert.AreEqual(0, failures.Count); + } + finally + { + if (Directory.Exists(workspaceRoot)) + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [TestMethod] + public async Task WorkspaceWatcherFailure_WhenReplacementRestartFails_DisposesBothWatchersAndRaisesFailure() + { + 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 FakeLanguageServerClient(); + var createdWatchers = new List(); + int watcherCreationCount = 0; + + using var provider = new LuaLanguageServerIntellisenseProvider( + workspaceRoot, + client, + workspaceFileWatcherFactory: (rootPath, dispatchAsync, watcherFailed) => + { + var watcher = new WorkspaceFileWatcher( + rootPath, + dispatchAsync, + LuaLanguageServerIntellisenseProvider.WorkspaceWatchSpecifications, + watcherFailed, + watcherCreationCount++ == 0 + ? null + : static (_, _) => throw new InvalidOperationException("Simulated watcher creation failure.")); + createdWatchers.Add(watcher); + return watcher; + }); + + var failures = new List(); + provider.WorkspaceWatcherFailed += failures.Add; + + await provider.GetHoverAsync(filePath, content, 0, 0); + + WorkspaceFileWatcher watcher = GetWorkspaceWatcher(provider) + ?? throw new AssertFailedException("Expected the workspace watcher to start."); + +#pragma warning disable CS0618 + watcher.ReportErrorForTest(new IOException("Simulated watcher failure.")); +#pragma warning restore CS0618 + + Assert.AreEqual(2, createdWatchers.Count); + Assert.IsTrue(createdWatchers[0].IsDisposed); + Assert.IsTrue(createdWatchers[1].IsDisposed); + Assert.IsFalse(createdWatchers[0].HasActiveWatchers); + Assert.IsFalse(createdWatchers[1].HasActiveWatchers); + Assert.IsNull(GetWorkspaceWatcher(provider)); + Assert.AreEqual(1, failures.Count); + } + finally + { + if (Directory.Exists(workspaceRoot)) + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [TestMethod] + public async Task WorkspaceWatcherFailure_RepeatedAutomaticRestartsContinueWithoutRaisingFailure() + { + string workspaceRoot = Path.Combine(Path.GetTempPath(), "LuaWatcherRepeatedFailure_" + 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 FakeLanguageServerClient(); + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + var failures = new List(); + + provider.WorkspaceWatcherFailed += failures.Add; + + await provider.GetHoverAsync(filePath, content, 0, 0); + + WorkspaceFileWatcher firstWatcher = GetWorkspaceWatcher(provider) + ?? throw new AssertFailedException("Expected the initial workspace watcher to start."); + +#pragma warning disable CS0618 + firstWatcher.ReportErrorForTest(new IOException("Simulated watcher failure 1.")); +#pragma warning restore CS0618 + + WorkspaceFileWatcher secondWatcher = GetWorkspaceWatcher(provider) + ?? throw new AssertFailedException("Expected the first replacement watcher to start."); + +#pragma warning disable CS0618 + secondWatcher.ReportErrorForTest(new IOException("Simulated watcher failure 2.")); +#pragma warning restore CS0618 + + WorkspaceFileWatcher thirdWatcher = GetWorkspaceWatcher(provider) + ?? throw new AssertFailedException("Expected the second replacement watcher to start."); + + Assert.AreNotSame(firstWatcher, secondWatcher); + Assert.AreNotSame(secondWatcher, thirdWatcher); + Assert.IsFalse(firstWatcher.HasActiveWatchers); + Assert.IsFalse(secondWatcher.HasActiveWatchers); + Assert.IsTrue(thirdWatcher.HasActiveWatchers); + Assert.AreEqual(0, failures.Count); + } + finally + { + if (Directory.Exists(workspaceRoot)) + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [TestMethod] + public async Task WorkspaceWatcherRecovery_ReconcilesLuaFilesCreatedWhileWatcherWasDown() + { + string workspaceRoot = Path.Combine(Path.GetTempPath(), "LuaWatcherRecoveryCreate_" + Guid.NewGuid().ToString("N")); + string filePath = Path.Combine(workspaceRoot, "Scripts", "test.lua"); + string missedFilePath = Path.Combine(workspaceRoot, "Scripts", "missed.lua"); + const string content = "local value = 1"; + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(filePath) ?? workspaceRoot); + + using var client = new FakeLanguageServerClient(); + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + await provider.GetHoverAsync(filePath, content, 0, 0); + + WorkspaceFileWatcher watcher = GetWorkspaceWatcher(provider) + ?? throw new AssertFailedException("Expected the workspace watcher to start."); + + watcher.Dispose(); + File.WriteAllText(missedFilePath, "return 1"); + + bool recovered = InvokePrivateMethodWithReturn(LuaLanguageServerIntellisenseProviderTestAccess.GetWorkspaceChangeCoordinator(provider), "TryRestartWorkspaceFileWatcher", watcher); + + Assert.IsTrue(recovered); + Assert.IsTrue(await client.WaitForMethodCountAsync("workspace/didChangeWatchedFiles", 1, TimeSpan.FromSeconds(1))); + + JsonElement changes = client.GetLastNotificationParameters("workspace/didChangeWatchedFiles").GetProperty("changes"); + Assert.AreEqual(1, changes.GetArrayLength()); + Assert.AreEqual(new Uri(missedFilePath).AbsoluteUri, changes[0].GetProperty("uri").GetString()); + Assert.AreEqual((int)FileChangeKind.Created, changes[0].GetProperty("type").GetInt32()); + } + finally + { + if (Directory.Exists(workspaceRoot)) + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [TestMethod] + public async Task WorkspaceWatcherRecovery_ReplaysConfigurationRefreshForMissedWorkspaceConfigChanges() + { + string workspaceRoot = Path.Combine(Path.GetTempPath(), "LuaWatcherRecoveryConfig_" + Guid.NewGuid().ToString("N")); + string filePath = Path.Combine(workspaceRoot, "Scripts", "test.lua"); + string configFilePath = Path.Combine(workspaceRoot, ".luarc.json"); + const string content = "local value = 1"; + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(filePath) ?? workspaceRoot); + + using var client = new FakeLanguageServerClient(); + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + await provider.GetHoverAsync(filePath, content, 0, 0); + + WorkspaceFileWatcher watcher = GetWorkspaceWatcher(provider) + ?? throw new AssertFailedException("Expected the workspace watcher to start."); + + watcher.Dispose(); + File.WriteAllText(configFilePath, "{\"Lua.workspace.maxPreload\": 1000}"); + + bool recovered = InvokePrivateMethodWithReturn(LuaLanguageServerIntellisenseProviderTestAccess.GetWorkspaceChangeCoordinator(provider), "TryRestartWorkspaceFileWatcher", watcher); + + Assert.IsTrue(recovered); + Assert.IsTrue(await client.WaitForMethodCountAsync("workspace/didChangeConfiguration", 1, TimeSpan.FromSeconds(1))); + Assert.IsTrue(await client.WaitForMethodCountAsync("workspace/didChangeWatchedFiles", 1, TimeSpan.FromSeconds(1))); + + string[] sentMethods = client.GetSentMethodNames(); + + CollectionAssert.AreEqual( + new[] { "textDocument/didOpen", "textDocument/hover", "workspace/didChangeConfiguration", "workspace/didChangeWatchedFiles" }, + sentMethods); + + JsonElement changes = client.GetLastNotificationParameters("workspace/didChangeWatchedFiles").GetProperty("changes"); + Assert.AreEqual(1, changes.GetArrayLength()); + Assert.AreEqual(new Uri(configFilePath).AbsoluteUri, changes[0].GetProperty("uri").GetString()); + Assert.AreEqual((int)FileChangeKind.Created, changes[0].GetProperty("type").GetInt32()); + } + finally + { + if (Directory.Exists(workspaceRoot)) + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [TestMethod] + public async Task WorkspaceWatcherRecovery_DoesNotReplayChangesAlreadyForwardedBeforeTheOutage() + { + string workspaceRoot = Path.Combine(Path.GetTempPath(), "LuaWatcherRecoveryDuplicate_" + Guid.NewGuid().ToString("N")); + string filePath = Path.Combine(workspaceRoot, "Scripts", "test.lua"); + const string content = "local value = 1"; + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(filePath) ?? workspaceRoot); + File.WriteAllText(filePath, content); + + using var client = new FakeLanguageServerClient(); + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + await provider.GetHoverAsync(filePath, content, 0, 0); + + File.WriteAllText(filePath, content + Environment.NewLine + "return value"); + + var batch = new FileChangeBatch( + [ + new WorkspaceFileChange(filePath, FileChangeKind.Changed) + ]); + + await DispatchWorkspaceFileChangesAsync(provider, batch, CancellationToken.None); + + Assert.AreEqual(1, CountSentMethods(client, "workspace/didChangeWatchedFiles")); + + WorkspaceFileWatcher watcher = GetWorkspaceWatcher(provider) + ?? throw new AssertFailedException("Expected the workspace watcher to start."); + + watcher.Dispose(); + bool recovered = InvokePrivateMethodWithReturn(LuaLanguageServerIntellisenseProviderTestAccess.GetWorkspaceChangeCoordinator(provider), "TryRestartWorkspaceFileWatcher", watcher); + + Assert.IsTrue(recovered); + await Task.Delay(150).ConfigureAwait(false); + Assert.AreEqual(1, CountSentMethods(client, "workspace/didChangeWatchedFiles")); + } + finally + { + if (Directory.Exists(workspaceRoot)) + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [TestMethod] + public async Task WorkspaceWatcherRecovery_ReconcilesDroppedChangesFromUnexpectedForwardingFailure() + { + string workspaceRoot = Path.Combine(Path.GetTempPath(), "LuaWatcherRecoveryDropped_" + Guid.NewGuid().ToString("N")); + string filePath = Path.Combine(workspaceRoot, "Scripts", "test.lua"); + const string content = "local value = 1"; + const string updatedContent = "local value = 2"; + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(filePath) ?? workspaceRoot); + File.WriteAllText(filePath, content); + + using var client = new FakeLanguageServerClient + { + ThrowInvalidOperationOnNextWatchedFilesNotification = 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); + + WorkspaceFileWatcher watcher = GetWorkspaceWatcher(provider) + ?? throw new AssertFailedException("Expected the workspace watcher to start."); + + File.WriteAllText(filePath, updatedContent); + + await DispatchWorkspaceFileChangesAsync( + provider, + new FileChangeBatch( + [ + new WorkspaceFileChange(filePath, FileChangeKind.Changed) + ]), + CancellationToken.None); + + Assert.AreEqual(1, CountSentMethods(client, "workspace/didChangeWatchedFiles")); + + watcher.Dispose(); + + bool recovered = InvokePrivateMethodWithReturn( + LuaLanguageServerIntellisenseProviderTestAccess.GetWorkspaceChangeCoordinator(provider), + "TryRestartWorkspaceFileWatcher", + watcher); + + Assert.IsTrue(recovered); + Assert.IsTrue(await client.WaitForMethodCountAsync("workspace/didChangeWatchedFiles", 2, TimeSpan.FromSeconds(1))); + + JsonElement changes = client.GetLastNotificationParameters("workspace/didChangeWatchedFiles").GetProperty("changes"); + Assert.AreEqual(1, changes.GetArrayLength()); + Assert.AreEqual(new Uri(filePath).AbsoluteUri, changes[0].GetProperty("uri").GetString()); + Assert.AreEqual((int)FileChangeKind.Changed, changes[0].GetProperty("type").GetInt32()); + } + finally + { + if (Directory.Exists(workspaceRoot)) + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [TestMethod] + public async Task WorkspaceWatcherRecovery_ConcurrentDispatchDuringRecovery_ConvergesWithoutExtraReplayOnNextRecovery() + { + string workspaceRoot = Path.Combine(Path.GetTempPath(), "LuaWatcherRecoveryConcurrent_" + Guid.NewGuid().ToString("N")); + string filePath = Path.Combine(workspaceRoot, "Scripts", "test.lua"); + string reconciledFilePath = Path.Combine(workspaceRoot, "Scripts", "reconciled.lua"); + string liveFilePath = Path.Combine(workspaceRoot, "Scripts", "live.lua"); + const string content = "local value = 1"; + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(filePath) ?? workspaceRoot); + + using var client = new FakeLanguageServerClient(); + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + await provider.GetHoverAsync(filePath, content, 0, 0); + + WorkspaceFileWatcher watcher = GetWorkspaceWatcher(provider) + ?? throw new AssertFailedException("Expected the workspace watcher to start."); + + watcher.Dispose(); + File.WriteAllText(reconciledFilePath, "return 1"); + File.WriteAllText(liveFilePath, "return 2"); + + client.BlockNextWatchedFilesNotification(); + + Task liveDispatchTask = DispatchWorkspaceFileChangesAsync( + provider, + new FileChangeBatch( + [ + new WorkspaceFileChange(liveFilePath, FileChangeKind.Created) + ]), + CancellationToken.None); + + Assert.IsTrue(await client.WaitForMethodCountAsync("workspace/didChangeWatchedFiles", 1, TimeSpan.FromSeconds(1))); + + bool recovered = InvokePrivateMethodWithReturn( + LuaLanguageServerIntellisenseProviderTestAccess.GetWorkspaceChangeCoordinator(provider), + "TryRestartWorkspaceFileWatcher", + watcher); + + Assert.IsTrue(recovered); + + client.ReleaseWatchedFilesNotification(); + await liveDispatchTask.ConfigureAwait(false); + + Assert.IsTrue(await client.WaitForMethodCountAsync("workspace/didChangeWatchedFiles", 2, TimeSpan.FromSeconds(1))); + + WorkspaceFileWatcher replacementWatcher = GetWorkspaceWatcher(provider) + ?? throw new AssertFailedException("Expected the replacement workspace watcher to start."); + + replacementWatcher.Dispose(); + + bool recoveredAgain = InvokePrivateMethodWithReturn( + LuaLanguageServerIntellisenseProviderTestAccess.GetWorkspaceChangeCoordinator(provider), + "TryRestartWorkspaceFileWatcher", + replacementWatcher); + + Assert.IsTrue(recoveredAgain); + await Task.Delay(150).ConfigureAwait(false); + Assert.AreEqual(2, CountSentMethods(client, "workspace/didChangeWatchedFiles")); + } + 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 FakeLanguageServerClient + { + SupportsSemanticTokensFull = true, + 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_DisposeDuringInFlightSemanticTokensRequest_DoesNotRaiseSemanticTokensUpdated() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + const string content = "local value = 1"; + + using var client = new FakeLanguageServerClient + { + SupportsSemanticTokensFull = true, + SemanticTokenTypes = ["variable"] + }; + + client.BlockNextSemanticTokensFullRequest(); + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + var semanticTokensUpdated = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + + provider.SemanticTokensUpdated += (_, tokens) => semanticTokensUpdated.TrySetResult(tokens); + + provider.OpenDocument(filePath, content); + + Assert.IsTrue(await client.WaitForMethodCountAsync("textDocument/semanticTokens/full", 1, TimeSpan.FromSeconds(1)).ConfigureAwait(false)); + + provider.Dispose(); + client.ReleaseSemanticTokensFullRequest(); + + Task completedTask = await Task.WhenAny(semanticTokensUpdated.Task, Task.Delay(TimeSpan.FromMilliseconds(250))).ConfigureAwait(false); + + Assert.AreNotSame(semanticTokensUpdated.Task, completedTask); + } + + [TestMethod] + public async Task SemanticTokensRefreshRequested_RequestFailure_ClearsCachedSemanticTokens() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + const string content = "local value = 1"; + + using var client = new FakeLanguageServerClient + { + SupportsSemanticTokensFull = true, + SemanticTokenTypes = ["variable"] + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + var initialTokensUpdated = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + var clearedTokensUpdated = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + + provider.SemanticTokensUpdated += (updatedFilePath, tokens) => + { + if (!string.Equals(updatedFilePath, filePath, StringComparison.OrdinalIgnoreCase)) + return; + + if (tokens.Count > 0) + initialTokensUpdated.TrySetResult(tokens); + else + clearedTokensUpdated.TrySetResult(tokens); + }; + + provider.OpenDocument(filePath, content); + + Task initialCompletedTask = await Task.WhenAny(initialTokensUpdated.Task, Task.Delay(TimeSpan.FromSeconds(1))).ConfigureAwait(false); + Assert.AreSame(initialTokensUpdated.Task, initialCompletedTask); + Assert.AreEqual(1, provider.GetSemanticTokens(filePath).Count); + + client.ThrowInvalidOperationOnNextRequestMethod = "textDocument/semanticTokens/full"; + client.PublishSemanticTokensRefreshRequested(); + + Task clearedCompletedTask = await Task.WhenAny(clearedTokensUpdated.Task, Task.Delay(TimeSpan.FromSeconds(1))).ConfigureAwait(false); + Assert.AreSame(clearedTokensUpdated.Task, clearedCompletedTask); + Assert.AreEqual(0, clearedTokensUpdated.Task.Result.Count); + Assert.AreEqual(0, provider.GetSemanticTokens(filePath).Count); + + CollectionAssert.AreEqual( + new[] + { + "textDocument/didOpen", + "textDocument/semanticTokens/full", + "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 FakeLanguageServerClient + { + SupportsSemanticTokensFull = true, + 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 FakeLanguageServerClient + { + SupportsSemanticTokensFull = true, + 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()); + } + + [TestMethod] + public async Task SemanticTokensRefreshRequested_FallsBackToFullRefreshAfterMalformedDeltaEdit() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + const string content = "local value = 1"; + + using var client = new FakeLanguageServerClient + { + SupportsSemanticTokensFull = true, + 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", + edits = new object[] + { + new { deleteCount = 1, data = new[] { 0, 6 } } + } + })); + + 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()); + } + + [TestMethod] + public async Task SemanticTokensRefreshRequested_FallsBackToFullRefreshAfterOverlappingDeltaEdits() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + const string content = "local value = 1"; + + using var client = new FakeLanguageServerClient + { + SupportsSemanticTokensFull = true, + 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", + edits = new object[] + { + new { start = 0, deleteCount = 2, data = new[] { 0, 6 } }, + new { start = 1, deleteCount = 1, data = new[] { 7 } } + } + })); + + 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()); + } + + [TestMethod] + public async Task OpenDocument_WithSemanticTokenLegendButNoFullSupport_DoesNotRequestSemanticTokens() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + const string content = "local value = 1"; + + using var client = new FakeLanguageServerClient + { + SupportsSemanticTokensFull = false, + SemanticTokenTypes = ["variable"] + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + provider.OpenDocument(filePath, content); + + Assert.IsTrue(await client.WaitForMethodCountAsync("textDocument/didOpen", 1, TimeSpan.FromSeconds(1)).ConfigureAwait(false)); + + CollectionAssert.DoesNotContain(client.GetSentMethodNames(), "textDocument/semanticTokens/full"); + } +} diff --git a/Tests/TombLib.LanguageServer.Lua.Tests/Provider/LuaLanguageServerIntellisenseProviderTests.WorkspaceAndRecovery.cs b/Tests/TombLib.LanguageServer.Lua.Tests/Provider/LuaLanguageServerIntellisenseProviderTests.WorkspaceAndRecovery.cs new file mode 100644 index 0000000000..a30c649df7 --- /dev/null +++ b/Tests/TombLib.LanguageServer.Lua.Tests/Provider/LuaLanguageServerIntellisenseProviderTests.WorkspaceAndRecovery.cs @@ -0,0 +1,605 @@ +using System.Text.Json; +using TombLib.Scripting.Hover; +using TombLib.Scripting.Navigation; +using TombLib.Scripting.Signatures; + +namespace TombLib.LanguageServer.Lua.Tests; + +public partial class LuaLanguageServerIntellisenseProviderTests +{ + [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 FakeLanguageServerClient(); + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + var batch = new FileChangeBatch( + [ + new WorkspaceFileChange(apiFilePath, FileChangeKind.Changed) + ]); + + await DispatchWorkspaceFileChangesAsync(provider, 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 DispatchWorkspaceFileChangesAsync_ReplaysDeferredChangesAfterStartupRecovery() + { + string workspaceRoot = Path.Combine(Path.GetTempPath(), "LuaDeferredWorkspaceReplay_" + Guid.NewGuid().ToString("N")); + string apiDirectory = Path.Combine(workspaceRoot, ".API"); + string apiFilePath = Path.Combine(apiDirectory, "Generated.lua"); + string scriptFilePath = Path.Combine(workspaceRoot, "Scripts", "test.lua"); + + try + { + Directory.CreateDirectory(apiDirectory); + + using var client = new FakeLanguageServerClient + { + IsReady = false, + StartResult = false, + HoverResponse = JsonSerializer.SerializeToElement(new + { + contents = new + { + kind = "markdown", + value = "Hover docs." + } + }) + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + var batch = new FileChangeBatch( + [ + new WorkspaceFileChange(apiFilePath, FileChangeKind.Changed) + ]); + + await DispatchWorkspaceFileChangesAsync(provider, batch, CancellationToken.None); + + Assert.AreEqual(0, client.GetSentMethodNames().Length); + + client.StartResult = true; + + TextHoverInfo? hover = await provider.GetHoverAsync(scriptFilePath, "local value = 1", 0, 0); + + Assert.IsNotNull(hover); + + CollectionAssert.AreEqual( + new[] + { + "workspace/didChangeConfiguration", + "workspace/didChangeWatchedFiles", + "textDocument/didOpen", + "textDocument/hover" + }, + client.GetSentMethodNames()); + } + finally + { + if (Directory.Exists(workspaceRoot)) + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [TestMethod] + public async Task DispatchWorkspaceFileChangesAsync_ReplaysBufferedChangesAfterWorkspaceNotificationTransportFailure() + { + string workspaceRoot = Path.Combine(Path.GetTempPath(), "LuaDeferredWorkspaceTransport_" + Guid.NewGuid().ToString("N")); + string changedFilePath = Path.Combine(workspaceRoot, "Scripts", "generated.lua"); + string scriptFilePath = Path.Combine(workspaceRoot, "Scripts", "test.lua"); + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(changedFilePath) ?? workspaceRoot); + + using var client = new FakeLanguageServerClient + { + ThrowIOExceptionOnNextWatchedFilesNotification = true, + HoverResponse = JsonSerializer.SerializeToElement(new + { + contents = new + { + kind = "markdown", + value = "Hover docs." + } + }) + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + var batch = new FileChangeBatch( + [ + new WorkspaceFileChange(changedFilePath, FileChangeKind.Changed) + ]); + + await DispatchWorkspaceFileChangesAsync(provider, batch, CancellationToken.None); + + CollectionAssert.AreEqual( + new[] { "workspace/didChangeWatchedFiles" }, + client.GetSentMethodNames()); + + TextHoverInfo? hover = await provider.GetHoverAsync(scriptFilePath, "local value = 1", 0, 0); + + Assert.IsNotNull(hover); + + CollectionAssert.AreEqual( + new[] + { + "workspace/didChangeWatchedFiles", + "workspace/didChangeWatchedFiles", + "textDocument/didOpen", + "textDocument/hover" + }, + client.GetSentMethodNames()); + } + finally + { + if (Directory.Exists(workspaceRoot)) + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [TestMethod] + public async Task GetHoverAsync_ReplaysDeferredWorkspaceChangesAfterReplayCancellation() + { + string workspaceRoot = Path.Combine(Path.GetTempPath(), "LuaDeferredWorkspaceCancellation_" + Guid.NewGuid().ToString("N")); + string changedFilePath = Path.Combine(workspaceRoot, "Scripts", "generated.lua"); + string scriptFilePath = Path.Combine(workspaceRoot, "Scripts", "test.lua"); + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(changedFilePath) ?? workspaceRoot); + + using var client = new FakeLanguageServerClient + { + IsReady = false, + StartResult = false, + HoverResponse = JsonSerializer.SerializeToElement(new + { + contents = new + { + kind = "markdown", + value = "Hover docs." + } + }) + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + var batch = new FileChangeBatch( + [ + new WorkspaceFileChange(changedFilePath, FileChangeKind.Changed) + ]); + + await DispatchWorkspaceFileChangesAsync(provider, batch, CancellationToken.None); + + client.StartResult = true; + + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + TextHoverInfo? canceledHover = await provider.GetHoverAsync(scriptFilePath, "local value = 1", 0, 0, cancellationTokenSource.Token); + TextHoverInfo? recoveredHover = await provider.GetHoverAsync(scriptFilePath, "local value = 1", 0, 0); + + Assert.IsNull(canceledHover); + Assert.IsNotNull(recoveredHover); + + CollectionAssert.AreEqual( + new[] + { + "workspace/didChangeWatchedFiles", + "textDocument/didOpen", + "textDocument/hover" + }, + client.GetSentMethodNames()); + } + finally + { + if (Directory.Exists(workspaceRoot)) + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [TestMethod] + public async Task DispatchWorkspaceFileChangesAsync_UnexpectedNotificationFailureDoesNotMarkTransportUnhealthyAndDropsChanges() + { + string workspaceRoot = Path.Combine(Path.GetTempPath(), "LuaDeferredWorkspaceUnexpected_" + Guid.NewGuid().ToString("N")); + string changedFilePath = Path.Combine(workspaceRoot, "Scripts", "generated.lua"); + string scriptFilePath = Path.Combine(workspaceRoot, "Scripts", "test.lua"); + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(changedFilePath) ?? workspaceRoot); + + using var client = new FakeLanguageServerClient + { + ThrowInvalidOperationOnNextWatchedFilesNotification = true, + HoverResponse = JsonSerializer.SerializeToElement(new + { + contents = new + { + kind = "markdown", + value = "Hover docs." + } + }) + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + var batch = new FileChangeBatch( + [ + new WorkspaceFileChange(changedFilePath, FileChangeKind.Changed) + ]); + + await DispatchWorkspaceFileChangesAsync(provider, batch, CancellationToken.None); + + TextHoverInfo? hover = await provider.GetHoverAsync(scriptFilePath, "local value = 1", 0, 0); + + Assert.IsNotNull(hover); + Assert.AreEqual(0, client.MarkTransportUnhealthyCallCount); + Assert.AreEqual(1, client.StartCallCount); + + CollectionAssert.AreEqual( + new[] + { + "workspace/didChangeWatchedFiles", + "textDocument/didOpen", + "textDocument/hover" + }, + client.GetSentMethodNames()); + } + finally + { + if (Directory.Exists(workspaceRoot)) + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [TestMethod] + public async Task DispatchWorkspaceFileChangesAsync_StaleWatcherTransportFailureDoesNotInvalidateRestartedTransport() + { + string workspaceRoot = Path.Combine(Path.GetTempPath(), "LuaDeferredWorkspaceStaleTransport_" + Guid.NewGuid().ToString("N")); + string changedFilePath = Path.Combine(workspaceRoot, "Scripts", "generated.lua"); + string scriptFilePath = Path.Combine(workspaceRoot, "Scripts", "test.lua"); + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(changedFilePath) ?? workspaceRoot); + + using var client = new FakeLanguageServerClient + { + HoverResponse = JsonSerializer.SerializeToElement(new + { + contents = new + { + kind = "markdown", + value = "Hover docs." + } + }) + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + client.BlockNextWatchedFilesNotification(); + client.ThrowIOExceptionAfterWatchedFilesNotificationGateRelease = true; + + var batch = new FileChangeBatch( + [ + new WorkspaceFileChange(changedFilePath, FileChangeKind.Changed) + ]); + + Task dispatchTask = DispatchWorkspaceFileChangesAsync(provider, batch, CancellationToken.None); + + Assert.IsTrue(await client.WaitForMethodCountAsync("workspace/didChangeWatchedFiles", 1, TimeSpan.FromSeconds(1)).ConfigureAwait(false)); + + client.IsReady = false; + + Task hoverTask = provider.GetHoverAsync(scriptFilePath, "local value = 1", 0, 0); + + DateTime deadline = DateTime.UtcNow + TimeSpan.FromSeconds(1); + + while (client.StartCallCount < 2 && DateTime.UtcNow < deadline) + await Task.Delay(10).ConfigureAwait(false); + + Assert.AreEqual(2, client.StartCallCount); + + client.ReleaseWatchedFilesNotification(); + + await dispatchTask.ConfigureAwait(false); + Assert.IsNotNull(await hoverTask.ConfigureAwait(false)); + + TextHoverInfo? followUpHover = await provider.GetHoverAsync(scriptFilePath, "local value = 2", 0, 0).ConfigureAwait(false); + + Assert.IsNotNull(followUpHover); + Assert.AreEqual(0, client.MarkTransportUnhealthyCallCount); + Assert.AreEqual(2, client.StartCallCount); + } + finally + { + if (Directory.Exists(workspaceRoot)) + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [TestMethod] + public async Task DispatchWorkspaceFileChangesAsync_RepeatedTransportFailuresReplayEachBufferedBatchOnce() + { + string workspaceRoot = Path.Combine(Path.GetTempPath(), "LuaDeferredWorkspaceRepeated_" + Guid.NewGuid().ToString("N")); + string scriptFilePath = Path.Combine(workspaceRoot, "Scripts", "test.lua"); + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(scriptFilePath) ?? workspaceRoot); + + using var client = new FakeLanguageServerClient + { + HoverResponse = JsonSerializer.SerializeToElement(new + { + contents = new + { + kind = "markdown", + value = "Hover docs." + } + }) + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + for (int i = 1; i <= 3; i++) + { + string changedFilePath = Path.Combine(workspaceRoot, "Scripts", $"generated{i}.lua"); + + var batch = new FileChangeBatch( + [ + new WorkspaceFileChange(changedFilePath, FileChangeKind.Changed) + ]); + + client.ThrowIOExceptionOnNextWatchedFilesNotification = true; + + await DispatchWorkspaceFileChangesAsync(provider, batch, CancellationToken.None); + + TextHoverInfo? hover = await provider.GetHoverAsync(scriptFilePath, $"local value = {i}", 0, 0); + + Assert.IsNotNull(hover); + Assert.AreEqual(i, client.MarkTransportUnhealthyCallCount); + Assert.AreEqual(i * 2, CountSentMethods(client, "workspace/didChangeWatchedFiles")); + + JsonElement replayPayload = client.GetLastNotificationParameters("workspace/didChangeWatchedFiles").GetProperty("changes"); + + Assert.AreEqual(1, replayPayload.GetArrayLength()); + Assert.AreEqual(new Uri(changedFilePath).AbsoluteUri, replayPayload[0].GetProperty("uri").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 FakeLanguageServerClient + { + IsReady = false, + StartResult = false + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + var failures = new List(); + + provider.StartupFailed += failures.Add; + + 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 FakeLanguageServerClient + { + HoverResponse = JsonSerializer.SerializeToElement(new + { + contents = new + { + kind = "markdown", + value = "Hover docs." + } + }) + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + TextHoverInfo? hover = await provider.GetHoverAsync(filePath, content, 0, 0); + + Assert.IsNotNull(hover); + Assert.AreEqual("Hover docs.", hover.Content); + Assert.IsTrue(hover.ContentKind == TextHoverContentKind.Markdown); + + 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 FakeLanguageServerClient + { + 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); + + TextDefinitionLocation? 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 FakeLanguageServerClient + { + 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].StartLineNumber); + Assert.AreEqual(5, references[0].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 FakeLanguageServerClient + { + 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); + + TextSignatureHelpInfo? 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.ActiveParameterIndex); + 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()); + } +} diff --git a/Tests/TombLib.LanguageServer.Lua.Tests/Provider/LuaLanguageServerIntellisenseProviderTests.cs b/Tests/TombLib.LanguageServer.Lua.Tests/Provider/LuaLanguageServerIntellisenseProviderTests.cs new file mode 100644 index 0000000000..37ef1360b5 --- /dev/null +++ b/Tests/TombLib.LanguageServer.Lua.Tests/Provider/LuaLanguageServerIntellisenseProviderTests.cs @@ -0,0 +1,605 @@ +using System.Text.Json; +using TombLib.Scripting.Completion; +using TombLib.Scripting.Editing; +using TombLib.Scripting.Hover; + +namespace TombLib.LanguageServer.Lua.Tests; + +[TestClass] +public partial 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 FakeLanguageServerClient(); + 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 GetHoverAsync_DisposeDuringStartupWait_ReturnsNullWithoutObjectDisposedException() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + + using var client = new FakeLanguageServerClient(); + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + SemaphoreSlim startLock = GetProviderStartLock(provider); + + startLock.Wait(); + + try + { + Task hoverTask = provider.GetHoverAsync(filePath, "local value = 1", 0, 0); + + await Task.Delay(50).ConfigureAwait(false); + provider.Dispose(); + + Assert.IsNull(await hoverTask.ConfigureAwait(false)); + } + finally + { + startLock.Release(); + } + } + + [TestMethod] + public async Task GetHoverAsync_DisposeDuringInFlightRequest_ReturnsNullWithoutLateResult() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + + using var client = new FakeLanguageServerClient + { + HoverResponse = JsonSerializer.SerializeToElement(new + { + contents = new + { + kind = "markdown", + value = "Hover docs." + } + }) + }; + + client.BlockNextHoverRequest(); + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + Task hoverTask = provider.GetHoverAsync(filePath, "local value = 1", 0, 0); + + Assert.IsTrue(await client.WaitForMethodCountAsync("textDocument/hover", 1, TimeSpan.FromSeconds(1)).ConfigureAwait(false)); + + provider.Dispose(); + client.ReleaseHoverRequest(); + + Assert.IsNull(await hoverTask.ConfigureAwait(false)); + } + + [TestMethod] + public async Task GetHoverAsync_DisposeDuringBlockedStart_ReturnsNullWithoutLeakingStartTask() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + + using var client = new FakeLanguageServerClient + { + IsReady = false, + HoverResponse = JsonSerializer.SerializeToElement(new + { + contents = new + { + kind = "markdown", + value = "Hover docs." + } + }) + }; + + client.BlockNextStartAsync(); + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + Task hoverTask = provider.GetHoverAsync(filePath, "local value = 1", 0, 0); + + await Task.Delay(50).ConfigureAwait(false); + provider.Dispose(); + + Assert.IsNull(await hoverTask.ConfigureAwait(false)); + Assert.AreEqual(1, client.StartCallCount); + Assert.AreEqual(1, client.StartCancellationTokenCanBeCanceled.Count); + } + + [TestMethod] + public void Dispose_DisposesOwnedCancellationSourceAndUnderlyingClientOnce() + { + const string workspaceRoot = @"C:\Workspace"; + + var client = new FakeLanguageServerClient(); + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + CancellationTokenSource disposeCts = GetProviderDisposeCancellationTokenSource(provider); + + provider.Dispose(); + provider.Dispose(); + + Assert.AreEqual(1, client.DisposeCallCount); + Assert.ThrowsException(() => disposeCts.Cancel()); + } + + [TestMethod] + public async Task GetCompletionItemsAsync_ResolvesCompletionItemDetailsWhenServerSupportsResolve() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + + using var client = new FakeLanguageServerClient + { + SupportsCompletionResolve = true, + CompletionResponse = JsonSerializer.SerializeToElement(new + { + items = new object[] + { + new + { + label = "spawn", + kind = 3, + insertText = "spawn", + data = new + { + completionId = 7, + source = "server" + } + } + } + }), + 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); + TextCompletionItem 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); + Assert.AreEqual(7, client.GetLastRequestParameters("completionItem/resolve").GetProperty("data").GetProperty("completionId").GetInt32()); + + 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 FakeLanguageServerClient + { + 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); + TextCompletionItem 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 FakeLanguageServerClient + { + 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 GetCompletionItemsAsync_WhenRequestCrossesTransportBoundary_RetriesOnce() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + + using var client = new FakeLanguageServerClient + { + TransportChangedRequestFailuresRemaining = 1, + CompletionResponse = JsonSerializer.SerializeToElement(new + { + items = new object[] + { + new + { + label = "spawn", + kind = 3, + insertText = "spawn" + } + } + }) + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + IReadOnlyList items = await provider.GetCompletionItemsAsync(filePath, "spa", 0, 3).ConfigureAwait(false); + + Assert.AreEqual(1, items.Count); + Assert.AreEqual("spawn", items[0].Label); + Assert.AreEqual(2, client.StartCallCount); + Assert.AreEqual(2, CountSentMethods(client, "textDocument/completion")); + } + + [TestMethod] + public async Task GetHoverAsync_WhenRequestTransportFails_RestartsAndRetriesOnce() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + const string content = "local value = 1"; + + using var client = new FakeLanguageServerClient + { + ThrowIOExceptionOnNextRequestMethod = "textDocument/hover", + HoverResponse = JsonSerializer.SerializeToElement(new + { + contents = new + { + kind = "markdown", + value = "Hover docs." + } + }) + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + TextHoverInfo? recoveredHover = await provider.GetHoverAsync(filePath, content, 0, 0).ConfigureAwait(false); + + Assert.IsNotNull(recoveredHover); + Assert.AreEqual("Hover docs.", recoveredHover.Content); + Assert.AreEqual(2, client.StartCallCount); + Assert.AreEqual(2, CountSentMethods(client, "textDocument/hover")); + } + + [TestMethod] + public async Task RenameSymbolAsync_WhenRequestTransportFails_RestartsAndRetriesOnce() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\rename.lua"; + const string content = "local tracked_value = 1\r\nreturn tracked_value\r\n"; + + using var client = new FakeLanguageServerClient + { + ThrowIOExceptionOnNextRequestMethod = "textDocument/rename", + RenameResponse = JsonSerializer.SerializeToElement(new + { + changes = new Dictionary + { + [new Uri(filePath).AbsoluteUri] = + [ + new + { + newText = "renamed_value", + range = new + { + start = new { line = 0, character = 6 }, + end = new { line = 0, character = 19 } + } + } + ] + } + }) + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + TextWorkspaceEdit? recoveredRename = await provider + .RenameSymbolAsync(new TextRenameRequest(filePath, content, 0, 8, "renamed_value")) + .ConfigureAwait(false); + + Assert.IsNotNull(recoveredRename); + Assert.IsTrue(recoveredRename.HasEdits); + Assert.AreEqual(2, client.StartCallCount); + Assert.AreEqual(2, CountSentMethods(client, "textDocument/rename")); + } + + [TestMethod] + public async Task GetHoverAsync_WhenRequestFailsWithoutTransportLoss_ThrowsWithoutRetrying() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\test.lua"; + const string content = "local value = 1"; + + using var client = new FakeLanguageServerClient + { + ThrowInvalidOperationOnNextRequestMethod = "textDocument/hover" + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + await Assert.ThrowsExceptionAsync(async () => + await provider.GetHoverAsync(filePath, content, 0, 0).ConfigureAwait(false)).ConfigureAwait(false); + + Assert.AreEqual(1, client.StartCallCount); + Assert.AreEqual(1, CountSentMethods(client, "textDocument/hover")); + } + + [TestMethod] + public async Task GetHoverAsync_RequestOnlyDocuments_DoNotAccumulateAcrossDistinctFiles() + { + const string workspaceRoot = @"C:\Workspace"; + const int maxTrackedRequestOnlyDocuments = 16; + + using var client = new FakeLanguageServerClient + { + HoverResponse = JsonSerializer.SerializeToElement(new + { + contents = new + { + kind = "markdown", + value = "Hover docs." + } + }) + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + for (int i = 0; i < 20; i++) + { + string filePath = $@"C:\Workspace\Scripts\hover_{i}.lua"; + TextHoverInfo? hover = await provider.GetHoverAsync(filePath, "local value = 1", 0, 0); + + Assert.IsNotNull(hover); + } + + Assert.AreEqual(maxTrackedRequestOnlyDocuments, GetTrackedDocumentCount(provider)); + Assert.IsTrue(await client.WaitForMethodCountAsync("textDocument/didClose", 4, TimeSpan.FromSeconds(1))); + Assert.AreEqual(4, CountSentMethods(client, "textDocument/didClose")); + } + + [TestMethod] + public async Task GetHoverAsync_RequestOnlyDocument_ReopensLazilyOnNextRequest() + { + const string workspaceRoot = @"C:\Workspace"; + const string filePath = @"C:\Workspace\Scripts\hover.lua"; + + using var client = new FakeLanguageServerClient + { + HoverResponse = JsonSerializer.SerializeToElement(new + { + contents = new + { + kind = "markdown", + value = "Hover docs." + } + }) + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + Assert.IsNotNull(await provider.GetHoverAsync(filePath, "local value = 1", 0, 0)); + + for (int i = 0; i < 20; i++) + { + string otherFilePath = $@"C:\Workspace\Scripts\other_{i}.lua"; + Assert.IsNotNull(await provider.GetHoverAsync(otherFilePath, "local value = 1", 0, 0)); + } + + int didOpenCountBeforeReopen = CountSentMethods(client, "textDocument/didOpen"); + int didCloseCountBeforeReopen = CountSentMethods(client, "textDocument/didClose"); + + Assert.IsNotNull(await provider.GetHoverAsync(filePath, "local value = 1", 0, 0)); + + Assert.AreEqual(16, GetTrackedDocumentCount(provider)); + Assert.AreEqual(didOpenCountBeforeReopen + 1, CountSentMethods(client, "textDocument/didOpen")); + Assert.AreEqual(didCloseCountBeforeReopen + 1, CountSentMethods(client, "textDocument/didClose")); + } + + [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 FakeLanguageServerClient + { + 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); + + TextWorkspaceEdit? workspaceEdit = await provider.FormatDocumentAsync( + new TextFormatRequest(filePath, content, new TextFormattingOptions(tabSize: 3, insertSpaces: false))); + + Assert.IsNotNull(workspaceEdit); + Assert.AreEqual(1, workspaceEdit.DocumentEdits.Count); + Assert.AreEqual(filePath, workspaceEdit.DocumentEdits[0].FilePath); + Assert.AreEqual(1, workspaceEdit.DocumentEdits[0].TextEdits.Count); + Assert.AreEqual("local value = 1\r\n", workspaceEdit.DocumentEdits[0].TextEdits[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 FakeLanguageServerClient + { + 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); + + TextWorkspaceEdit? workspaceEdit = await provider + .RenameSymbolAsync(new TextRenameRequest(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 FakeLanguageServerClient + { + SupportsFormatting = false + }; + + using var provider = new LuaLanguageServerIntellisenseProvider(workspaceRoot, client); + + TextWorkspaceEdit? workspaceEdit = await provider.FormatDocumentAsync( + new TextFormatRequest(filePath, "local value=1", new TextFormattingOptions(tabSize: 4, insertSpaces: true))); + + Assert.IsNull(workspaceEdit); + + CollectionAssert.AreEqual( + new[] { "textDocument/didOpen" }, + client.GetSentMethodNames()); + } +} diff --git a/Tests/TombLib.LanguageServer.Lua.Tests/ResponseParsing/LuaLanguageServerResponseParserTests.DefinitionAndEdits.cs b/Tests/TombLib.LanguageServer.Lua.Tests/ResponseParsing/LuaLanguageServerResponseParserTests.DefinitionAndEdits.cs new file mode 100644 index 0000000000..e72e308420 --- /dev/null +++ b/Tests/TombLib.LanguageServer.Lua.Tests/ResponseParsing/LuaLanguageServerResponseParserTests.DefinitionAndEdits.cs @@ -0,0 +1,530 @@ +using NLog; +using System.Text.Json; +using TombLib.Scripting.Editing; +using TombLib.Scripting.Navigation; + +namespace TombLib.LanguageServer.Lua.Tests; + +public partial class LuaLanguageServerResponseParserTests +{ + [TestMethod] + public void ParseDefinitionLocation_UsesFirstEntryFromMultiLocationResponse() + { + string firstPath = Path.GetFullPath(@"C:\Workspace\Scripts\first.lua"); + string secondPath = Path.GetFullPath(@"C:\Workspace\Scripts\second.lua"); + + TextDefinitionLocation? 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 DeserializeDefinitionResponse_PreservesAllTargetsFromMultiLocationResponse() + { + string firstPath = Path.GetFullPath(@"C:\Workspace\Scripts\first.lua"); + string secondPath = Path.GetFullPath(@"C:\Workspace\Scripts\second.lua"); + + DefinitionResponse response = 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.AreEqual(2, response.Targets.Count); + Assert.AreEqual(new Uri(firstPath).AbsoluteUri, response.Targets[0].Uri); + Assert.AreEqual(3, response.Targets[0].LineNumber); + Assert.AreEqual(5, response.Targets[0].ColumnNumber); + Assert.AreEqual(new Uri(secondPath).AbsoluteUri, response.Targets[1].Uri); + Assert.AreEqual(9, response.Targets[1].LineNumber); + Assert.AreEqual(2, response.Targets[1].ColumnNumber); + } + + [TestMethod] + public void SerializeDefinitionResponse_WritesRoundTrippableLocationArray() + { + string firstUri = new Uri(Path.GetFullPath(@"C:\Workspace\Scripts\first.lua")).AbsoluteUri; + string secondUri = new Uri(Path.GetFullPath(@"C:\Workspace\Scripts\second.lua")).AbsoluteUri; + + var response = new DefinitionResponse( + [ + new DefinitionTargetResponse(firstUri, 3, 5), + new DefinitionTargetResponse(secondUri, 9, 2) + ]); + + string json = JsonSerializer.Serialize(response); + + DefinitionResponse roundTripped = JsonSerializer.Deserialize(json) + ?? throw new AssertFailedException("Serialized definition response should deserialize successfully."); + + Assert.AreEqual(2, roundTripped.Targets.Count); + Assert.AreEqual(firstUri, roundTripped.Targets[0].Uri); + Assert.AreEqual(3, roundTripped.Targets[0].LineNumber); + Assert.AreEqual(5, roundTripped.Targets[0].ColumnNumber); + Assert.AreEqual(secondUri, roundTripped.Targets[1].Uri); + Assert.AreEqual(9, roundTripped.Targets[1].LineNumber); + Assert.AreEqual(2, roundTripped.Targets[1].ColumnNumber); + } + + [TestMethod] + public void DeserializeDefinitionResponse_IgnoresMalformedTargetsAndKeepsUsableEntries() + { + string validPath = Path.GetFullPath(@"C:\Workspace\Scripts\valid.lua"); + + DefinitionResponse response = DeserializeDefinitionResponse(new object[] + { + new + { + uri = "not a uri", + range = new + { + start = new { line = 0, character = 0 }, + end = new { line = 0, character = 1 } + } + }, + new + { + uri = new Uri(validPath).AbsoluteUri, + range = new + { + start = new { line = 3, character = 2 }, + end = new { line = 3, character = 7 } + } + } + }); + + Assert.AreEqual(1, response.Targets.Count); + Assert.AreEqual(new Uri(validPath).AbsoluteUri, response.Targets[0].Uri); + Assert.AreEqual(4, response.Targets[0].LineNumber); + Assert.AreEqual(3, response.Targets[0].ColumnNumber); + } + + [TestMethod] + public void ParseDefinitionLocation_UsesTargetSelectionRangeFromLocationLink() + { + string targetPath = Path.GetFullPath(@"C:\Workspace\Scripts\linked.lua"); + + TextDefinitionLocation? 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 DeserializeDefinitionResponse_FallsBackToTargetRangeWhenSelectionRangeIsMalformed() + { + string targetPath = Path.GetFullPath(@"C:\Workspace\Scripts\linked.lua"); + + DefinitionResponse response = DeserializeDefinitionResponse(new + { + targetUri = new Uri(targetPath).AbsoluteUri, + targetSelectionRange = new + { + start = new { line = -1, character = 2 }, + end = new { line = 4, character = 9 } + }, + targetRange = new + { + start = new { line = 6, character = 3 }, + end = new { line = 6, character = 8 } + } + }); + + Assert.AreEqual(1, response.Targets.Count); + Assert.AreEqual(new Uri(targetPath).AbsoluteUri, response.Targets[0].Uri); + Assert.AreEqual(7, response.Targets[0].LineNumber); + Assert.AreEqual(4, response.Targets[0].ColumnNumber); + } + + [TestMethod] + public void DeserializeDefinitionResponse_ReturnsEmptyTargetsForSingleMalformedPayload() + { + DefinitionResponse response = DeserializeDefinitionResponse(new + { + uri = "not a uri", + range = new + { + start = new { line = 0, character = 0 }, + end = new { line = 0, character = 1 } + } + }); + + Assert.AreEqual(0, response.Targets.Count); + } + + [TestMethod] + public void ParseDefinitionLocation_ReturnsNullForNegativeTargetPosition() + { + string targetPath = Path.GetFullPath(@"C:\Workspace\Scripts\linked.lua"); + + TextDefinitionLocation? location = LuaLanguageServerResponseParser.ParseDefinitionLocation( + DeserializeDefinitionResponse(new + { + targetUri = new Uri(targetPath).AbsoluteUri, + targetSelectionRange = new + { + start = new { line = -1, character = 2 }, + end = new { line = 4, character = 9 } + } + })); + + Assert.IsNull(location); + } + + [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].StartLineNumber); + Assert.AreEqual(5, locations[0].StartColumnNumber); + Assert.AreEqual(3, locations[0].EndLineNumber); + Assert.AreEqual(10, locations[0].EndColumnNumber); + } + + [TestMethod] + public void ParseReferenceLocations_IgnoresNegativeProtocolRanges() + { + 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 = -1, character = 4 }, + end = new { line = 2, character = 9 } + } + } + })); + + Assert.AreEqual(0, locations.Count); + } + + [TestMethod] + public void ParseWorkspaceEdit_MergesChangeMapAndDocumentChanges() + { + string firstPath = Path.GetFullPath(@"C:\Workspace\Scripts\first.lua"); + string secondPath = Path.GetFullPath(@"C:\Workspace\Scripts\second.lua"); + + TextWorkspaceEdit? 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 ParseWorkspaceEdit_ReturnsNullWhenDocumentChangesContainUnsupportedResourceOperation() + { + string firstPath = Path.GetFullPath(@"C:\Workspace\Scripts\first.lua"); + string secondPath = Path.GetFullPath(@"C:\Workspace\Scripts\second.lua"); + + WorkspaceEditResponse? response = DeserializeWorkspaceEditResponse(new + { + documentChanges = new object[] + { + new + { + textDocument = new { uri = new Uri(firstPath).AbsoluteUri }, + edits = new object[] + { + new + { + range = new + { + start = new { line = 0, character = 0 }, + end = new { line = 0, character = 5 } + }, + newText = "local" + } + } + }, + new + { + kind = "rename", + oldUri = new Uri(firstPath).AbsoluteUri, + newUri = new Uri(secondPath).AbsoluteUri + } + } + }); + + TextWorkspaceEdit? workspaceEdit = LuaLanguageServerResponseParser.ParseWorkspaceEdit(response); + + Assert.IsNull(workspaceEdit); + } + + [TestMethod] + public void ParseWorkspaceEdit_LogsWarningWhenDocumentChangesContainUnsupportedResourceOperation() + { + string firstPath = Path.GetFullPath(@"C:\Workspace\Scripts\first.lua"); + string secondPath = Path.GetFullPath(@"C:\Workspace\Scripts\second.lua"); + + WorkspaceEditResponse? response = DeserializeWorkspaceEditResponse(new + { + documentChanges = new object[] + { + new + { + kind = "rename", + oldUri = new Uri(firstPath).AbsoluteUri, + newUri = new Uri(secondPath).AbsoluteUri + } + } + }); + + using var logScope = new NLogMemoryScope(LogLevel.Warn); + + TextWorkspaceEdit? workspaceEdit = LuaLanguageServerResponseParser.ParseWorkspaceEdit(response); + + Assert.IsNull(workspaceEdit); + Assert.AreEqual(1, logScope.Logs.Count); + StringAssert.Contains(logScope.Logs[0], "unsupported resource operation"); + StringAssert.Contains(logScope.Logs[0], "rename workspace edit"); + StringAssert.Contains(logScope.Logs[0], "first.lua"); + StringAssert.Contains(logScope.Logs[0], "second.lua"); + } + + [TestMethod] + public void DeserializeWorkspaceEditResponse_PreservesResourceOperationMetadataInDocumentChanges() + { + string firstPath = Path.GetFullPath(@"C:\Workspace\Scripts\first.lua"); + string secondPath = Path.GetFullPath(@"C:\Workspace\Scripts\second.lua"); + + WorkspaceEditResponse? response = DeserializeWorkspaceEditResponse(new + { + documentChanges = new object[] + { + new + { + textDocument = new { uri = new Uri(firstPath).AbsoluteUri }, + edits = new object[] + { + new + { + range = new + { + start = new { line = 0, character = 0 }, + end = new { line = 0, character = 5 } + }, + newText = "local" + } + } + }, + new + { + kind = "rename", + oldUri = new Uri(firstPath).AbsoluteUri, + newUri = new Uri(secondPath).AbsoluteUri + } + } + }); + + Assert.IsNotNull(response); + Assert.IsNotNull(response.Value.DocumentChanges); + Assert.AreEqual(2, response.Value.DocumentChanges.Count); + Assert.AreEqual(firstPath, Path.GetFullPath(new Uri(response.Value.DocumentChanges[0].TextDocument?.Uri ?? string.Empty).LocalPath)); + Assert.AreEqual("rename", response.Value.DocumentChanges[1].Kind); + Assert.AreEqual(new Uri(firstPath).AbsoluteUri, response.Value.DocumentChanges[1].OldUri); + Assert.AreEqual(new Uri(secondPath).AbsoluteUri, response.Value.DocumentChanges[1].NewUri); + } + + [TestMethod] + public void WorkspaceEditResponse_DefensivelyClonesNestedEditCollections() + { + IReadOnlyList edits = + [ + new TextEditPayload( + new ProtocolRangePayload( + new ProtocolNullablePosition(0, 0), + new ProtocolNullablePosition(0, 1)), + "x") + ]; + + var changes = new Dictionary?> + { + [new Uri(@"C:\Workspace\Scripts\first.lua").AbsoluteUri] = edits + }; + + WorkspaceDocumentChangePayload[] documentChanges = + [ + new WorkspaceDocumentChangePayload( + new TextDocumentUriPayload(new Uri(@"C:\Workspace\Scripts\first.lua").AbsoluteUri), + edits, + kind: null, + uri: null, + oldUri: null, + newUri: null) + ]; + + var response = new WorkspaceEditResponse(changes, documentChanges); + changes.Clear(); + + documentChanges[0] = new WorkspaceDocumentChangePayload( + new TextDocumentUriPayload(new Uri(@"C:\Workspace\Scripts\second.lua").AbsoluteUri), + edits, + kind: "rename", + uri: null, + oldUri: new Uri(@"C:\Workspace\Scripts\first.lua").AbsoluteUri, + newUri: new Uri(@"C:\Workspace\Scripts\second.lua").AbsoluteUri); + + Assert.IsNotNull(response.Changes); + Assert.AreEqual(1, response.Changes.Count); + Assert.IsNotNull(response.DocumentChanges); + Assert.AreEqual(1, response.DocumentChanges.Count); + Assert.AreEqual(new Uri(@"C:\Workspace\Scripts\first.lua").AbsoluteUri, response.DocumentChanges[0].TextDocument?.Uri); + Assert.IsFalse(response.DocumentChanges[0].IsResourceOperation); + } + + [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); + } +} diff --git a/Tests/TombLib.LanguageServer.Lua.Tests/ResponseParsing/LuaLanguageServerResponseParserTests.HoverAndMarkup.cs b/Tests/TombLib.LanguageServer.Lua.Tests/ResponseParsing/LuaLanguageServerResponseParserTests.HoverAndMarkup.cs new file mode 100644 index 0000000000..4d85e7b438 --- /dev/null +++ b/Tests/TombLib.LanguageServer.Lua.Tests/ResponseParsing/LuaLanguageServerResponseParserTests.HoverAndMarkup.cs @@ -0,0 +1,150 @@ +using System.Text.Json; +using TombLib.Scripting.Hover; + +namespace TombLib.LanguageServer.Lua.Tests; + +public partial class LuaLanguageServerResponseParserTests +{ + [TestMethod] + public void ParseHoverInfo_PreservesIndentedMarkdownAndHardBreakWhitespace() + { + HoverResponse response = DeserializeHoverResponse(new + { + contents = new + { + kind = "markdown", + value = " local value = 1 \nnext" + } + }); + + TextHoverInfo? hover = LuaLanguageServerResponseParser.ParseHoverInfo(response); + + Assert.IsNotNull(hover); + Assert.AreEqual(" local value = 1 \nnext", hover.Content); + Assert.AreEqual(TextHoverContentKind.Markdown, hover.ContentKind); + } + + [TestMethod] + public void ParseHoverInfo_CombinesMarkupArrayWithoutTrimmingIndentedMarkdownFragment() + { + HoverResponse response = DeserializeHoverResponse(new + { + contents = new object[] + { + "Summary", + new + { + kind = "markdown", + value = " local value = 1" + } + } + }); + + TextHoverInfo? hover = LuaLanguageServerResponseParser.ParseHoverInfo(response); + + Assert.IsNotNull(hover); + Assert.AreEqual($"Summary{Environment.NewLine}{Environment.NewLine} local value = 1", hover.Content); + Assert.AreEqual(TextHoverContentKind.Markdown, hover.ContentKind); + } + + [TestMethod] + public void ParseHoverInfo_CodeBlockPayloadUsesFenceLongerThanEmbeddedBackticks() + { + HoverResponse response = DeserializeHoverResponse(new + { + contents = new + { + language = "lua", + value = "print(\"```\")" + } + }); + + TextHoverInfo? hover = LuaLanguageServerResponseParser.ParseHoverInfo(response); + + Assert.IsNotNull(hover); + Assert.AreEqual("````lua\nprint(\"```\")\n````", hover.Content.Replace("\r\n", "\n", StringComparison.Ordinal)); + Assert.AreEqual(TextHoverContentKind.Markdown, hover.ContentKind); + } + + [TestMethod] + public void MarkupContentReader_ExtractContent_CombinesMixedArrayAndSkipsMalformedEntries() + { + JsonElement element = JsonSerializer.SerializeToElement(new object[] + { + "Summary", + new + { + kind = "markdown", + value = "**bold**" + }, + new + { + value = 5 + }, + new + { + language = "lua", + value = "print(1)" + }, + new + { + value = "tail" + } + }); + + MarkupContent content = MarkupContentReader.ExtractContent(element); + + Assert.IsTrue(content.IsMarkdown); + + Assert.AreEqual( + "Summary\n\n**bold**\n\n```lua\nprint(1)\n```\n\ntail", + content.Text.Replace("\r\n", "\n", StringComparison.Ordinal)); + } + + [TestMethod] + public void MarkupContentReader_ExtractContent_FallsBackToPlainValueWhenKindHasWrongType() + { + JsonElement element = JsonSerializer.SerializeToElement(new + { + kind = 5, + value = "plain text" + }); + + MarkupContent content = MarkupContentReader.ExtractContent(element); + + Assert.AreEqual("plain text", content.Text); + Assert.IsFalse(content.IsMarkdown); + } + + [TestMethod] + public void MarkupContentReader_ExtractContent_ReturnsDefaultForPartiallyMissingCodeBlockPayload() + { + JsonElement element = JsonSerializer.SerializeToElement(new + { + language = "lua" + }); + + MarkupContent content = MarkupContentReader.ExtractContent(element); + + Assert.IsTrue(string.IsNullOrEmpty(content.Text)); + Assert.IsFalse(content.IsMarkdown); + } + + [TestMethod] + public void NormalizeMarkupText_PreservesInlineBackticksForPlainText() + { + string? normalized = LuaMarkupTextHelper.NormalizeMarkupText("Call `value` before `other`."); + Assert.AreEqual("Call `value` before `other`.", normalized); + } + + [TestMethod] + public void NormalizeMarkupText_StripsFenceLinesButPreservesCodeContent() + { + string? normalized = LuaMarkupTextHelper.NormalizeMarkupText( + "Summary\n```lua\nlocal value = 1\n```\nTail"); + + Assert.AreEqual( + $"Summary{Environment.NewLine}local value = 1{Environment.NewLine}Tail", + normalized); + } +} diff --git a/Tests/TombLib.LanguageServer.Lua.Tests/ResponseParsing/LuaLanguageServerResponseParserTests.SignatureHelp.cs b/Tests/TombLib.LanguageServer.Lua.Tests/ResponseParsing/LuaLanguageServerResponseParserTests.SignatureHelp.cs new file mode 100644 index 0000000000..0a7de39584 --- /dev/null +++ b/Tests/TombLib.LanguageServer.Lua.Tests/ResponseParsing/LuaLanguageServerResponseParserTests.SignatureHelp.cs @@ -0,0 +1,104 @@ +using TombLib.Scripting.Signatures; + +namespace TombLib.LanguageServer.Lua.Tests; + +public partial class LuaLanguageServerResponseParserTests +{ + [TestMethod] + public void ParseSignatureHelp_UsesParameterLabelOffsetsAndActiveParameter() + { + TextSignatureHelpInfo? 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.ActiveParameterIndex); + 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() + { + TextSignatureHelpInfo? 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.ActiveParameterIndex); + Assert.AreEqual("y", signatureInfo.Parameters[1].Label); + } + + [TestMethod] + public void ParseSignatureHelp_ClampsOutOfRangeActiveParameterToLastAvailableParameter() + { + TextSignatureHelpInfo? signatureInfo = LuaLanguageServerResponseParser.ParseSignatureHelp( + DeserializeSignatureHelpResponse(new + { + activeSignature = 0, + activeParameter = 9, + signatures = new[] + { + new + { + label = "spawn(room, objectName)", + parameters = new object[] + { + new { label = "room" }, + new { label = "objectName" } + } + } + } + })); + + Assert.IsNotNull(signatureInfo); + Assert.AreEqual(2, signatureInfo.Parameters.Count); + Assert.AreEqual(1, signatureInfo.ActiveParameterIndex); + Assert.AreEqual("objectName", signatureInfo.Parameters[signatureInfo.ActiveParameterIndex].Label); + } +} diff --git a/Tests/TombLib.LanguageServer.Lua.Tests/ResponseParsing/LuaLanguageServerResponseParserTests.Support.cs b/Tests/TombLib.LanguageServer.Lua.Tests/ResponseParsing/LuaLanguageServerResponseParserTests.Support.cs new file mode 100644 index 0000000000..a7000c8ac3 --- /dev/null +++ b/Tests/TombLib.LanguageServer.Lua.Tests/ResponseParsing/LuaLanguageServerResponseParserTests.Support.cs @@ -0,0 +1,81 @@ +using NLog; +using NLog.Config; +using NLog.Targets; +using System.Text.Json; + +namespace TombLib.LanguageServer.Lua.Tests; + +public partial class LuaLanguageServerResponseParserTests +{ + private static CompletionItemPayload 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 CompletionItemPayload DeserializeCompletionItemPayload(object payload) + => JsonSerializer.Deserialize(JsonSerializer.Serialize(payload)) + ?? throw new InvalidOperationException("Failed to deserialize the Lua completion-item test payload."); + + private static CompletionResponse? DeserializeCompletionResponse(object payload) + => JsonSerializer.Deserialize(JsonSerializer.Serialize(payload)); + + private sealed class NLogMemoryScope : IDisposable + { + private readonly LoggingConfiguration? _previousConfiguration; + + public NLogMemoryScope(LogLevel minLevel) + { + _previousConfiguration = LogManager.Configuration; + + var target = new MemoryTarget("LuaLanguageServerResponseParserTests") + { + Layout = "${level}|${message}|${exception:format=Message}" + }; + + var configuration = new LoggingConfiguration(); + configuration.AddTarget(target); + configuration.AddRule(minLevel, LogLevel.Fatal, target); + + LogManager.Configuration = configuration; + LogManager.ReconfigExistingLoggers(); + + Target = target; + } + + public MemoryTarget Target { get; } + + public IList Logs => Target.Logs; + + public void Dispose() + { + LogManager.Configuration = _previousConfiguration; + LogManager.ReconfigExistingLoggers(); + } + } + + private static HoverResponse DeserializeHoverResponse(object payload) + => JsonSerializer.Deserialize(JsonSerializer.Serialize(payload)) + ?? throw new InvalidOperationException("Failed to deserialize the hover response test payload."); + + private static SignatureHelpResponse? DeserializeSignatureHelpResponse(object payload) + => JsonSerializer.Deserialize(JsonSerializer.Serialize(payload)); + + private static WorkspaceEditResponse? DeserializeWorkspaceEditResponse(object payload) + => JsonSerializer.Deserialize(JsonSerializer.Serialize(payload)); + + private static TextEditPayload[]? DeserializeTextEdits(object payload) + => JsonSerializer.Deserialize(JsonSerializer.Serialize(payload)); + + private static DefinitionResponse DeserializeDefinitionResponse(object payload) + => JsonSerializer.Deserialize(JsonSerializer.Serialize(payload)) + ?? throw new InvalidOperationException("Failed to deserialize the definition response test payload."); + + private static ReferenceResponse[]? DeserializeReferenceResponse(object payload) + => JsonSerializer.Deserialize(JsonSerializer.Serialize(payload)); +} diff --git a/Tests/TombLib.LanguageServer.Lua.Tests/ResponseParsing/LuaLanguageServerResponseParserTests.cs b/Tests/TombLib.LanguageServer.Lua.Tests/ResponseParsing/LuaLanguageServerResponseParserTests.cs new file mode 100644 index 0000000000..a010e1b523 --- /dev/null +++ b/Tests/TombLib.LanguageServer.Lua.Tests/ResponseParsing/LuaLanguageServerResponseParserTests.cs @@ -0,0 +1,379 @@ +using NLog; +using System.Text.Json; +using TombLib.Scripting.Completion; + +namespace TombLib.LanguageServer.Lua.Tests; + +[TestClass] +public partial class LuaLanguageServerResponseParserTests +{ + [TestMethod] + public void ParseCompletionItem_AddsLocalAndUpvaluePriorityBonuses() + { + CompletionItemPayload baselineElement = CreateCompletionItem("baseline", kind: 6, detail: "variable", documentation: "plain text"); + CompletionItemPayload boostedElement = CreateCompletionItem("boosted", kind: 6, detail: "local variable", documentation: "upvalue"); + + TextCompletionItem? baselineItem = LuaLanguageServerResponseParser.ParseCompletionItem(baselineElement, 0); + TextCompletionItem? boostedItem = LuaLanguageServerResponseParser.ParseCompletionItem(boostedElement, 0); + + Assert.IsNotNull(baselineItem); + Assert.IsNotNull(boostedItem); + Assert.AreEqual(35000.0, boostedItem.Priority - baselineItem.Priority); + } + + [TestMethod] + public void ParseCompletionItem_UsesParameterIconWhenDetailContainsParameter() + { + CompletionItemPayload itemElement = CreateCompletionItem("arg", kind: 6, detail: "parameter", documentation: null); + + TextCompletionItem? item = LuaLanguageServerResponseParser.ParseCompletionItem(itemElement, 0); + + Assert.IsNotNull(item); + Assert.AreEqual(TextCompletionItemKind.Parameter, item.Kind); + } + + [TestMethod] + public void ParseCompletionItem_ParsesTextEditRange() + { + CompletionItemPayload 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 } + } + } + }); + + TextCompletionItem? item = LuaLanguageServerResponseParser.ParseCompletionItem(itemElement, 0); + + Assert.IsNotNull(item); + Assert.AreEqual("print", item.InsertText); + Assert.IsNotNull(item.TextEdit); + Assert.AreEqual(new TextCompletionPosition(1, 2), item.TextEdit.Value.InsertRange.Start); + Assert.AreEqual(new TextCompletionPosition(1, 5), item.TextEdit.Value.InsertRange.End); + Assert.IsNull(item.TextEdit.Value.ReplaceRange); + } + + [TestMethod] + public void ParseCompletionItem_ParsesInsertReplaceEditRanges() + { + CompletionItemPayload 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 } + } + } + }); + + TextCompletionItem? item = LuaLanguageServerResponseParser.ParseCompletionItem(itemElement, 0); + + Assert.IsNotNull(item); + Assert.IsNotNull(item.TextEdit); + Assert.AreEqual(new TextCompletionPosition(0, 1), item.TextEdit.Value.InsertRange.Start); + Assert.AreEqual(new TextCompletionPosition(0, 3), item.TextEdit.Value.InsertRange.End); + Assert.AreEqual(new TextCompletionPosition(0, 1), item.TextEdit.Value.ReplaceRange!.Value.Start); + Assert.AreEqual(new TextCompletionPosition(0, 6), item.TextEdit.Value.ReplaceRange!.Value.End); + Assert.AreEqual(new TextCompletionPosition(0, 6), item.TextEdit.Value.ReplacementRange.End); + } + + [TestMethod] + public void ParseCompletionItem_StripsSnippetAndPreservesFinalCaretOffset() + { + CompletionItemPayload itemElement = DeserializeCompletionItemPayload(new + { + label = "if", + kind = 15, + insertText = "if ${1:condition} then\r\n\t$0\r\nend", + insertTextFormat = 2 + }); + + TextCompletionItem? 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() + { + CompletionItemPayload itemElement = DeserializeCompletionItemPayload(new + { + label = "call", + kind = 3, + insertText = "call(${name}, ${0:done})", + insertTextFormat = 2 + }); + + TextCompletionItem? 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 ParseCompletionItems_PreservesDistinctItemsWithDifferentTextEdits() + { + IReadOnlyList items = LuaLanguageServerResponseParser.ParseCompletionItems( + [ + DeserializeCompletionItemPayload(new + { + label = "spawn", + kind = 3, + insertText = "spawn", + textEdit = new + { + newText = "spawn", + range = new + { + start = new { line = 0, character = 0 }, + end = new { line = 0, character = 3 } + } + } + }), + DeserializeCompletionItemPayload(new + { + label = "spawn", + kind = 3, + insertText = "spawn", + textEdit = new + { + newText = "spawn", + range = new + { + start = new { line = 0, character = 1 }, + end = new { line = 0, character = 4 } + } + } + }) + ]); + + Assert.AreEqual(2, items.Count); + Assert.AreEqual(new TextCompletionPosition(0, 0), items[0].TextEdit?.InsertRange.Start); + Assert.AreEqual(new TextCompletionPosition(0, 1), items[1].TextEdit?.InsertRange.Start); + } + + [TestMethod] + public void DeserializeCompletionResponse_PreservesCompletionListMetadata() + { + CompletionResponse? response = DeserializeCompletionResponse(new + { + isIncomplete = true, + items = new object[] + { + new + { + label = "spawn", + kind = 3, + insertText = "spawn", + filterText = "spawn" + } + } + }); + + Assert.IsNotNull(response); + Assert.IsTrue(response.IsIncomplete); + Assert.IsNotNull(response.Items); + Assert.AreEqual(1, response.Items.Count); + Assert.AreEqual("spawn", response.Items[0].Label); + } + + [TestMethod] + public void DeserializeCompletionResponse_ParsesArrayPayload() + { + CompletionResponse? response = DeserializeCompletionResponse(new object[] + { + new + { + label = "spawn", + kind = 3, + insertText = "spawn", + filterText = "spawn" + } + }); + + Assert.IsNotNull(response); + Assert.IsFalse(response.IsIncomplete); + Assert.IsNotNull(response.Items); + Assert.AreEqual(1, response.Items.Count); + Assert.AreEqual("spawn", response.Items[0].Label); + } + + [TestMethod] + public void DeserializeCompletionResponse_NullPayload_ReturnsEmptyResponse() + { + CompletionResponse? response = JsonSerializer.Deserialize("null"); + Assert.IsNull(response); + } + + [TestMethod] + public void DeserializeCompletionResponse_IgnoresNonBooleanIncompleteFlag() + { + CompletionResponse? response = DeserializeCompletionResponse(new + { + isIncomplete = "yes", + items = new object[] + { + new + { + label = "spawn", + kind = 3, + insertText = "spawn", + filterText = "spawn" + } + } + }); + + Assert.IsNotNull(response); + Assert.IsFalse(response.IsIncomplete); + Assert.IsNotNull(response.Items); + Assert.AreEqual(1, response.Items.Count); + } + + [TestMethod] + public void CompletionResponse_DefensivelyClonesItemList() + { + CompletionItemPayload[] items = + [ + new CompletionItemPayload + { + Label = "spawn", + Kind = 3, + InsertText = "spawn" + } + ]; + + var response = new CompletionResponse(items); + items[0] = new CompletionItemPayload + { + Label = "changed", + Kind = 14, + InsertText = "changed" + }; + + Assert.IsNotNull(response.Items); + Assert.AreEqual(1, response.Items.Count); + Assert.AreEqual("spawn", response.Items[0].Label); + } + + [TestMethod] + public void DeserializeCompletionResponse_IgnoresMalformedCompletionListItemsShape() + { + using var logScope = new NLogMemoryScope(LogLevel.Warn); + + CompletionResponse? response = DeserializeCompletionResponse(new + { + isIncomplete = true, + items = new + { + label = "spawn" + } + }); + + Assert.IsNotNull(response); + Assert.IsNull(response.Items); + Assert.IsFalse(response.IsIncomplete); + Assert.IsTrue(logScope.Logs.Any(log => log.Contains("unsupported JSON kind", StringComparison.OrdinalIgnoreCase) + && log.Contains("Object", StringComparison.Ordinal)), + string.Join(Environment.NewLine, logScope.Logs)); + } + + [TestMethod] + public void DeserializeCompletionResponse_LogsWhenCompletionListItemsPropertyIsMissing() + { + using var logScope = new NLogMemoryScope(LogLevel.Warn); + + CompletionResponse? response = DeserializeCompletionResponse(new + { + isIncomplete = true + }); + + Assert.IsNotNull(response); + Assert.IsNull(response.Items); + Assert.IsFalse(response.IsIncomplete); + Assert.IsTrue(logScope.Logs.Any(log => log.Contains("items' property was missing", StringComparison.OrdinalIgnoreCase)), + string.Join(Environment.NewLine, logScope.Logs)); + } + + [TestMethod] + public void SerializeCompletionResponse_WritesRoundTrippableCompletionListShape() + { + var response = new CompletionResponse( + [ + new CompletionItemPayload + { + Label = "spawn", + Kind = 3, + InsertText = "spawn" + } + ], + isIncomplete: true); + + string json = JsonSerializer.Serialize(response); + CompletionResponse? roundTripped = JsonSerializer.Deserialize(json); + + Assert.AreEqual("{\"isIncomplete\":true,\"items\":[{\"label\":\"spawn\",\"kind\":3,\"insertText\":\"spawn\"}]}", json); + Assert.IsNotNull(roundTripped); + Assert.IsTrue(roundTripped.IsIncomplete); + Assert.IsNotNull(roundTripped.Items); + Assert.AreEqual(1, roundTripped.Items.Count); + Assert.AreEqual("spawn", roundTripped.Items[0].Label); + } + + [TestMethod] + public void ParseCompletionItem_PreservesMarkdownIndentedCodeBlockDocumentation() + { + CompletionItemPayload itemElement = DeserializeCompletionItemPayload(new + { + label = "spawn", + kind = 3, + documentation = new + { + kind = "markdown", + value = " local value = 1" + } + }); + + TextCompletionItem? item = LuaLanguageServerResponseParser.ParseCompletionItem(itemElement, 0); + + Assert.IsNotNull(item); + Assert.AreEqual(" local value = 1", item.Description); + Assert.IsTrue(item.IsDescriptionMarkdown); + } +} diff --git a/Tests/TombLib.LanguageServer.Lua.Tests/TombLib.LanguageServer.Lua.Tests.csproj b/Tests/TombLib.LanguageServer.Lua.Tests/TombLib.LanguageServer.Lua.Tests.csproj new file mode 100644 index 0000000000..23967dc44e --- /dev/null +++ b/Tests/TombLib.LanguageServer.Lua.Tests/TombLib.LanguageServer.Lua.Tests.csproj @@ -0,0 +1,26 @@ + + + + enable + false + true + enable + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Tests/TombLib.LanguageServer.Lua.Tests/Workspace/LuaWorkspaceSnapshotTrackerTests.cs b/Tests/TombLib.LanguageServer.Lua.Tests/Workspace/LuaWorkspaceSnapshotTrackerTests.cs new file mode 100644 index 0000000000..1003d66c6d --- /dev/null +++ b/Tests/TombLib.LanguageServer.Lua.Tests/Workspace/LuaWorkspaceSnapshotTrackerTests.cs @@ -0,0 +1,160 @@ +namespace TombLib.LanguageServer.Lua.Tests; + +[TestClass] +public class LuaWorkspaceSnapshotTrackerTests +{ + [TestMethod] + public void BuildDeltaBatch_ReportsCreatedChangedAndDeletedPaths() + { + string workspaceRoot = Path.Combine(Path.GetTempPath(), "LuaWorkspaceSnapshot_" + Guid.NewGuid().ToString("N")); + string scriptsDirectoryPath = Path.Combine(workspaceRoot, "Scripts"); + string changedFilePath = Path.Combine(scriptsDirectoryPath, "changed.lua"); + string deletedFilePath = Path.Combine(scriptsDirectoryPath, "deleted.lua"); + string createdFilePath = Path.Combine(scriptsDirectoryPath, "created.lua"); + + try + { + Directory.CreateDirectory(scriptsDirectoryPath); + File.WriteAllText(changedFilePath, "return 1"); + File.WriteAllText(deletedFilePath, "return 2"); + + var tracker = CreateTracker(workspaceRoot); + tracker.CaptureTrackedSnapshot(); + Dictionary previousSnapshot = tracker.CloneTrackedSnapshot(); + + File.WriteAllText(changedFilePath, "return 123456"); + File.Delete(deletedFilePath); + File.WriteAllText(createdFilePath, "return 3"); + + Dictionary currentSnapshot = tracker.ReplaceTrackedSnapshotWithCurrent(); + FileChangeBatch batch = LuaWorkspaceSnapshotTracker.BuildDeltaBatch(previousSnapshot, currentSnapshot); + + string normalizedChangedFilePath = LanguageServerPathHelper.NormalizeLocalPath(changedFilePath); + string normalizedDeletedFilePath = LanguageServerPathHelper.NormalizeLocalPath(deletedFilePath); + string normalizedCreatedFilePath = LanguageServerPathHelper.NormalizeLocalPath(createdFilePath); + + Assert.AreEqual(3, batch.Count); + Assert.AreEqual(FileChangeKind.Changed, GetChange(batch, normalizedChangedFilePath).Kind); + Assert.AreEqual(FileChangeKind.Deleted, GetChange(batch, normalizedDeletedFilePath).Kind); + Assert.AreEqual(FileChangeKind.Created, GetChange(batch, normalizedCreatedFilePath).Kind); + } + finally + { + if (Directory.Exists(workspaceRoot)) + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [TestMethod] + public void BuildDeltaBatch_DoesNotReportDeletionWhenTrackedPathStillExistsButCurrentSnapshotMissesIt() + { + string workspaceRoot = Path.Combine(Path.GetTempPath(), "LuaWorkspaceSnapshotMissingCurrent_" + Guid.NewGuid().ToString("N")); + string scriptsDirectoryPath = Path.Combine(workspaceRoot, "Scripts"); + string existingFilePath = Path.Combine(scriptsDirectoryPath, "existing.lua"); + + try + { + Directory.CreateDirectory(scriptsDirectoryPath); + File.WriteAllText(existingFilePath, "return 1"); + + var tracker = CreateTracker(workspaceRoot); + tracker.CaptureTrackedSnapshot(); + Dictionary previousSnapshot = tracker.CloneTrackedSnapshot(); + var currentSnapshot = new Dictionary(StringComparer.OrdinalIgnoreCase); + + FileChangeBatch batch = LuaWorkspaceSnapshotTracker.BuildDeltaBatch(previousSnapshot, currentSnapshot); + + Assert.AreEqual(0, batch.Count); + } + finally + { + if (Directory.Exists(workspaceRoot)) + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [TestMethod] + public void BuildDeltaBatch_ReportsChangedPathWhenContentChangesWithoutLengthOrTimestampChange() + { + string workspaceRoot = Path.Combine(Path.GetTempPath(), "LuaWorkspaceSnapshotFingerprint_" + Guid.NewGuid().ToString("N")); + string scriptsDirectoryPath = Path.Combine(workspaceRoot, "Scripts"); + string changedFilePath = Path.Combine(scriptsDirectoryPath, "changed.lua"); + + try + { + Directory.CreateDirectory(scriptsDirectoryPath); + File.WriteAllText(changedFilePath, "return 1"); + DateTime baselineWriteTime = File.GetLastWriteTimeUtc(changedFilePath); + + var tracker = CreateTracker(workspaceRoot); + tracker.CaptureTrackedSnapshot(); + Dictionary previousSnapshot = tracker.CloneTrackedSnapshot(); + + File.WriteAllText(changedFilePath, "return 2"); + File.SetLastWriteTimeUtc(changedFilePath, baselineWriteTime); + + Dictionary currentSnapshot = tracker.ReplaceTrackedSnapshotWithCurrent(); + FileChangeBatch batch = LuaWorkspaceSnapshotTracker.BuildDeltaBatch(previousSnapshot, currentSnapshot); + + string normalizedChangedFilePath = LanguageServerPathHelper.NormalizeLocalPath(changedFilePath); + + Assert.AreEqual(1, batch.Count); + Assert.AreEqual(FileChangeKind.Changed, GetChange(batch, normalizedChangedFilePath).Kind); + } + finally + { + if (Directory.Exists(workspaceRoot)) + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [TestMethod] + public void ApplyChanges_UpdatesTrackedSnapshotForSubsequentRecoveryDiff() + { + string workspaceRoot = Path.Combine(Path.GetTempPath(), "LuaWorkspaceSnapshotApply_" + Guid.NewGuid().ToString("N")); + string scriptsDirectoryPath = Path.Combine(workspaceRoot, "Scripts"); + string createdFilePath = Path.Combine(scriptsDirectoryPath, "created.lua"); + + try + { + Directory.CreateDirectory(scriptsDirectoryPath); + + var tracker = CreateTracker(workspaceRoot); + tracker.CaptureTrackedSnapshot(); + + File.WriteAllText(createdFilePath, "return 1"); + string normalizedCreatedFilePath = LanguageServerPathHelper.NormalizeLocalPath(createdFilePath); + + tracker.ApplyChanges( + [ + new WorkspaceFileChange(normalizedCreatedFilePath, FileChangeKind.Created) + ]); + + Dictionary previousSnapshot = tracker.CloneTrackedSnapshot(); + File.Delete(createdFilePath); + + Dictionary currentSnapshot = tracker.ReplaceTrackedSnapshotWithCurrent(); + FileChangeBatch batch = LuaWorkspaceSnapshotTracker.BuildDeltaBatch(previousSnapshot, currentSnapshot); + + Assert.AreEqual(1, batch.Count); + Assert.AreEqual(FileChangeKind.Deleted, batch.Entries[0].Kind); + Assert.AreEqual(normalizedCreatedFilePath, batch.Entries[0].Path); + } + finally + { + if (Directory.Exists(workspaceRoot)) + Directory.Delete(workspaceRoot, recursive: true); + } + } + + private static LuaWorkspaceSnapshotTracker CreateTracker(string workspaceRootDirectoryPath) => new( + LanguageServerPathHelper.NormalizeLocalPath(workspaceRootDirectoryPath), + [ + new WorkspaceWatchSpecification("*.lua", IncludeSubdirectories: true), + new WorkspaceWatchSpecification(".API", IncludeSubdirectories: false), + new WorkspaceWatchSpecification(".luarc.*", IncludeSubdirectories: false) + ]); + + private static WorkspaceFileChange GetChange(FileChangeBatch batch, string filePath) + => batch.Entries.First(change => string.Equals(change.Path, filePath, StringComparison.OrdinalIgnoreCase)); +} diff --git a/Tests/TombLib.Tests/ClassicScript/ClassicScriptDocumentFormatterTests.cs b/Tests/TombLib.Tests/ClassicScript/ClassicScriptDocumentFormatterTests.cs new file mode 100644 index 0000000000..50c9a11117 --- /dev/null +++ b/Tests/TombLib.Tests/ClassicScript/ClassicScriptDocumentFormatterTests.cs @@ -0,0 +1,49 @@ +using TombLib.Scripting.ClassicScript.Cleaning; + +namespace TombLib.Tests; + +[TestClass] +public class ClassicScriptDocumentFormatterTests +{ + [TestMethod] + public void FormatDocument_UsesConfiguredSpacingRulesAndTrimsTrailingWhitespace() + { + var formatter = new ClassicScriptDocumentFormatter + { + PreEqualSpace = true, + PostEqualSpace = true, + PreCommaSpace = false, + PostCommaSpace = true, + ReduceSpaces = false + }; + + string formatted = formatter.FormatDocument("Customize= CUST_BAR,foo \r\nLegend =1\t"); + + Assert.AreEqual("Customize = CUST_BAR, foo" + Environment.NewLine + "Legend = 1", formatted); + } + + [TestMethod] + public void FormatDocument_TrimOnlyOnlyRemovesTrailingWhitespace() + { + var formatter = new ClassicScriptDocumentFormatter + { + PreEqualSpace = false, + PostEqualSpace = false, + PreCommaSpace = false, + PostCommaSpace = false, + ReduceSpaces = false + }; + + string formatted = formatter.FormatDocument("Legend = 1 \r\nCustomize = CUST_BAR, foo\t", trimOnly: true); + + Assert.AreEqual("Legend = 1" + Environment.NewLine + "Customize = CUST_BAR, foo", formatted); + } + + [TestMethod] + public void FormatCompilerOutput_RemovesSpacesBeforeEqualsWithoutChangingSpacesAfterEquals() + { + string formatted = ClassicScriptDocumentFormatter.FormatCompilerOutput("Legend =1 \r\nCustomize = CUST_BAR\t"); + + Assert.AreEqual("Legend=1" + Environment.NewLine + "Customize= CUST_BAR", formatted); + } +} \ No newline at end of file diff --git a/Tests/TombLib.Tests/ClassicScript/ClassicScriptNodesProviderTests.cs b/Tests/TombLib.Tests/ClassicScript/ClassicScriptNodesProviderTests.cs new file mode 100644 index 0000000000..639700000d --- /dev/null +++ b/Tests/TombLib.Tests/ClassicScript/ClassicScriptNodesProviderTests.cs @@ -0,0 +1,49 @@ +using DarkUI.Controls; +using TombLib.Scripting.ClassicScript.Enums; +using TombLib.Scripting.ClassicScript.ContentNodes; + +namespace TombLib.Tests; + +[TestClass] +public class ClassicScriptNodesProviderTests +{ + [TestMethod] + public void GetNodes_ReturnsExpectedGroupsForMatchingContent() + { + var provider = new ClassicScriptNodesProvider(); + const string content = "[Options]\r\nName = Caves ; comment\r\n#include \"strings.txt\"\r\n#define SECRET_FLAG ENABLED\r\n[Level]\r\n"; + + IReadOnlyList nodes = provider.GetNodes(content, string.Empty); + + Assert.AreEqual(4, nodes.Count); + Assert.AreEqual("Sections", nodes[0].Text); + Assert.AreEqual("[Options]", nodes[0].Nodes[0].Text); + Assert.AreEqual(ObjectType.Section, nodes[0].Nodes[0].Tag); + + Assert.AreEqual("Levels", nodes[1].Text); + Assert.AreEqual("Caves", nodes[1].Nodes[0].Text); + Assert.AreEqual(ObjectType.Level, nodes[1].Nodes[0].Tag); + + Assert.AreEqual("Includes", nodes[2].Text); + Assert.AreEqual("strings.txt", nodes[2].Nodes[0].Text); + Assert.AreEqual(ObjectType.Include, nodes[2].Nodes[0].Tag); + + Assert.AreEqual("Defines", nodes[3].Text); + Assert.AreEqual("SECRET_FLAG", nodes[3].Nodes[0].Text); + Assert.AreEqual(ObjectType.Define, nodes[3].Nodes[0].Tag); + } + + [TestMethod] + public void GetNodes_FiltersOutUnmatchedGroups() + { + var provider = new ClassicScriptNodesProvider(); + const string content = "[Options]\r\nName = Caves\r\n#include \"scripts.dat\"\r\n#define SECRET_FLAG ENABLED\r\n"; + + IReadOnlyList nodes = provider.GetNodes(content, "script"); + + Assert.AreEqual(1, nodes.Count); + Assert.AreEqual("Includes", nodes[0].Text); + Assert.AreEqual(1, nodes[0].Nodes.Count); + Assert.AreEqual("scripts.dat", nodes[0].Nodes[0].Text); + } +} \ No newline at end of file diff --git a/Tests/TombLib.Tests/ClassicScript/ClassicScriptReferenceInfoServiceTests.cs b/Tests/TombLib.Tests/ClassicScript/ClassicScriptReferenceInfoServiceTests.cs new file mode 100644 index 0000000000..7408a2eb31 --- /dev/null +++ b/Tests/TombLib.Tests/ClassicScript/ClassicScriptReferenceInfoServiceTests.cs @@ -0,0 +1,37 @@ +using TombLib.Scripting.ClassicScript.Enums; +using TombLib.Scripting.ClassicScript.Services; + +namespace TombLib.Tests; + +[TestClass] +public class ClassicScriptReferenceInfoServiceTests +{ + private readonly ClassicScriptReferenceInfoService _service = new(); + + [TestMethod] + public void GetReferenceInfo_KnownOldCommand_ReturnsDescriptionWithoutMissingMessage() + { + ClassicScriptReferenceInfo info = _service.GetReferenceInfo("Legend", ReferenceType.OldCommand); + + Assert.IsFalse(string.IsNullOrWhiteSpace(info.Description)); + Assert.IsNull(info.MissingDescriptionMessage); + } + + [TestMethod] + public void GetReferenceInfo_UnknownMnemonicConstant_ReturnsFlagMissingMessage() + { + ClassicScriptReferenceInfo info = _service.GetReferenceInfo("unknown_flag", ReferenceType.MnemonicConstant); + + Assert.AreEqual(string.Empty, info.Description); + Assert.AreEqual("No description found for the UNKNOWN_FLAG flag.", info.MissingDescriptionMessage); + } + + [TestMethod] + public void GetReferenceInfo_UnknownHexValue_ReturnsContextualMissingMessage() + { + ClassicScriptReferenceInfo info = _service.GetReferenceInfo("$123", ReferenceType.MnemonicConstant); + + Assert.AreEqual(string.Empty, info.Description); + Assert.AreEqual("Couldn't identify the hexadecimal value for the given context.", info.MissingDescriptionMessage); + } +} \ No newline at end of file diff --git a/Tests/TombLib.Tests/ClassicScript/ClassicScriptSyntaxCatalogServiceTests.cs b/Tests/TombLib.Tests/ClassicScript/ClassicScriptSyntaxCatalogServiceTests.cs new file mode 100644 index 0000000000..cefc067f98 --- /dev/null +++ b/Tests/TombLib.Tests/ClassicScript/ClassicScriptSyntaxCatalogServiceTests.cs @@ -0,0 +1,42 @@ +using TombLib.Scripting.Specifications.ClassicScript.Syntaxes; + +namespace TombLib.Tests; + +[TestClass] +public class ClassicScriptSyntaxCatalogServiceTests +{ + [TestMethod] + public void GetCommandDefinition_UsesCaseInsensitiveLookupAndExposesParsedMetadata() + { + var service = new ClassicScriptSyntaxCatalogService(); + + ClassicScriptSyntaxDefinition customize = service.GetCommandDefinition("customize") + ?? throw new AssertFailedException("Customize syntax definition was not loaded."); + ClassicScriptSyntaxDefinition legend = service.GetCommandDefinition("Legend") + ?? throw new AssertFailedException("Legend syntax definition was not loaded."); + + Assert.AreEqual("Level", customize.ApplicableSection); + Assert.AreEqual(2, customize.ArgumentCount); + Assert.IsTrue(customize.HasArrayArguments); + StringAssert.Contains(customize.SyntaxText, "Customize="); + + Assert.AreEqual("Level", legend.ApplicableSection); + Assert.AreEqual(1, legend.ArgumentCount); + Assert.IsFalse(legend.HasArrayArguments); + StringAssert.Contains(legend.SyntaxText, "Legend="); + } + + [TestMethod] + public void GetCustomizeAndParameterSyntax_ReturnExpectedEntries() + { + var service = new ClassicScriptSyntaxCatalogService(); + + string? customizeSyntax = service.GetCustomizeSyntax("cust_bar"); + string? parameterSyntax = service.GetParameterSyntax("param_rect"); + + Assert.IsFalse(string.IsNullOrWhiteSpace(customizeSyntax)); + Assert.IsFalse(string.IsNullOrWhiteSpace(parameterSyntax)); + StringAssert.Contains(customizeSyntax, "CUST_BAR"); + StringAssert.Contains(parameterSyntax, "PARAM_RECT"); + } +} \ No newline at end of file diff --git a/Tests/TombLib.Tests/ClassicScript/ClassicScriptSyntaxDefinitionsLoaderTests.cs b/Tests/TombLib.Tests/ClassicScript/ClassicScriptSyntaxDefinitionsLoaderTests.cs new file mode 100644 index 0000000000..e90292946c --- /dev/null +++ b/Tests/TombLib.Tests/ClassicScript/ClassicScriptSyntaxDefinitionsLoaderTests.cs @@ -0,0 +1,76 @@ +using System.Text; +using TombLib.Scripting.Specifications.ClassicScript.Syntaxes; + +namespace TombLib.Tests; + +[TestClass] +public class ClassicScriptSyntaxDefinitionsLoaderTests +{ + [TestMethod] + public void Load_ParsesSectionArgumentCountAndArrayMetadata() + { + string filePath = CreateTemporaryResx( + ("Legend", "[Level] Legend= {MESSAGE_STRING}"), + ("Customize", "[Level] Customize= {TYPE (CUST_...)}, {Arguments (*Array*)}"), + ("LevelPC", "[PCExtensions] Level= .TR4 ; Default value")); + + try + { + var loader = new ClassicScriptSyntaxDefinitionsLoader(); + Dictionary definitions = loader.Load(filePath) + .ToDictionary(definition => definition.Key, StringComparer.OrdinalIgnoreCase); + + Assert.AreEqual(3, definitions.Count); + + ClassicScriptSyntaxDefinition legend = definitions["Legend"]; + Assert.AreEqual("Level", legend.ApplicableSection); + Assert.AreEqual(1, legend.ArgumentCount); + Assert.IsFalse(legend.HasArrayArguments); + + ClassicScriptSyntaxDefinition customize = definitions["Customize"]; + Assert.AreEqual("Level", customize.ApplicableSection); + Assert.AreEqual(2, customize.ArgumentCount); + Assert.IsTrue(customize.HasArrayArguments); + + ClassicScriptSyntaxDefinition levelPc = definitions["LevelPC"]; + Assert.AreEqual("PCExtensions", levelPc.ApplicableSection); + Assert.AreEqual(1, levelPc.ArgumentCount); + Assert.IsFalse(levelPc.HasArrayArguments); + } + finally + { + if (File.Exists(filePath)) + File.Delete(filePath); + } + } + + [TestMethod] + public void Load_MissingFile_ReturnsEmptyList() + { + var loader = new ClassicScriptSyntaxDefinitionsLoader(); + + IReadOnlyList definitions = loader.Load(Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".resx")); + + Assert.AreEqual(0, definitions.Count); + } + + private static string CreateTemporaryResx(params (string Key, string Value)[] entries) + { + string filePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".resx"); + var builder = new StringBuilder(); + + builder.AppendLine(""); + builder.AppendLine(""); + + foreach ((string key, string value) in entries) + { + builder.AppendLine($" "); + builder.AppendLine($" {System.Security.SecurityElement.Escape(value)}"); + builder.AppendLine(" "); + } + + builder.AppendLine(""); + File.WriteAllText(filePath, builder.ToString()); + return filePath; + } +} \ No newline at end of file diff --git a/Tests/TombLib.Tests/Completion/ClassicScriptCompletionSessionCoordinatorTests.cs b/Tests/TombLib.Tests/Completion/ClassicScriptCompletionSessionCoordinatorTests.cs new file mode 100644 index 0000000000..f107f92fbf --- /dev/null +++ b/Tests/TombLib.Tests/Completion/ClassicScriptCompletionSessionCoordinatorTests.cs @@ -0,0 +1,49 @@ +using System.Linq; +using TombLib.Scripting.ClassicScript.Services; +using TombLib.Scripting.Completion; + +namespace TombLib.Tests; + +[TestClass] +public class ClassicScriptCompletionSessionCoordinatorTests +{ + [TestMethod] + public async Task GetTextEnteredDecisionAsync_AfterEqualsSpace_OpensContextualCompletionAtCaret() + { + var coordinator = new ClassicScriptCompletionSessionCoordinator(); + + TextCompletionSessionDecision decision = await coordinator.GetTextEnteredDecisionAsync( + "Horizon= ", + null, + 9, + " ", + false); + + Assert.IsNotNull(decision.Items); + Assert.IsTrue(decision.Items.Any(item => item.Label == "ENABLED")); + Assert.IsTrue(decision.Items.Any(item => item.Label == "DISABLED")); + Assert.AreEqual(9, decision.StartOffset); + Assert.AreEqual(9, decision.EndOffset); + } + + [TestMethod] + public async Task GetTextEnteredDecisionAsync_AfterCommaSpace_OpensContextualCompletionAtCaret() + { + var coordinator = new ClassicScriptCompletionSessionCoordinator(); + + const string text = "Customize= CUST_LOOK_TRASPARENT, "; + + TextCompletionSessionDecision decision = await coordinator.GetTextEnteredDecisionAsync( + text, + null, + text.Length, + " ", + false); + + Assert.IsNotNull(decision.Items); + Assert.IsTrue(decision.Items.Any(item => item.Label == "ENABLED")); + Assert.IsTrue(decision.Items.Any(item => item.Label == "DISABLED")); + Assert.AreEqual(text.Length, decision.StartOffset); + Assert.AreEqual(text.Length, decision.EndOffset); + } +} \ No newline at end of file diff --git a/Tests/TombLib.Tests/Completion/ClassicScriptEditorCompletionWindowTests.cs b/Tests/TombLib.Tests/Completion/ClassicScriptEditorCompletionWindowTests.cs new file mode 100644 index 0000000000..475c898e8d --- /dev/null +++ b/Tests/TombLib.Tests/Completion/ClassicScriptEditorCompletionWindowTests.cs @@ -0,0 +1,75 @@ +using ICSharpCode.AvalonEdit.CodeCompletion; +using System.Collections.Generic; +using System.Windows; +using System.Windows.Threading; +using TombLib.Scripting.ClassicScript; +using TombLib.Scripting.ClassicScript.Services; +using TombLib.Scripting.Completion; +using TombLib.Scripting.Objects; + +namespace TombLib.Tests; + +[TestClass] +public class ClassicScriptEditorCompletionWindowTests +{ + private static readonly ITextCompletionProvider CompletionProvider = new ClassicScriptCompletionProvider(); + + [TestMethod] + public void EmptyLineCompletion_ItemsExposeKindDetailText() + { + IReadOnlyList completionItems = CompletionProvider.GetCompletionItems( + new TextCompletionContext(string.Empty, 0, TextCompletionTrigger.EmptyLine)); + + Assert.AreEqual("Old Command", completionItems.First(item => item.Kind == TextCompletionItemKind.OldCommand).Detail); + Assert.AreEqual("New Command", completionItems.First(item => item.Kind == TextCompletionItemKind.NewCommand).Detail); + Assert.AreEqual("Section", completionItems.First(item => item.Kind == TextCompletionItemKind.Section).Detail); + Assert.AreEqual("Directive", completionItems.First(item => item.Kind == TextCompletionItemKind.Directive).Detail); + } + + [TestMethod] + public void EmptyLineCompletion_OpensCompletionWindowAtLineOffset() + { + WPFTestHelper.RunInSta(() => + { + var editor = new ClassicScriptEditor(new Version(1, 0)) + { + Text = string.Empty + }; + + Window hostWindow = WPFTestHelper.ShowInHostWindow(editor); + + try + { + ICompletionData[] completionItems = [.. + CompletionProvider.GetCompletionItems( + new TextCompletionContext(editor.Text, editor.CaretOffset, TextCompletionTrigger.EmptyLine)) + .Select(item => new CompletionData(item))]; + int lineOffset = editor.Document.GetLineByOffset(editor.CaretOffset).Offset; + + bool opened = (bool)(WPFTestHelper.InvokeInstanceMethod( + editor, + "TryOpenCompletionWindow", + [typeof(IEnumerable), typeof(int?), typeof(int?), typeof(int), typeof(int)], + completionItems, + lineOffset, + null, + 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.IsTrue(completionWindow.CompletionList.CompletionData.Count > 0); + Assert.AreEqual(0, completionWindow.StartOffset); + } + finally + { + (WPFTestHelper.FindInstanceField(editor.GetType(), "_completionWindow")?.GetValue(editor) as CompletionWindow)?.Close(); + hostWindow.Close(); + } + }); + } +} diff --git a/Tests/TombLib.Tests/Completion/EditorCompletionTriggerHelperTests.cs b/Tests/TombLib.Tests/Completion/EditorCompletionTriggerHelperTests.cs new file mode 100644 index 0000000000..a33f4382eb --- /dev/null +++ b/Tests/TombLib.Tests/Completion/EditorCompletionTriggerHelperTests.cs @@ -0,0 +1,29 @@ +using System.Windows.Input; +using TombLib.Scripting.Utils; + +namespace TombLib.Tests; + +[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/Tests/TombLib.Tests/Completion/GameFlowEditorCompletionWindowTests.cs b/Tests/TombLib.Tests/Completion/GameFlowEditorCompletionWindowTests.cs new file mode 100644 index 0000000000..ae8032b437 --- /dev/null +++ b/Tests/TombLib.Tests/Completion/GameFlowEditorCompletionWindowTests.cs @@ -0,0 +1,114 @@ +using ICSharpCode.AvalonEdit.CodeCompletion; +using System.Windows; +using System.Windows.Threading; +using TombLib.Scripting.GameFlowScript; +using TombLib.Scripting.Objects; + +namespace TombLib.Tests; + +[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/Tests/TombLib.Tests/Completion/LuaCompletionDataTests.cs b/Tests/TombLib.Tests/Completion/LuaCompletionDataTests.cs new file mode 100644 index 0000000000..1fc0fd4f5e --- /dev/null +++ b/Tests/TombLib.Tests/Completion/LuaCompletionDataTests.cs @@ -0,0 +1,75 @@ +using ICSharpCode.AvalonEdit.Document; +using System.Reflection; +using TombLib.Scripting.Completion; +using TombLib.Scripting.Objects; + +namespace TombLib.Tests; + +[TestClass] +public class LuaCompletionDataTests +{ + [TestMethod] + public void ResolveCompletionSegment_UsesInsertRangeByDefault() + { + (int offset, int length) = InvokeResolveCompletionSegment( + new TextDocument("abcdef"), + fallbackOffset: 1, + fallbackLength: 2, + new TextCompletionTextEdit( + new TextCompletionRange(new TextCompletionPosition(0, 2), new TextCompletionPosition(0, 3)), + new TextCompletionRange(new TextCompletionPosition(0, 2), new TextCompletionPosition(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 TextCompletionTextEdit( + new TextCompletionRange(new TextCompletionPosition(0, 2), new TextCompletionPosition(0, 3)), + new TextCompletionRange(new TextCompletionPosition(0, 2), new TextCompletionPosition(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 TextCompletionTextEdit( + new TextCompletionRange(new TextCompletionPosition(4, 0), new TextCompletionPosition(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, + TextCompletionTextEdit? textEdit, + bool useReplaceRange) + { + MethodInfo method = typeof(CompletionData).GetMethod( + "ResolveCompletionSegment", + BindingFlags.NonPublic | BindingFlags.Static, + binder: null, + [typeof(TextDocument), typeof(int), typeof(int), typeof(TextCompletionTextEdit?), 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.")); + } +} diff --git a/Tests/TombLib.Tests/Completion/LuaCompletionItemTests.cs b/Tests/TombLib.Tests/Completion/LuaCompletionItemTests.cs new file mode 100644 index 0000000000..96ea63e409 --- /dev/null +++ b/Tests/TombLib.Tests/Completion/LuaCompletionItemTests.cs @@ -0,0 +1,50 @@ +using TombLib.Scripting.Completion; + +namespace TombLib.Tests; + +[TestClass] +public class LuaCompletionItemTests +{ + [TestMethod] + public async Task WithRequestContext_PreservesRequestMetadataAcrossResolve() + { + var item = new TextCompletionItem( + "spawn", + insertText: "spawn", + resolveAsync: _ => Task.FromResult(new TextCompletionItem("spawn", detail: "function", insertCaretOffset: 2)), + insertCaretOffset: 2) + .WithRequestContext(4, 7); + + TextCompletionItem 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() + { + TextCompletionTextEdit textEdit = new( + new TextCompletionRange(new TextCompletionPosition(0, 2), new TextCompletionPosition(0, 5))); + + var item = new TextCompletionItem( + "Color", + insertText: "Color", + resolveAsync: _ => Task.FromResult(new TextCompletionItem("Color", detail: "enum", textEdit: textEdit)), + textEdit: textEdit) + .WithFilteredCommitContext(6, 2); + + Assert.AreEqual(6, item.RequestDocumentVersion); + Assert.AreEqual(2, item.RequestGeneration); + Assert.IsNull(item.TextEdit); + + TextCompletionItem resolvedItem = await item.ResolveAsync(); + + Assert.AreEqual("enum", resolvedItem.Detail); + Assert.IsNull(resolvedItem.TextEdit); + Assert.AreEqual(6, resolvedItem.RequestDocumentVersion); + Assert.AreEqual(2, resolvedItem.RequestGeneration); + } +} diff --git a/Tests/TombLib.Tests/Completion/LuaEditorCompletionWindowTests.cs b/Tests/TombLib.Tests/Completion/LuaEditorCompletionWindowTests.cs new file mode 100644 index 0000000000..36dc339af3 --- /dev/null +++ b/Tests/TombLib.Tests/Completion/LuaEditorCompletionWindowTests.cs @@ -0,0 +1,398 @@ +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.Completion; +using TombLib.Scripting.Editing; +using TombLib.Scripting.Diagnostics; +using TombLib.Scripting.Hover; +using TombLib.Scripting.Lua; +using TombLib.Scripting.Navigation; +using TombLib.Scripting.Objects; +using TombLib.Scripting.Signatures; +using static TombLib.Tests.WPFTestHelper; + +namespace TombLib.Tests; + +[TestClass] +public class LuaEditorCompletionWindowTests +{ + [TestMethod] + public void RequestCompletionAsync_OpensCompletionWindowWithCurrentItemsAndOffsets() + { + RunInSta(() => + { + var provider = new FakeLuaCompletionProvider(); + + provider.EnqueueCompletionResponse( + [ + new TextCompletionItem("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 TextCompletionItem("spawn_room", detail: "local variable") + ]); + + provider.EnqueueCompletionResponse( + [ + new TextCompletionItem("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 = (CompletionData)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 TextCompletionItem("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(); + } + }); + } + + [Ignore("Obsolete reflection-based coverage for pre-shared completion/signature internals. Replace with shared controller tests.")] + public void RequestCompletionAsync_DismissesSignatureHelpBeforeOpeningWindow() + { + RunInSta(() => + { + var provider = new FakeLuaCompletionProvider(); + + provider.EnqueueCompletionResponse( + [ + new TextCompletionItem("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(); + } + }); + } + + [Ignore("Obsolete reflection-based coverage for pre-shared completion tooltip internals. Replace with shared controller tests.")] + public void UpdateCompletionTooltipAsync_ResolvesSelectedCompletionItem() + { + RunInSta(() => + { + int resolveCallCount = 0; + var provider = new FakeLuaCompletionProvider(); + + provider.EnqueueCompletionResponse( + [ + new TextCompletionItem( + "spawn_room", + resolveAsync: _ => + { + resolveCallCount++; + return Task.FromResult(new TextCompletionItem("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(); + } + }); + } + + [Ignore("Obsolete reflection-based coverage for pre-shared completion tooltip internals. Replace with shared controller tests.")] + public void UpdateCompletionTooltipAsync_HidesTooltipWhenSelectionIsCleared() + { + RunInSta(() => + { + var provider = new FakeLuaCompletionProvider(); + + provider.EnqueueCompletionResponse( + [ + new TextCompletionItem("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> GetReferencesAsync(TextReferenceRequest request, CancellationToken cancellationToken = default) + => Task.FromResult>([]); + + public Task RenameSymbolAsync(TextRenameRequest request, CancellationToken cancellationToken = default) + => Task.FromResult(null); + + public Task FormatDocumentAsync(TextFormatRequest request, CancellationToken cancellationToken = default) + => Task.FromResult(null); + + public Task GetSignatureHelpAsync(string filePath, string content, + int line, int column, CancellationToken cancellationToken = default) + => Task.FromResult(null); + + public void Dispose() + { } + } +} diff --git a/Tests/TombLib.Tests/GameFlow/GameFlowDefinitionsProviderTests.cs b/Tests/TombLib.Tests/GameFlow/GameFlowDefinitionsProviderTests.cs new file mode 100644 index 0000000000..1cc98e9c64 --- /dev/null +++ b/Tests/TombLib.Tests/GameFlow/GameFlowDefinitionsProviderTests.cs @@ -0,0 +1,15 @@ +using TombLib.Scripting.Specifications.GameFlow; + +namespace TombLib.Tests; + +[TestClass] +public class GameFlowDefinitionsProviderTests +{ + [TestMethod] + public void Definitions_AreLoadedFromBundledJson() + { + Assert.IsTrue(GameFlowDefinitionsProvider.Sections.Count > 0); + Assert.IsTrue(GameFlowDefinitionsProvider.Properties.Count > 0); + Assert.IsTrue(GameFlowDefinitionsProvider.Constants.Count > 0); + } +} \ No newline at end of file diff --git a/Tests/TombLib.Tests/GameFlow/GameFlowNodesProviderTests.cs b/Tests/TombLib.Tests/GameFlow/GameFlowNodesProviderTests.cs new file mode 100644 index 0000000000..202de53916 --- /dev/null +++ b/Tests/TombLib.Tests/GameFlow/GameFlowNodesProviderTests.cs @@ -0,0 +1,43 @@ +using DarkUI.Controls; +using TombLib.Scripting.GameFlowScript.Enums; +using TombLib.Scripting.GameFlowScript.ContentNodes; + +namespace TombLib.Tests; + +[TestClass] +public class GameFlowNodesProviderTests +{ + [TestMethod] + public void GetNodes_ReturnsSectionAndLevelGroupsForMatchingContent() + { + var provider = new GameFlowNodesProvider(); + const string content = "TITLE:\r\nLEVEL: Caves // comment\r\nEND:\r\n"; + + IReadOnlyList nodes = provider.GetNodes(content, string.Empty); + + Assert.AreEqual(2, nodes.Count); + Assert.AreEqual("Sections", nodes[0].Text); + Assert.AreEqual(1, nodes[0].Nodes.Count); + Assert.AreEqual("TITLE", nodes[0].Nodes[0].Text); + Assert.AreEqual(ObjectType.Section, nodes[0].Nodes[0].Tag); + + Assert.AreEqual("Levels", nodes[1].Text); + Assert.AreEqual(1, nodes[1].Nodes.Count); + Assert.AreEqual("Caves", nodes[1].Nodes[0].Text); + Assert.AreEqual(ObjectType.Level, nodes[1].Nodes[0].Tag); + } + + [TestMethod] + public void GetNodes_FiltersOutUnmatchedGroups() + { + var provider = new GameFlowNodesProvider(); + const string content = "TITLE:\r\nLEVEL: Caves\r\nLEVEL: Venice\r\n"; + + IReadOnlyList nodes = provider.GetNodes(content, "ven"); + + Assert.AreEqual(1, nodes.Count); + Assert.AreEqual("Levels", nodes[0].Text); + Assert.AreEqual(1, nodes[0].Nodes.Count); + Assert.AreEqual("Venice", nodes[0].Nodes[0].Text); + } +} \ No newline at end of file diff --git a/Tests/TombLib.Tests/GlobalUsings.cs b/Tests/TombLib.Tests/GlobalUsings.cs new file mode 100644 index 0000000000..4d58e4d987 --- /dev/null +++ b/Tests/TombLib.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using TombLib.Scripting.Core.Lua; \ No newline at end of file diff --git a/Tests/TombLib.Tests/Highlighting/LuaTextMateSyntaxHighlightingTests.cs b/Tests/TombLib.Tests/Highlighting/LuaTextMateSyntaxHighlightingTests.cs new file mode 100644 index 0000000000..1f0a32e5aa --- /dev/null +++ b/Tests/TombLib.Tests/Highlighting/LuaTextMateSyntaxHighlightingTests.cs @@ -0,0 +1,43 @@ +using ICSharpCode.AvalonEdit; +using ICSharpCode.AvalonEdit.Highlighting; +using TombLib.Scripting.Highlighting; + +namespace TombLib.Tests; + +[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); + }); + } +} diff --git a/Tests/TombLib.Tests/Highlighting/TextMateHighlightingTests.cs b/Tests/TombLib.Tests/Highlighting/TextMateHighlightingTests.cs new file mode 100644 index 0000000000..8058481a7a --- /dev/null +++ b/Tests/TombLib.Tests/Highlighting/TextMateHighlightingTests.cs @@ -0,0 +1,113 @@ +using ICSharpCode.AvalonEdit.Document; +using System.Windows.Media; +using TombLib.Scripting.Highlighting; +using TombLib.Scripting.Lua; + +namespace TombLib.Tests; + +[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(); + } +} diff --git a/TombLib/TombLib.Test/ObjectGroupTests.cs b/Tests/TombLib.Tests/LevelData/ObjectGroupTests.cs similarity index 97% rename from TombLib/TombLib.Test/ObjectGroupTests.cs rename to Tests/TombLib.Tests/LevelData/ObjectGroupTests.cs index 2c9e5758e3..300a650735 100644 --- a/TombLib/TombLib.Test/ObjectGroupTests.cs +++ b/Tests/TombLib.Tests/LevelData/ObjectGroupTests.cs @@ -1,9 +1,8 @@ -using System.Linq; using System.Numerics; using System.Reflection; using TombLib.LevelData; -namespace TombLib.Test; +namespace TombLib.Tests; [TestClass] public class ObjectGroupTests diff --git a/TombLib/TombLib.Test/SectorFaceExtensionsTests.cs b/Tests/TombLib.Tests/LevelData/SectorFaceExtensionsTests.cs similarity index 99% rename from TombLib/TombLib.Test/SectorFaceExtensionsTests.cs rename to Tests/TombLib.Tests/LevelData/SectorFaceExtensionsTests.cs index 710b3f97a7..d76050e696 100644 --- a/TombLib/TombLib.Test/SectorFaceExtensionsTests.cs +++ b/Tests/TombLib.Tests/LevelData/SectorFaceExtensionsTests.cs @@ -2,7 +2,7 @@ using TombLib.LevelData.SectorEnums; using TombLib.LevelData.SectorEnums.Extensions; -namespace TombLib.Test; +namespace TombLib.Tests; [TestClass] public class SectorFaceExtensionsTests diff --git a/TombLib/TombLib.Test/SectorVerticalPartExtensionsTests.cs b/Tests/TombLib.Tests/LevelData/SectorVerticalPartExtensionsTests.cs similarity index 99% rename from TombLib/TombLib.Test/SectorVerticalPartExtensionsTests.cs rename to Tests/TombLib.Tests/LevelData/SectorVerticalPartExtensionsTests.cs index bc2dfa03ed..e525ec5692 100644 --- a/TombLib/TombLib.Test/SectorVerticalPartExtensionsTests.cs +++ b/Tests/TombLib.Tests/LevelData/SectorVerticalPartExtensionsTests.cs @@ -1,8 +1,7 @@ -using TombLib.LevelData; using TombLib.LevelData.SectorEnums; using TombLib.LevelData.SectorEnums.Extensions; -namespace TombLib.Test; +namespace TombLib.Tests; [TestClass] public class SectorVerticalPartExtensionsTests diff --git a/Tests/TombLib.Tests/Lua/LuaEditorIntellisenseStateTests.cs b/Tests/TombLib.Tests/Lua/LuaEditorIntellisenseStateTests.cs new file mode 100644 index 0000000000..a665749c7c --- /dev/null +++ b/Tests/TombLib.Tests/Lua/LuaEditorIntellisenseStateTests.cs @@ -0,0 +1,1022 @@ +using System.Reflection; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Threading; +using TombLib.Scripting.Completion; +using TombLib.Scripting.Editing; +using TombLib.Scripting.Diagnostics; +using TombLib.Scripting.Hover; +using TombLib.Scripting.Lua; +using TombLib.Scripting.Navigation; +using TombLib.Scripting.Services; +using TombLib.Scripting.Signatures; +using static TombLib.Tests.WPFTestHelper; + +namespace TombLib.Tests; + +[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); + } + + [Ignore("Obsolete reflection-based coverage for pre-shared signature controller internals. Replace with shared controller tests.")] + public void ScheduleSignatureHelpRefresh_StoresCaretOffsetAndStartsTimer() + { + RunInSta(() => + { + var editor = new LuaEditor(new Version(1, 0)) + { + Text = "spawn(room)", + CaretOffset = 6 + }; + + InvokeControllerInstanceMethod(editor, "_signatureHelpController", "ScheduleRefresh"); + + Assert.IsTrue(GetSignatureHelpField(editor, "_signatureRefreshPending")); + Assert.AreEqual(6, GetSignatureHelpField(editor, "_pendingSignatureHelpOffset")); + Assert.IsTrue(GetSignatureHelpField(editor, "_refreshTimer").IsEnabled); + }); + } + + [Ignore("Obsolete reflection-based coverage for pre-shared signature controller internals. Replace with shared controller tests.")] + public void CancelPendingSignatureHelpRefresh_ClearsPendingStateAndStopsTimer() + { + RunInSta(() => + { + var editor = new LuaEditor(new Version(1, 0)) + { + Text = "spawn(room)", + 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, "_refreshTimer").IsEnabled); + }); + } + + [Ignore("Obsolete reflection-based coverage for pre-shared signature controller internals. Replace with shared controller tests.")] + 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)], + [null])); + + Assert.IsFalse(InvokePrivateStaticBooleanMethod( + "ShouldKeepCompletionWindowOpen", + [typeof(string)], + ".")); + } + + [Ignore("Obsolete reflection-based coverage for pre-shared completion controller internals. Replace with shared controller tests.")] + 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)); + } + + [Ignore("Obsolete reflection-based coverage for pre-shared completion controller internals. Replace with shared controller tests.")] + 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")); + }); + } + + [Ignore("Obsolete reflection-based coverage for pre-shared hover controller internals. Replace with shared controller tests.")] + 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); + }); + } + + [Ignore("Obsolete reflection-based coverage for pre-shared hover controller internals. Replace with shared controller tests.")] + 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); + }); + } + + [Ignore("Obsolete reflection-based coverage for pre-shared hover tooltip internals. Replace with shared controller tests.")] + public void ShowBestHoverToolTip_ShowsCombinedTooltipWhenHoverAndDiagnosticAreAvailable() + { + RunInSta(() => + { + var editor = new LuaEditor(new Version(1, 0)); + + InvokeControllerInstanceMethod( + editor, + "_hoverController", + "ShowBestToolTip", + [typeof(TextHoverInfo), typeof(bool), typeof(string), typeof(TextEditorDiagnosticSeverity)], + new TextHoverInfo("Hover docs.", TextHoverContentKind.PlainText), + true, + "Warning message.", + TextEditorDiagnosticSeverity.Warning); + + Popup popup = GetPrivateField(editor, "_specialToolTip"); + ContentPresenter presenter = GetPrivateField(editor, "_toolTipPresenter").ContentPresenter; + + Assert.IsTrue(popup.IsOpen); + Assert.IsInstanceOfType(presenter.Content, typeof(StackPanel)); + + var panel = (StackPanel)presenter.Content!; + Assert.AreEqual(2, panel.Children.Count); + }); + } + + [Ignore("Obsolete reflection-based coverage for pre-shared hover tooltip internals. Replace with shared controller tests.")] + public void ShowBestHoverToolTip_ShowsHoverTooltipWhenOnlyHoverIsAvailable() + { + RunInSta(() => + { + var editor = new LuaEditor(new Version(1, 0)); + + InvokeControllerInstanceMethod( + editor, + "_hoverController", + "ShowBestToolTip", + [typeof(TextHoverInfo), typeof(bool), typeof(string), typeof(TextEditorDiagnosticSeverity)], + new TextHoverInfo("Hover docs.", TextHoverContentKind.PlainText), + false, + string.Empty, + TextEditorDiagnosticSeverity.Warning); + + Popup popup = GetPrivateField(editor, "_specialToolTip"); + ContentPresenter presenter = GetPrivateField(editor, "_toolTipPresenter").ContentPresenter; + + Assert.IsTrue(popup.IsOpen); + Assert.IsNotNull(presenter.Content); + Assert.IsNotInstanceOfType(presenter.Content, typeof(StackPanel)); + }); + } + + [Ignore("Obsolete reflection-based coverage for pre-shared hover tooltip internals. Replace with shared controller tests.")] + public void ShowBestHoverToolTip_ShowsDiagnosticTooltipWhenOnlyDiagnosticIsAvailable() + { + RunInSta(() => + { + var editor = new LuaEditor(new Version(1, 0)); + + InvokeControllerInstanceMethod( + editor, + "_hoverController", + "ShowBestToolTip", + [typeof(TextHoverInfo), typeof(bool), typeof(string), typeof(TextEditorDiagnosticSeverity)], + null, + true, + "Warning message.", + TextEditorDiagnosticSeverity.Warning); + + Popup popup = GetPrivateField(editor, "_specialToolTip"); + ContentPresenter presenter = GetPrivateField(editor, "_toolTipPresenter").ContentPresenter; + + Assert.IsTrue(popup.IsOpen); + Assert.IsNotNull(presenter.Content); + Assert.IsNotInstanceOfType(presenter.Content, typeof(StackPanel)); + }); + } + + [Ignore("Obsolete reflection-based coverage for pre-shared hover tooltip internals. Replace with shared controller tests.")] + public void ShowBestHoverToolTip_SuppressesTooltipWhenCompletionWindowIsOpen() + { + RunInSta(() => + { + var editor = new LuaEditor(new Version(1, 0)); + editor.InitializeCompletionWindow(); + + InvokeControllerInstanceMethod( + editor, + "_hoverController", + "ShowBestToolTip", + [typeof(TextHoverInfo), typeof(bool), typeof(string), typeof(TextEditorDiagnosticSeverity)], + new TextHoverInfo("Hover docs.", TextHoverContentKind.PlainText), + true, + "Warning message.", + TextEditorDiagnosticSeverity.Warning); + + Popup popup = GetPrivateField(editor, "_specialToolTip"); + ContentPresenter presenter = GetPrivateField(editor, "_toolTipPresenter").ContentPresenter; + + Assert.IsFalse(popup.IsOpen); + Assert.IsNull(presenter.Content); + }); + } + + [Ignore("Obsolete reflection-based coverage for pre-shared transient tooltip internals. Replace with shared controller tests.")] + 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 TextDefinitionLocation(4, 2, @"C:\Workspace\Definitions\spawn.lua") + }; + + var editor = new LuaEditor(new Version(1, 0)) + { + FilePath = @"C:\Workspace\Scripts\test.lua", + Text = "spawn()", + IntellisenseProvider = provider, + CaretOffset = 2 + }; + + TextDefinitionLocation? 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, + 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(); + } + }); + } + + [Ignore("Obsolete reflection-based coverage for pre-shared signature refresh internals. Replace with shared controller tests.")] + public void RequestSignatureHelpAsync_WhenRequestIsInFlight_DefersRefreshToLatestOffset() + { + RunInSta(() => + { + var firstResponse = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + TextSignatureHelpInfo secondSignature = new( + "spawn(room, objectName)", + 1, + "Spawns an object.", + [new TextSignatureParameterInfo("room", "Room id."), new TextSignatureParameterInfo("objectName", "Object name.")]); + + 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 TextSignatureHelpInfo( + "spawn(room)", + 0, + "Spawns an object.", + [new TextSignatureParameterInfo("room", "Room id.")])); + + firstRequestTask.GetAwaiter().GetResult(); + Assert.IsTrue(GetSignatureHelpField(editor, "_refreshTimer").IsEnabled); + + InvokeControllerInstanceMethod(editor, "_signatureHelpController", "RefreshTimer_Tick", [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_PreservesVisiblePopupWhenRefreshReturnsNoSignature() + { + RunInSta(() => + { + int servedResponses = 0; + + var provider = new FakeLuaIntellisenseProvider + { + SignatureHelpHandler = (_, _) => + { + servedResponses++; + return Task.FromResult(servedResponses == 1 + ? new TextSignatureHelpInfo( + "spawn(room)", + 0, + "Spawns an object.", + [new TextSignatureParameterInfo("room", "Room id.")]) + : null); + } + }; + + 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.")); + + firstRequestTask.GetAwaiter().GetResult(); + + Popup popup = GetSignatureHelpField(editor, "_signaturePopup"); + ContentPresenter presenter = GetSignatureHelpField(editor, "_signaturePopupPresenter"); + object? originalContent = presenter.Content; + + Task secondRequestTask = (Task)(InvokeInstanceMethod(editor, "RequestSignatureHelpAsync", [typeof(int)], 11) + ?? throw new InvalidOperationException("Private instance method 'RequestSignatureHelpAsync' returned null.")); + + secondRequestTask.GetAwaiter().GetResult(); + + Assert.IsTrue(popup.IsOpen); + Assert.IsNotNull(presenter.Content); + Assert.AreSame(originalContent, presenter.Content); + Assert.AreEqual(2, provider.SignatureRequests.Count); + } + finally + { + window.Close(); + } + }); + } + + [TestMethod] + public void RequestSignatureHelpAsync_ShowsSignaturePopupForResolvedSignature() + { + RunInSta(() => + { + var provider = new FakeLuaIntellisenseProvider + { + SignatureResponse = new TextSignatureHelpInfo( + "spawn(room)", + 0, + "Spawns an object.", + [new TextSignatureParameterInfo("room", "Room id.")]) + }; + + 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 TextHoverInfo? HoverResponse { get; set; } + + public TextDefinitionLocation? DefinitionResponse { get; set; } + + public TextSignatureHelpInfo? 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> GetReferencesAsync(TextReferenceRequest request, CancellationToken cancellationToken = default) + => Task.FromResult>([]); + + public Task RenameSymbolAsync(TextRenameRequest request, CancellationToken cancellationToken = default) + => Task.FromResult(null); + + public Task FormatDocumentAsync(TextFormatRequest request, CancellationToken cancellationToken = default) + => Task.FromResult(null); + + 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() + { } + } +} diff --git a/Tests/TombLib.Tests/Lua/LuaEditorInteractionRulesTests.cs b/Tests/TombLib.Tests/Lua/LuaEditorInteractionRulesTests.cs new file mode 100644 index 0000000000..cb1e10d181 --- /dev/null +++ b/Tests/TombLib.Tests/Lua/LuaEditorInteractionRulesTests.cs @@ -0,0 +1,235 @@ +using ICSharpCode.AvalonEdit.Document; +using TombLib.Scripting.Lua.Utils; + +namespace TombLib.Tests; + +[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/Tests/TombLib.Tests/Lua/LuaIndentationStrategyTests.cs b/Tests/TombLib.Tests/Lua/LuaIndentationStrategyTests.cs new file mode 100644 index 0000000000..0d5d405f44 --- /dev/null +++ b/Tests/TombLib.Tests/Lua/LuaIndentationStrategyTests.cs @@ -0,0 +1,86 @@ +using ICSharpCode.AvalonEdit; +using ICSharpCode.AvalonEdit.Document; +using TombLib.Scripting.Lua.Utils; + +namespace TombLib.Tests; + +[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); + } +} diff --git a/Tests/TombLib.Tests/Lua/LuaLineParserTests.cs b/Tests/TombLib.Tests/Lua/LuaLineParserTests.cs new file mode 100644 index 0000000000..661f607027 --- /dev/null +++ b/Tests/TombLib.Tests/Lua/LuaLineParserTests.cs @@ -0,0 +1,42 @@ +using TombLib.Scripting.Lua.Utils; + +namespace TombLib.Tests; + +[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/Tests/TombLib.Tests/Lua/LuaThemeConfigurationTests.cs b/Tests/TombLib.Tests/Lua/LuaThemeConfigurationTests.cs new file mode 100644 index 0000000000..55a1354527 --- /dev/null +++ b/Tests/TombLib.Tests/Lua/LuaThemeConfigurationTests.cs @@ -0,0 +1,170 @@ +using TombLib.Scripting.Highlighting; +using TombLib.Scripting.Lua; +using TombLib.Scripting.Lua.Resources; + +namespace TombLib.Tests; + +[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/TombEngineLanguageScriptServiceTests.cs b/Tests/TombLib.Tests/Lua/TombEngineLanguageScriptServiceTests.cs similarity index 69% rename from TombLib/TombLib.Test/TombEngineLanguageScriptServiceTests.cs rename to Tests/TombLib.Tests/Lua/TombEngineLanguageScriptServiceTests.cs index 6f8c836bc5..560f06e2ba 100644 --- a/TombLib/TombLib.Test/TombEngineLanguageScriptServiceTests.cs +++ b/Tests/TombLib.Tests/Lua/TombEngineLanguageScriptServiceTests.cs @@ -1,7 +1,7 @@ using ICSharpCode.AvalonEdit.Document; -using TombLib.Scripting.Lua.Services; +using TombLib.Scripting.Lua.Utils; -namespace TombLib.Test; +namespace TombLib.Tests; [TestClass] public class TombEngineLanguageScriptServiceTests @@ -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/Tests/TombLib.Tests/Lua/TombEngineLevelScriptServiceTests.cs b/Tests/TombLib.Tests/Lua/TombEngineLevelScriptServiceTests.cs new file mode 100644 index 0000000000..545f759756 --- /dev/null +++ b/Tests/TombLib.Tests/Lua/TombEngineLevelScriptServiceTests.cs @@ -0,0 +1,66 @@ +using ICSharpCode.AvalonEdit.Document; +using TombLib.Scripting.Lua.Utils; + +namespace TombLib.Tests; + +[TestClass] +public class TombEngineLevelScriptServiceTests +{ + private readonly TombEngineLevelScriptService _service = new(); + + [TestMethod] + public void IsLevelScriptDefined_ReturnsTrueWhenLanguageEntryAndFlowRegistrationMatch() + { + var scriptDocument = CreateDocument( + "LevelOne = TEN.Flow.Level()", + "LevelOne.nameKey = \"LevelOne\"", + "TEN.Flow.AddLevel(LevelOne)"); + var languageDocument = CreateDocument("LevelOne = { \"First Level\" }"); + + bool result = _service.IsLevelScriptDefined(scriptDocument, languageDocument, "First Level"); + + Assert.IsTrue(result); + } + + [TestMethod] + public void IsLevelScriptDefined_ReturnsFalseWhenLanguageEntryIsMissing() + { + var scriptDocument = CreateDocument("TEN.Flow.AddLevel(LevelOne)"); + var languageDocument = CreateDocument("OtherLevel = { \"Other Level\" }"); + + bool result = _service.IsLevelScriptDefined(scriptDocument, languageDocument, "First Level"); + + Assert.IsFalse(result); + } + + [TestMethod] + public void IsLevelScriptDefined_ReturnsFalseWhenFlowRegistrationIsMissing() + { + var scriptDocument = CreateDocument("LevelOne = TEN.Flow.Level()"); + var languageDocument = CreateDocument("LevelOne = { \"First Level\" }"); + + bool result = _service.IsLevelScriptDefined(scriptDocument, languageDocument, "First Level"); + + Assert.IsFalse(result); + } + + [TestMethod] + public void IsLevelScriptDefined_IgnoresCommentedOutEntries() + { + var scriptDocument = CreateDocument( + "-- TEN.Flow.AddLevel(LevelOne)", + "TEN.Flow.AddLevel(LevelTwo)"); + var languageDocument = CreateDocument( + "-- LevelOne = { \"First Level\" }", + "LevelTwo = { \"Second Level\" }"); + + bool firstResult = _service.IsLevelScriptDefined(scriptDocument, languageDocument, "First Level"); + bool secondResult = _service.IsLevelScriptDefined(scriptDocument, languageDocument, "Second Level"); + + Assert.IsFalse(firstResult); + Assert.IsTrue(secondResult); + } + + private static TextDocument CreateDocument(params string[] lines) + => new(string.Join(Environment.NewLine, lines)); +} \ No newline at end of file diff --git a/Tests/TombLib.Tests/Navigation/TextDefinitionTriggerControllerTests.cs b/Tests/TombLib.Tests/Navigation/TextDefinitionTriggerControllerTests.cs new file mode 100644 index 0000000000..928c951452 --- /dev/null +++ b/Tests/TombLib.Tests/Navigation/TextDefinitionTriggerControllerTests.cs @@ -0,0 +1,87 @@ +using System.Windows; +using System.Windows.Input; +using TombLib.Scripting.UI.Navigation; + +namespace TombLib.Tests; + +[TestClass] +public class TextDefinitionTriggerControllerTests +{ + [TestMethod] + public void TryHandleKeyDownAsync_F12AndSuccessfulNavigation_HandlesEventAndUsesCaretOffset() + { + WPFTestHelper.RunInSta(() => + { + var owner = new Border(); + Window hostWindow = WPFTestHelper.ShowInHostWindow(owner); + + try + { + int requestedOffset = -1; + var controller = new TextDefinitionTriggerController( + owner, + _ => -1, + (offset, cancellationToken) => + { + requestedOffset = offset; + return Task.FromResult(true); + }); + + var eventArgs = new KeyEventArgs( + Keyboard.PrimaryDevice, + PresentationSource.FromVisual(hostWindow)!, + 0, + Key.F12) + { + RoutedEvent = Keyboard.KeyDownEvent + }; + + bool handled = controller.TryHandleKeyDownAsync(eventArgs, 42).GetAwaiter().GetResult(); + + Assert.IsTrue(handled); + Assert.IsTrue(eventArgs.Handled); + Assert.AreEqual(42, requestedOffset); + } + finally + { + hostWindow.Close(); + } + }); + } + + [TestMethod] + public void TryHandleKeyDownAsync_F12AndFailedNavigation_DoesNotHandleEvent() + { + WPFTestHelper.RunInSta(() => + { + var owner = new Border(); + Window hostWindow = WPFTestHelper.ShowInHostWindow(owner); + + try + { + var controller = new TextDefinitionTriggerController( + owner, + _ => -1, + (offset, cancellationToken) => Task.FromResult(false)); + + var eventArgs = new KeyEventArgs( + Keyboard.PrimaryDevice, + PresentationSource.FromVisual(hostWindow)!, + 0, + Key.F12) + { + RoutedEvent = Keyboard.KeyDownEvent + }; + + bool handled = controller.TryHandleKeyDownAsync(eventArgs, 42).GetAwaiter().GetResult(); + + Assert.IsFalse(handled); + Assert.IsFalse(eventArgs.Handled); + } + finally + { + hostWindow.Close(); + } + }); + } +} \ No newline at end of file diff --git a/TombLib/TombLib.Test/ScriptGeneratorTests.cs b/Tests/TombLib.Tests/ScriptGeneration/ScriptGeneratorTests.cs similarity index 97% rename from TombLib/TombLib.Test/ScriptGeneratorTests.cs rename to Tests/TombLib.Tests/ScriptGeneration/ScriptGeneratorTests.cs index f3493756dd..ab6332f189 100644 --- a/TombLib/TombLib.Test/ScriptGeneratorTests.cs +++ b/Tests/TombLib.Tests/ScriptGeneration/ScriptGeneratorTests.cs @@ -1,7 +1,7 @@ using TombIDE.Shared.SharedClasses; using TombLib.LevelData; -namespace TombLib.Test; +namespace TombLib.Tests; [TestClass] public class ScriptGeneratorTests diff --git a/Tests/TombLib.Tests/Services/TextLineCommentServiceTests.cs b/Tests/TombLib.Tests/Services/TextLineCommentServiceTests.cs new file mode 100644 index 0000000000..0433bb8ecb --- /dev/null +++ b/Tests/TombLib.Tests/Services/TextLineCommentServiceTests.cs @@ -0,0 +1,46 @@ +using ICSharpCode.AvalonEdit.Document; +using TombLib.Scripting.Services; + +namespace TombLib.Tests; + +[TestClass] +public class TextLineCommentServiceTests +{ + [TestMethod] + public void TryCreateEdit_ToggleOnUncommentedSelection_CommentsEachLine() + { + var document = new TextDocument("first" + Environment.NewLine + " second"); + var service = new TextLineCommentService(); + + bool success = service.TryCreateEdit( + document, + selectionStart: 0, + selectionLength: document.TextLength, + commentPrefix: "//", + action: TextLineCommentAction.Toggle, + out TextLineCommentEdit edit); + + Assert.IsTrue(success); + document.Replace(edit.ReplaceOffset, edit.ReplaceLength, edit.ReplacementText); + Assert.AreEqual("//first" + Environment.NewLine + " //second" + Environment.NewLine, document.Text); + } + + [TestMethod] + public void TryCreateEdit_ToggleOnCommentedSelection_UncommentsEachLine() + { + var document = new TextDocument("//first" + Environment.NewLine + " //second"); + var service = new TextLineCommentService(); + + bool success = service.TryCreateEdit( + document, + selectionStart: 0, + selectionLength: document.TextLength, + commentPrefix: "//", + action: TextLineCommentAction.Toggle, + out TextLineCommentEdit edit); + + Assert.IsTrue(success); + document.Replace(edit.ReplaceOffset, edit.ReplaceLength, edit.ReplacementText); + Assert.AreEqual("first" + Environment.NewLine + " second" + Environment.NewLine, document.Text); + } +} \ No newline at end of file diff --git a/Tests/TombLib.Tests/TRX/GameflowSchemaServiceTests.cs b/Tests/TombLib.Tests/TRX/GameflowSchemaServiceTests.cs new file mode 100644 index 0000000000..30c3e0bb91 --- /dev/null +++ b/Tests/TombLib.Tests/TRX/GameflowSchemaServiceTests.cs @@ -0,0 +1,21 @@ +#nullable enable + +using TombLib.Scripting.Specifications.TRX; +using TombLib.Scripting.Specifications.TRX.Services; + +namespace TombLib.Tests; + +[TestClass] +public class GameflowSchemaServiceTests +{ + [TestMethod] + public void GetSchemaKeywords_ReturnsBundledSchemaKeywords() + { + var service = new GameflowSchemaService(TrxResourcePaths.GetGameflowSchemaPath()); + + var keywords = service.GetSchemaKeywords(); + + Assert.IsNotNull(keywords); + Assert.IsTrue(keywords.Collections.Length > 0 || keywords.Properties.Length > 0 || keywords.Constants.Length > 0); + } +} \ No newline at end of file diff --git a/Tests/TombLib.Tests/TRX/TRXDocumentLookupServiceTests.cs b/Tests/TombLib.Tests/TRX/TRXDocumentLookupServiceTests.cs new file mode 100644 index 0000000000..1f3cef248c --- /dev/null +++ b/Tests/TombLib.Tests/TRX/TRXDocumentLookupServiceTests.cs @@ -0,0 +1,48 @@ +#nullable enable + +using ICSharpCode.AvalonEdit.Document; +using TombLib.Scripting.TRX.Services; + +namespace TombLib.Tests; + +[TestClass] +public class TRXDocumentLookupServiceTests +{ + [TestMethod] + public void IsLevelScriptDefined_ReturnsTrueForMatchingTitleLine() + { + var service = new TRXDocumentLookupService(); + var document = new TextDocument( + "{\r\n" + + " \"levels\": [\r\n" + + " {\r\n" + + " \"title\": \"Vatican City\",\r\n" + + " \"file\": \"data\\\\level21.phd\"\r\n" + + " }\r\n" + + " ]\r\n" + + "}\r\n"); + + bool isDefined = service.IsLevelScriptDefined(document, "Vatican City"); + + Assert.IsTrue(isDefined); + } + + [TestMethod] + public void IsLevelScriptDefined_ReturnsFalseForMissingLevel() + { + var service = new TRXDocumentLookupService(); + var document = new TextDocument( + "{\r\n" + + " \"levels\": [\r\n" + + " {\r\n" + + " \"title\": \"Vatican City\",\r\n" + + " \"file\": \"data\\\\level21.phd\"\r\n" + + " }\r\n" + + " ]\r\n" + + "}\r\n"); + + bool isDefined = service.IsLevelScriptDefined(document, "Colosseum"); + + Assert.IsFalse(isDefined); + } +} \ No newline at end of file diff --git a/Tests/TombLib.Tests/Tomb1Main/Tomb1MainDocumentLookupServiceTests.cs b/Tests/TombLib.Tests/Tomb1Main/Tomb1MainDocumentLookupServiceTests.cs new file mode 100644 index 0000000000..1dbe08e5ed --- /dev/null +++ b/Tests/TombLib.Tests/Tomb1Main/Tomb1MainDocumentLookupServiceTests.cs @@ -0,0 +1,48 @@ +#nullable enable + +using ICSharpCode.AvalonEdit.Document; +using TombLib.Scripting.TRX.Services; + +namespace TombLib.Tests; + +[TestClass] +public class Tomb1MainDocumentLookupServiceTests +{ + [TestMethod] + public void IsLevelScriptDefined_ReturnsTrueForMatchingTitleLine() + { + var service = new TRXDocumentLookupService(); + var document = new TextDocument( + "{\r\n" + + " \"levels\": [\r\n" + + " {\r\n" + + " \"title\": \"Vatican City\",\r\n" + + " \"file\": \"data\\\\level21.phd\"\r\n" + + " }\r\n" + + " ]\r\n" + + "}\r\n"); + + bool isDefined = service.IsLevelScriptDefined(document, "Vatican City"); + + Assert.IsTrue(isDefined); + } + + [TestMethod] + public void IsLevelScriptDefined_ReturnsFalseForMissingLevel() + { + var service = new TRXDocumentLookupService(); + var document = new TextDocument( + "{\r\n" + + " \"levels\": [\r\n" + + " {\r\n" + + " \"title\": \"Vatican City\",\r\n" + + " \"file\": \"data\\\\level21.phd\"\r\n" + + " }\r\n" + + " ]\r\n" + + "}\r\n"); + + bool isDefined = service.IsLevelScriptDefined(document, "Colosseum"); + + Assert.IsFalse(isDefined); + } +} \ No newline at end of file diff --git a/Tests/TombLib.Tests/TombLib.Tests.csproj b/Tests/TombLib.Tests/TombLib.Tests.csproj new file mode 100644 index 0000000000..4dcb1de96e --- /dev/null +++ b/Tests/TombLib.Tests/TombLib.Tests.csproj @@ -0,0 +1,36 @@ + + + + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/TombLib.Tests/ToolTips/MarkdownToolTipRendererTests.cs b/Tests/TombLib.Tests/ToolTips/MarkdownToolTipRendererTests.cs new file mode 100644 index 0000000000..78e59fe3de --- /dev/null +++ b/Tests/TombLib.Tests/ToolTips/MarkdownToolTipRendererTests.cs @@ -0,0 +1,23 @@ +using ICSharpCode.AvalonEdit; +using System.Windows.Media; +using TombLib.Scripting.Rendering; + +namespace TombLib.Tests; + +[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); + }); + } +} diff --git a/Tests/TombLib.Tests/ToolTips/TRXEditorToolTipTests.cs b/Tests/TombLib.Tests/ToolTips/TRXEditorToolTipTests.cs new file mode 100644 index 0000000000..22105920f1 --- /dev/null +++ b/Tests/TombLib.Tests/ToolTips/TRXEditorToolTipTests.cs @@ -0,0 +1,43 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Threading; +using TombLib.Scripting.Services; +using TombLib.Scripting.TRX; + +namespace TombLib.Tests; + +[TestClass] +public class TRXEditorToolTipTests +{ + [TestMethod] + public void ShowMarkdownToolTip_OpensPopupAndForceCloseClearsContent() + { + WPFTestHelper.RunInSta(() => + { + var editor = new TRXEditor(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, "_toolTipPresenter").ContentPresenter; + + 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/Tests/TombLib.Tests/ToolTips/Tomb1MainEditorToolTipTests.cs b/Tests/TombLib.Tests/ToolTips/Tomb1MainEditorToolTipTests.cs new file mode 100644 index 0000000000..89a99fec99 --- /dev/null +++ b/Tests/TombLib.Tests/ToolTips/Tomb1MainEditorToolTipTests.cs @@ -0,0 +1,43 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Threading; +using TombLib.Scripting.Services; +using TombLib.Scripting.TRX; + +namespace TombLib.Tests; + +[TestClass] +public class Tomb1MainEditorToolTipTests +{ + [TestMethod] + public void ShowMarkdownToolTip_OpensPopupAndForceCloseClearsContent() + { + WPFTestHelper.RunInSta(() => + { + var editor = new TRXEditor(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, "_toolTipPresenter").ContentPresenter; + + 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/ViewModels/GeometryIOSettingsWindowViewModelTests.cs b/Tests/TombLib.Tests/ViewModels/GeometryIOSettingsWindowViewModelTests.cs similarity index 99% rename from TombLib/TombLib.Test/ViewModels/GeometryIOSettingsWindowViewModelTests.cs rename to Tests/TombLib.Tests/ViewModels/GeometryIOSettingsWindowViewModelTests.cs index 41ccc7c089..7edca8662c 100644 --- a/TombLib/TombLib.Test/ViewModels/GeometryIOSettingsWindowViewModelTests.cs +++ b/Tests/TombLib.Tests/ViewModels/GeometryIOSettingsWindowViewModelTests.cs @@ -8,7 +8,7 @@ using TombLib.WPF.Services; using TombLib.WPF.Services.Abstract; -namespace TombLib.Test.ViewModels; +namespace TombLib.Tests.ViewModels; [TestClass] public class GeometryIOSettingsWindowViewModelTests diff --git a/TombLib/TombLib.Test/ViewModels/InputBoxWindowViewModelTests.cs b/Tests/TombLib.Tests/ViewModels/InputBoxWindowViewModelTests.cs similarity index 99% rename from TombLib/TombLib.Test/ViewModels/InputBoxWindowViewModelTests.cs rename to Tests/TombLib.Tests/ViewModels/InputBoxWindowViewModelTests.cs index 5eae64a7c3..003a332a92 100644 --- a/TombLib/TombLib.Test/ViewModels/InputBoxWindowViewModelTests.cs +++ b/Tests/TombLib.Tests/ViewModels/InputBoxWindowViewModelTests.cs @@ -5,7 +5,7 @@ using TombLib.WPF.Services; using TombLib.WPF.Services.Abstract; -namespace TombLib.Test.ViewModels; +namespace TombLib.Tests.ViewModels; [TestClass] public class InputBoxWindowViewModelTests diff --git a/Tests/TombLib.Tests/WPFTestHelper.cs b/Tests/TombLib.Tests/WPFTestHelper.cs new file mode 100644 index 0000000000..a2f5830016 --- /dev/null +++ b/Tests/TombLib.Tests/WPFTestHelper.cs @@ -0,0 +1,125 @@ +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Windows; +using System.Windows.Threading; + +namespace TombLib.Tests; + +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(); + } +} diff --git a/Tomb Editor.sln b/Tomb Editor.sln index a10bb5cede..ddcd2b7137 100644 --- a/Tomb Editor.sln +++ b/Tomb Editor.sln @@ -1,6 +1,7 @@ + Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.1.32328.378 +# Visual Studio Version 18 +VisualStudioVersion = 18.5.11723.231 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TombEditor", "TombEditor\TombEditor.csproj", "{330CCD5E-D061-4C40-B568-BF367213C525}" EndProject @@ -46,19 +47,25 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TombIDE.REGSVR", "TombIDE\T EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "TombLib", "TombLib", "{642147AB-23FD-4715-AE26-90BE6C755FD0}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TombLib.Scripting", "TombLib\TombLib.Scripting\TombLib.Scripting.csproj", "{3EAAFD71-DD96-427D-8793-643DADD2F3A3}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TombLib.Scripting.Core", "TombLib\TombLib.Scripting.Core\TombLib.Scripting.Core.csproj", "{F3AFCF41-1F6C-46C9-920A-A19BAA0D1067}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TombLib.Scripting.UI", "TombLib\TombLib.Scripting.UI\TombLib.Scripting.UI.csproj", "{EA375184-410A-4986-9135-EBAA26A16366}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TombLib.Scripting.Specifications", "TombLib\TombLib.Scripting.Specifications\TombLib.Scripting.Specifications.csproj", "{CD5097B1-03FD-4AEF-9CCF-25DA6E8EA2D2}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TombLib.Scripting.ClassicScript", "TombLib\TombLib.Scripting.ClassicScript\TombLib.Scripting.ClassicScript.csproj", "{5D9A72E7-4B33-4177-AD7C-1B46591005FD}" 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.LanguageServer.Core", "TombLib\TombLib.LanguageServer.Core\TombLib.LanguageServer.Core.csproj", "{FDF8C618-7AFF-4756-A138-4AF3AD9F679D}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TombLib.LanguageServer.Lua", "TombLib\TombLib.LanguageServer.Lua\TombLib.LanguageServer.Lua.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}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TombLib.Scripting.Tomb1Main", "TombLib\TombLib.Scripting.Tomb1Main\TombLib.Scripting.Tomb1Main.csproj", "{7987FD7B-AAC9-4C7F-8188-C54FDE35C3D6}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TombLib.Test", "TombLib\TombLib.Test\TombLib.Test.csproj", "{61E45B12-B972-136D-6066-1CD28A5429E1}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TombLib.Scripting.TRX", "TombLib\TombLib.Scripting.TRX\TombLib.Scripting.TRX.csproj", "{7987FD7B-AAC9-4C7F-8188-C54FDE35C3D6}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DarkUI.WPF", "DarkUI\DarkUI.WPF\DarkUI.WPF.csproj", "{E1E456E7-D224-460F-9C65-C6FFAD69F1A5}" EndProject @@ -68,226 +75,410 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LuaApiBuilder", "LuaApiBuil EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TombLib.WPF", "TombLib\TombLib.WPF\TombLib.WPF.csproj", "{38A8E2D8-FB7B-43E2-B7D2-929A64263723}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TombEditor.Tests", "TombEditor.Tests\TombEditor.Tests.csproj", "{E0A14053-8CBD-4EB7-911A-477EA8A315ED}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{6F5F6BB9-64D2-4F82-B6AE-162361DF4965}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TombLib.LanguageServer.Lua.Tests", "Tests\TombLib.LanguageServer.Lua.Tests\TombLib.LanguageServer.Lua.Tests.csproj", "{5B6870A9-0E41-4290-B85E-2A5F8BCF78AF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TombLib.LanguageServer.Core.Tests", "Tests\TombLib.LanguageServer.Core.Tests\TombLib.LanguageServer.Core.Tests.csproj", "{C17ECE0A-BBAF-430D-9FDA-59A9BA4FB92E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TombEditor.Tests", "Tests\TombEditor.Tests\TombEditor.Tests.csproj", "{952375C0-1A8D-403A-B21F-18910D950987}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TombLib.Tests", "Tests\TombLib.Tests\TombLib.Tests.csproj", "{627E39C2-4445-4232-B522-EDF76689B808}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU Debug|x64 = Debug|x64 Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU Release|x64 = Release|x64 Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution + {330CCD5E-D061-4C40-B568-BF367213C525}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {330CCD5E-D061-4C40-B568-BF367213C525}.Debug|Any CPU.Build.0 = Debug|Any CPU {330CCD5E-D061-4C40-B568-BF367213C525}.Debug|x64.ActiveCfg = Debug|x64 {330CCD5E-D061-4C40-B568-BF367213C525}.Debug|x64.Build.0 = Debug|x64 {330CCD5E-D061-4C40-B568-BF367213C525}.Debug|x86.ActiveCfg = Debug|x86 {330CCD5E-D061-4C40-B568-BF367213C525}.Debug|x86.Build.0 = Debug|x86 + {330CCD5E-D061-4C40-B568-BF367213C525}.Release|Any CPU.ActiveCfg = Release|Any CPU + {330CCD5E-D061-4C40-B568-BF367213C525}.Release|Any CPU.Build.0 = Release|Any CPU {330CCD5E-D061-4C40-B568-BF367213C525}.Release|x64.ActiveCfg = Release|x64 {330CCD5E-D061-4C40-B568-BF367213C525}.Release|x64.Build.0 = Release|x64 {330CCD5E-D061-4C40-B568-BF367213C525}.Release|x86.ActiveCfg = Release|x86 {330CCD5E-D061-4C40-B568-BF367213C525}.Release|x86.Build.0 = Release|x86 + {A0421DCD-80D1-44D8-8243-6A33F83A692C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A0421DCD-80D1-44D8-8243-6A33F83A692C}.Debug|Any CPU.Build.0 = Debug|Any CPU {A0421DCD-80D1-44D8-8243-6A33F83A692C}.Debug|x64.ActiveCfg = Debug|x64 {A0421DCD-80D1-44D8-8243-6A33F83A692C}.Debug|x64.Build.0 = Debug|x64 {A0421DCD-80D1-44D8-8243-6A33F83A692C}.Debug|x86.ActiveCfg = Debug|x86 {A0421DCD-80D1-44D8-8243-6A33F83A692C}.Debug|x86.Build.0 = Debug|x86 + {A0421DCD-80D1-44D8-8243-6A33F83A692C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A0421DCD-80D1-44D8-8243-6A33F83A692C}.Release|Any CPU.Build.0 = Release|Any CPU {A0421DCD-80D1-44D8-8243-6A33F83A692C}.Release|x64.ActiveCfg = Release|x64 {A0421DCD-80D1-44D8-8243-6A33F83A692C}.Release|x64.Build.0 = Release|x64 {A0421DCD-80D1-44D8-8243-6A33F83A692C}.Release|x86.ActiveCfg = Release|x86 {A0421DCD-80D1-44D8-8243-6A33F83A692C}.Release|x86.Build.0 = Release|x86 + {FA212817-73AF-453A-9F34-A59BEABFF916}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FA212817-73AF-453A-9F34-A59BEABFF916}.Debug|Any CPU.Build.0 = Debug|Any CPU {FA212817-73AF-453A-9F34-A59BEABFF916}.Debug|x64.ActiveCfg = Debug|x64 {FA212817-73AF-453A-9F34-A59BEABFF916}.Debug|x64.Build.0 = Debug|x64 {FA212817-73AF-453A-9F34-A59BEABFF916}.Debug|x86.ActiveCfg = Debug|x86 {FA212817-73AF-453A-9F34-A59BEABFF916}.Debug|x86.Build.0 = Debug|x86 + {FA212817-73AF-453A-9F34-A59BEABFF916}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FA212817-73AF-453A-9F34-A59BEABFF916}.Release|Any CPU.Build.0 = Release|Any CPU {FA212817-73AF-453A-9F34-A59BEABFF916}.Release|x64.ActiveCfg = Release|x64 {FA212817-73AF-453A-9F34-A59BEABFF916}.Release|x64.Build.0 = Release|x64 {FA212817-73AF-453A-9F34-A59BEABFF916}.Release|x86.ActiveCfg = Release|x86 {FA212817-73AF-453A-9F34-A59BEABFF916}.Release|x86.Build.0 = Release|x86 + {F19472F5-8C44-4C51-A8A0-B9DE5F555255}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F19472F5-8C44-4C51-A8A0-B9DE5F555255}.Debug|Any CPU.Build.0 = Debug|Any CPU {F19472F5-8C44-4C51-A8A0-B9DE5F555255}.Debug|x64.ActiveCfg = Debug|x64 {F19472F5-8C44-4C51-A8A0-B9DE5F555255}.Debug|x64.Build.0 = Debug|x64 {F19472F5-8C44-4C51-A8A0-B9DE5F555255}.Debug|x86.ActiveCfg = Debug|x86 {F19472F5-8C44-4C51-A8A0-B9DE5F555255}.Debug|x86.Build.0 = Debug|x86 + {F19472F5-8C44-4C51-A8A0-B9DE5F555255}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F19472F5-8C44-4C51-A8A0-B9DE5F555255}.Release|Any CPU.Build.0 = Release|Any CPU {F19472F5-8C44-4C51-A8A0-B9DE5F555255}.Release|x64.ActiveCfg = Release|x64 {F19472F5-8C44-4C51-A8A0-B9DE5F555255}.Release|x64.Build.0 = Release|x64 {F19472F5-8C44-4C51-A8A0-B9DE5F555255}.Release|x86.ActiveCfg = Release|x86 {F19472F5-8C44-4C51-A8A0-B9DE5F555255}.Release|x86.Build.0 = Release|x86 + {FA334815-6D78-4E9A-9F4D-6C8A58222A57}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FA334815-6D78-4E9A-9F4D-6C8A58222A57}.Debug|Any CPU.Build.0 = Debug|Any CPU {FA334815-6D78-4E9A-9F4D-6C8A58222A57}.Debug|x64.ActiveCfg = Debug|x64 {FA334815-6D78-4E9A-9F4D-6C8A58222A57}.Debug|x64.Build.0 = Debug|x64 {FA334815-6D78-4E9A-9F4D-6C8A58222A57}.Debug|x86.ActiveCfg = Debug|x86 {FA334815-6D78-4E9A-9F4D-6C8A58222A57}.Debug|x86.Build.0 = Debug|x86 + {FA334815-6D78-4E9A-9F4D-6C8A58222A57}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FA334815-6D78-4E9A-9F4D-6C8A58222A57}.Release|Any CPU.Build.0 = Release|Any CPU {FA334815-6D78-4E9A-9F4D-6C8A58222A57}.Release|x64.ActiveCfg = Release|x64 {FA334815-6D78-4E9A-9F4D-6C8A58222A57}.Release|x64.Build.0 = Release|x64 {FA334815-6D78-4E9A-9F4D-6C8A58222A57}.Release|x86.ActiveCfg = Release|x86 {FA334815-6D78-4E9A-9F4D-6C8A58222A57}.Release|x86.Build.0 = Release|x86 + {EFC2A02B-1138-46EA-A496-F94D58AC6B3D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EFC2A02B-1138-46EA-A496-F94D58AC6B3D}.Debug|Any CPU.Build.0 = Debug|Any CPU {EFC2A02B-1138-46EA-A496-F94D58AC6B3D}.Debug|x64.ActiveCfg = Debug|x64 {EFC2A02B-1138-46EA-A496-F94D58AC6B3D}.Debug|x64.Build.0 = Debug|x64 {EFC2A02B-1138-46EA-A496-F94D58AC6B3D}.Debug|x86.ActiveCfg = Debug|x86 {EFC2A02B-1138-46EA-A496-F94D58AC6B3D}.Debug|x86.Build.0 = Debug|x86 + {EFC2A02B-1138-46EA-A496-F94D58AC6B3D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EFC2A02B-1138-46EA-A496-F94D58AC6B3D}.Release|Any CPU.Build.0 = Release|Any CPU {EFC2A02B-1138-46EA-A496-F94D58AC6B3D}.Release|x64.ActiveCfg = Release|x64 {EFC2A02B-1138-46EA-A496-F94D58AC6B3D}.Release|x64.Build.0 = Release|x64 {EFC2A02B-1138-46EA-A496-F94D58AC6B3D}.Release|x86.ActiveCfg = Release|x86 {EFC2A02B-1138-46EA-A496-F94D58AC6B3D}.Release|x86.Build.0 = Release|x86 + {EB61DFAC-51A6-44E3-869C-9BBDE830A40E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EB61DFAC-51A6-44E3-869C-9BBDE830A40E}.Debug|Any CPU.Build.0 = Debug|Any CPU {EB61DFAC-51A6-44E3-869C-9BBDE830A40E}.Debug|x64.ActiveCfg = Debug|x64 {EB61DFAC-51A6-44E3-869C-9BBDE830A40E}.Debug|x64.Build.0 = Debug|x64 {EB61DFAC-51A6-44E3-869C-9BBDE830A40E}.Debug|x86.ActiveCfg = Debug|x86 {EB61DFAC-51A6-44E3-869C-9BBDE830A40E}.Debug|x86.Build.0 = Debug|x86 + {EB61DFAC-51A6-44E3-869C-9BBDE830A40E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EB61DFAC-51A6-44E3-869C-9BBDE830A40E}.Release|Any CPU.Build.0 = Release|Any CPU {EB61DFAC-51A6-44E3-869C-9BBDE830A40E}.Release|x64.ActiveCfg = Release|x64 {EB61DFAC-51A6-44E3-869C-9BBDE830A40E}.Release|x64.Build.0 = Release|x64 {EB61DFAC-51A6-44E3-869C-9BBDE830A40E}.Release|x86.ActiveCfg = Release|x86 {EB61DFAC-51A6-44E3-869C-9BBDE830A40E}.Release|x86.Build.0 = Release|x86 + {E2F6F614-5007-471A-A22C-39B7D840376C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E2F6F614-5007-471A-A22C-39B7D840376C}.Debug|Any CPU.Build.0 = Debug|Any CPU {E2F6F614-5007-471A-A22C-39B7D840376C}.Debug|x64.ActiveCfg = Debug|x64 {E2F6F614-5007-471A-A22C-39B7D840376C}.Debug|x64.Build.0 = Debug|x64 {E2F6F614-5007-471A-A22C-39B7D840376C}.Debug|x86.ActiveCfg = Debug|x86 {E2F6F614-5007-471A-A22C-39B7D840376C}.Debug|x86.Build.0 = Debug|x86 + {E2F6F614-5007-471A-A22C-39B7D840376C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E2F6F614-5007-471A-A22C-39B7D840376C}.Release|Any CPU.Build.0 = Release|Any CPU {E2F6F614-5007-471A-A22C-39B7D840376C}.Release|x64.ActiveCfg = Release|x64 {E2F6F614-5007-471A-A22C-39B7D840376C}.Release|x64.Build.0 = Release|x64 {E2F6F614-5007-471A-A22C-39B7D840376C}.Release|x86.ActiveCfg = Release|x86 {E2F6F614-5007-471A-A22C-39B7D840376C}.Release|x86.Build.0 = Release|x86 + {AE350FF9-E471-473F-9898-2746D889D1BA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AE350FF9-E471-473F-9898-2746D889D1BA}.Debug|Any CPU.Build.0 = Debug|Any CPU {AE350FF9-E471-473F-9898-2746D889D1BA}.Debug|x64.ActiveCfg = Debug|x64 {AE350FF9-E471-473F-9898-2746D889D1BA}.Debug|x64.Build.0 = Debug|x64 {AE350FF9-E471-473F-9898-2746D889D1BA}.Debug|x86.ActiveCfg = Debug|x86 {AE350FF9-E471-473F-9898-2746D889D1BA}.Debug|x86.Build.0 = Debug|x86 + {AE350FF9-E471-473F-9898-2746D889D1BA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AE350FF9-E471-473F-9898-2746D889D1BA}.Release|Any CPU.Build.0 = Release|Any CPU {AE350FF9-E471-473F-9898-2746D889D1BA}.Release|x64.ActiveCfg = Release|x64 {AE350FF9-E471-473F-9898-2746D889D1BA}.Release|x64.Build.0 = Release|x64 {AE350FF9-E471-473F-9898-2746D889D1BA}.Release|x86.ActiveCfg = Release|x86 {AE350FF9-E471-473F-9898-2746D889D1BA}.Release|x86.Build.0 = Release|x86 + {8E9772C8-DFFD-4ABB-A319-8226F9A4EE14}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8E9772C8-DFFD-4ABB-A319-8226F9A4EE14}.Debug|Any CPU.Build.0 = Debug|Any CPU {8E9772C8-DFFD-4ABB-A319-8226F9A4EE14}.Debug|x64.ActiveCfg = Debug|x64 {8E9772C8-DFFD-4ABB-A319-8226F9A4EE14}.Debug|x64.Build.0 = Debug|x64 {8E9772C8-DFFD-4ABB-A319-8226F9A4EE14}.Debug|x86.ActiveCfg = Debug|x86 {8E9772C8-DFFD-4ABB-A319-8226F9A4EE14}.Debug|x86.Build.0 = Debug|x86 + {8E9772C8-DFFD-4ABB-A319-8226F9A4EE14}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8E9772C8-DFFD-4ABB-A319-8226F9A4EE14}.Release|Any CPU.Build.0 = Release|Any CPU {8E9772C8-DFFD-4ABB-A319-8226F9A4EE14}.Release|x64.ActiveCfg = Release|x64 {8E9772C8-DFFD-4ABB-A319-8226F9A4EE14}.Release|x64.Build.0 = Release|x64 {8E9772C8-DFFD-4ABB-A319-8226F9A4EE14}.Release|x86.ActiveCfg = Release|x86 {8E9772C8-DFFD-4ABB-A319-8226F9A4EE14}.Release|x86.Build.0 = Release|x86 + {CA19A4C1-85E7-4BB2-97C6-1AAA2B48C532}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CA19A4C1-85E7-4BB2-97C6-1AAA2B48C532}.Debug|Any CPU.Build.0 = Debug|Any CPU {CA19A4C1-85E7-4BB2-97C6-1AAA2B48C532}.Debug|x64.ActiveCfg = Debug|x64 {CA19A4C1-85E7-4BB2-97C6-1AAA2B48C532}.Debug|x64.Build.0 = Debug|x64 {CA19A4C1-85E7-4BB2-97C6-1AAA2B48C532}.Debug|x86.ActiveCfg = Debug|x86 {CA19A4C1-85E7-4BB2-97C6-1AAA2B48C532}.Debug|x86.Build.0 = Debug|x86 + {CA19A4C1-85E7-4BB2-97C6-1AAA2B48C532}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CA19A4C1-85E7-4BB2-97C6-1AAA2B48C532}.Release|Any CPU.Build.0 = Release|Any CPU {CA19A4C1-85E7-4BB2-97C6-1AAA2B48C532}.Release|x64.ActiveCfg = Release|x64 {CA19A4C1-85E7-4BB2-97C6-1AAA2B48C532}.Release|x64.Build.0 = Release|x64 {CA19A4C1-85E7-4BB2-97C6-1AAA2B48C532}.Release|x86.ActiveCfg = Release|x86 {CA19A4C1-85E7-4BB2-97C6-1AAA2B48C532}.Release|x86.Build.0 = Release|x86 + {2397EA24-CB8E-482F-8E93-F3D626D255E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2397EA24-CB8E-482F-8E93-F3D626D255E5}.Debug|Any CPU.Build.0 = Debug|Any CPU {2397EA24-CB8E-482F-8E93-F3D626D255E5}.Debug|x64.ActiveCfg = Debug|x64 {2397EA24-CB8E-482F-8E93-F3D626D255E5}.Debug|x64.Build.0 = Debug|x64 {2397EA24-CB8E-482F-8E93-F3D626D255E5}.Debug|x86.ActiveCfg = Debug|x86 {2397EA24-CB8E-482F-8E93-F3D626D255E5}.Debug|x86.Build.0 = Debug|x86 + {2397EA24-CB8E-482F-8E93-F3D626D255E5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2397EA24-CB8E-482F-8E93-F3D626D255E5}.Release|Any CPU.Build.0 = Release|Any CPU {2397EA24-CB8E-482F-8E93-F3D626D255E5}.Release|x64.ActiveCfg = Release|x64 {2397EA24-CB8E-482F-8E93-F3D626D255E5}.Release|x64.Build.0 = Release|x64 {2397EA24-CB8E-482F-8E93-F3D626D255E5}.Release|x86.ActiveCfg = Release|x86 {2397EA24-CB8E-482F-8E93-F3D626D255E5}.Release|x86.Build.0 = Release|x86 + {92D73867-1202-43E5-B615-FB998ED8F796}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {92D73867-1202-43E5-B615-FB998ED8F796}.Debug|Any CPU.Build.0 = Debug|Any CPU {92D73867-1202-43E5-B615-FB998ED8F796}.Debug|x64.ActiveCfg = Debug|x64 {92D73867-1202-43E5-B615-FB998ED8F796}.Debug|x64.Build.0 = Debug|x64 {92D73867-1202-43E5-B615-FB998ED8F796}.Debug|x86.ActiveCfg = Debug|x86 {92D73867-1202-43E5-B615-FB998ED8F796}.Debug|x86.Build.0 = Debug|x86 + {92D73867-1202-43E5-B615-FB998ED8F796}.Release|Any CPU.ActiveCfg = Release|Any CPU + {92D73867-1202-43E5-B615-FB998ED8F796}.Release|Any CPU.Build.0 = Release|Any CPU {92D73867-1202-43E5-B615-FB998ED8F796}.Release|x64.ActiveCfg = Release|x64 {92D73867-1202-43E5-B615-FB998ED8F796}.Release|x64.Build.0 = Release|x64 {92D73867-1202-43E5-B615-FB998ED8F796}.Release|x86.ActiveCfg = Release|x86 {92D73867-1202-43E5-B615-FB998ED8F796}.Release|x86.Build.0 = Release|x86 + {8F6B306B-3E55-4295-B822-BF29E7781B2B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8F6B306B-3E55-4295-B822-BF29E7781B2B}.Debug|Any CPU.Build.0 = Debug|Any CPU {8F6B306B-3E55-4295-B822-BF29E7781B2B}.Debug|x64.ActiveCfg = Debug|x64 {8F6B306B-3E55-4295-B822-BF29E7781B2B}.Debug|x64.Build.0 = Debug|x64 {8F6B306B-3E55-4295-B822-BF29E7781B2B}.Debug|x86.ActiveCfg = Debug|x86 {8F6B306B-3E55-4295-B822-BF29E7781B2B}.Debug|x86.Build.0 = Debug|x86 + {8F6B306B-3E55-4295-B822-BF29E7781B2B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8F6B306B-3E55-4295-B822-BF29E7781B2B}.Release|Any CPU.Build.0 = Release|Any CPU {8F6B306B-3E55-4295-B822-BF29E7781B2B}.Release|x64.ActiveCfg = Release|x64 {8F6B306B-3E55-4295-B822-BF29E7781B2B}.Release|x64.Build.0 = Release|x64 {8F6B306B-3E55-4295-B822-BF29E7781B2B}.Release|x86.ActiveCfg = Release|x86 {8F6B306B-3E55-4295-B822-BF29E7781B2B}.Release|x86.Build.0 = Release|x86 - {3EAAFD71-DD96-427D-8793-643DADD2F3A3}.Debug|x64.ActiveCfg = Debug|x64 - {3EAAFD71-DD96-427D-8793-643DADD2F3A3}.Debug|x64.Build.0 = Debug|x64 - {3EAAFD71-DD96-427D-8793-643DADD2F3A3}.Debug|x86.ActiveCfg = Debug|x86 - {3EAAFD71-DD96-427D-8793-643DADD2F3A3}.Debug|x86.Build.0 = Debug|x86 - {3EAAFD71-DD96-427D-8793-643DADD2F3A3}.Release|x64.ActiveCfg = Release|x64 - {3EAAFD71-DD96-427D-8793-643DADD2F3A3}.Release|x64.Build.0 = Release|x64 - {3EAAFD71-DD96-427D-8793-643DADD2F3A3}.Release|x86.ActiveCfg = Release|x86 - {3EAAFD71-DD96-427D-8793-643DADD2F3A3}.Release|x86.Build.0 = Release|x86 + {F3AFCF41-1F6C-46C9-920A-A19BAA0D1067}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F3AFCF41-1F6C-46C9-920A-A19BAA0D1067}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F3AFCF41-1F6C-46C9-920A-A19BAA0D1067}.Debug|x64.ActiveCfg = Debug|x64 + {F3AFCF41-1F6C-46C9-920A-A19BAA0D1067}.Debug|x64.Build.0 = Debug|x64 + {F3AFCF41-1F6C-46C9-920A-A19BAA0D1067}.Debug|x86.ActiveCfg = Debug|x86 + {F3AFCF41-1F6C-46C9-920A-A19BAA0D1067}.Debug|x86.Build.0 = Debug|x86 + {F3AFCF41-1F6C-46C9-920A-A19BAA0D1067}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F3AFCF41-1F6C-46C9-920A-A19BAA0D1067}.Release|Any CPU.Build.0 = Release|Any CPU + {F3AFCF41-1F6C-46C9-920A-A19BAA0D1067}.Release|x64.ActiveCfg = Release|x64 + {F3AFCF41-1F6C-46C9-920A-A19BAA0D1067}.Release|x64.Build.0 = Release|x64 + {F3AFCF41-1F6C-46C9-920A-A19BAA0D1067}.Release|x86.ActiveCfg = Release|x86 + {F3AFCF41-1F6C-46C9-920A-A19BAA0D1067}.Release|x86.Build.0 = Release|x86 + {EA375184-410A-4986-9135-EBAA26A16366}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EA375184-410A-4986-9135-EBAA26A16366}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EA375184-410A-4986-9135-EBAA26A16366}.Debug|x64.ActiveCfg = Debug|x64 + {EA375184-410A-4986-9135-EBAA26A16366}.Debug|x64.Build.0 = Debug|x64 + {EA375184-410A-4986-9135-EBAA26A16366}.Debug|x86.ActiveCfg = Debug|x86 + {EA375184-410A-4986-9135-EBAA26A16366}.Debug|x86.Build.0 = Debug|x86 + {EA375184-410A-4986-9135-EBAA26A16366}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EA375184-410A-4986-9135-EBAA26A16366}.Release|Any CPU.Build.0 = Release|Any CPU + {EA375184-410A-4986-9135-EBAA26A16366}.Release|x64.ActiveCfg = Release|x64 + {EA375184-410A-4986-9135-EBAA26A16366}.Release|x64.Build.0 = Release|x64 + {EA375184-410A-4986-9135-EBAA26A16366}.Release|x86.ActiveCfg = Release|x86 + {EA375184-410A-4986-9135-EBAA26A16366}.Release|x86.Build.0 = Release|x86 + {CD5097B1-03FD-4AEF-9CCF-25DA6E8EA2D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CD5097B1-03FD-4AEF-9CCF-25DA6E8EA2D2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CD5097B1-03FD-4AEF-9CCF-25DA6E8EA2D2}.Debug|x64.ActiveCfg = Debug|x64 + {CD5097B1-03FD-4AEF-9CCF-25DA6E8EA2D2}.Debug|x64.Build.0 = Debug|x64 + {CD5097B1-03FD-4AEF-9CCF-25DA6E8EA2D2}.Debug|x86.ActiveCfg = Debug|x86 + {CD5097B1-03FD-4AEF-9CCF-25DA6E8EA2D2}.Debug|x86.Build.0 = Debug|x86 + {CD5097B1-03FD-4AEF-9CCF-25DA6E8EA2D2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CD5097B1-03FD-4AEF-9CCF-25DA6E8EA2D2}.Release|Any CPU.Build.0 = Release|Any CPU + {CD5097B1-03FD-4AEF-9CCF-25DA6E8EA2D2}.Release|x64.ActiveCfg = Release|x64 + {CD5097B1-03FD-4AEF-9CCF-25DA6E8EA2D2}.Release|x64.Build.0 = Release|x64 + {CD5097B1-03FD-4AEF-9CCF-25DA6E8EA2D2}.Release|x86.ActiveCfg = Release|x86 + {CD5097B1-03FD-4AEF-9CCF-25DA6E8EA2D2}.Release|x86.Build.0 = Release|x86 + {5D9A72E7-4B33-4177-AD7C-1B46591005FD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5D9A72E7-4B33-4177-AD7C-1B46591005FD}.Debug|Any CPU.Build.0 = Debug|Any CPU {5D9A72E7-4B33-4177-AD7C-1B46591005FD}.Debug|x64.ActiveCfg = Debug|x64 {5D9A72E7-4B33-4177-AD7C-1B46591005FD}.Debug|x64.Build.0 = Debug|x64 {5D9A72E7-4B33-4177-AD7C-1B46591005FD}.Debug|x86.ActiveCfg = Debug|x86 {5D9A72E7-4B33-4177-AD7C-1B46591005FD}.Debug|x86.Build.0 = Debug|x86 + {5D9A72E7-4B33-4177-AD7C-1B46591005FD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5D9A72E7-4B33-4177-AD7C-1B46591005FD}.Release|Any CPU.Build.0 = Release|Any CPU {5D9A72E7-4B33-4177-AD7C-1B46591005FD}.Release|x64.ActiveCfg = Release|x64 {5D9A72E7-4B33-4177-AD7C-1B46591005FD}.Release|x64.Build.0 = Release|x64 {5D9A72E7-4B33-4177-AD7C-1B46591005FD}.Release|x86.ActiveCfg = Release|x86 {5D9A72E7-4B33-4177-AD7C-1B46591005FD}.Release|x86.Build.0 = Release|x86 + {87D4149C-E716-49F6-848F-ACA345D11B30}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {87D4149C-E716-49F6-848F-ACA345D11B30}.Debug|Any CPU.Build.0 = Debug|Any CPU {87D4149C-E716-49F6-848F-ACA345D11B30}.Debug|x64.ActiveCfg = Debug|x64 {87D4149C-E716-49F6-848F-ACA345D11B30}.Debug|x64.Build.0 = Debug|x64 {87D4149C-E716-49F6-848F-ACA345D11B30}.Debug|x86.ActiveCfg = Debug|x86 {87D4149C-E716-49F6-848F-ACA345D11B30}.Debug|x86.Build.0 = Debug|x86 + {87D4149C-E716-49F6-848F-ACA345D11B30}.Release|Any CPU.ActiveCfg = Release|Any CPU + {87D4149C-E716-49F6-848F-ACA345D11B30}.Release|Any CPU.Build.0 = Release|Any CPU {87D4149C-E716-49F6-848F-ACA345D11B30}.Release|x64.ActiveCfg = Release|x64 {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 + {FDF8C618-7AFF-4756-A138-4AF3AD9F679D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FDF8C618-7AFF-4756-A138-4AF3AD9F679D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FDF8C618-7AFF-4756-A138-4AF3AD9F679D}.Debug|x64.ActiveCfg = Debug|x64 + {FDF8C618-7AFF-4756-A138-4AF3AD9F679D}.Debug|x64.Build.0 = Debug|x64 + {FDF8C618-7AFF-4756-A138-4AF3AD9F679D}.Debug|x86.ActiveCfg = Debug|x86 + {FDF8C618-7AFF-4756-A138-4AF3AD9F679D}.Debug|x86.Build.0 = Debug|x86 + {FDF8C618-7AFF-4756-A138-4AF3AD9F679D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FDF8C618-7AFF-4756-A138-4AF3AD9F679D}.Release|Any CPU.Build.0 = Release|Any CPU + {FDF8C618-7AFF-4756-A138-4AF3AD9F679D}.Release|x64.ActiveCfg = Release|x64 + {FDF8C618-7AFF-4756-A138-4AF3AD9F679D}.Release|x64.Build.0 = Release|x64 + {FDF8C618-7AFF-4756-A138-4AF3AD9F679D}.Release|x86.ActiveCfg = Release|x86 + {FDF8C618-7AFF-4756-A138-4AF3AD9F679D}.Release|x86.Build.0 = Release|x86 + {BAC24926-D29E-4E42-905B-CDBAB29C0B53}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BAC24926-D29E-4E42-905B-CDBAB29C0B53}.Debug|Any CPU.Build.0 = Debug|Any CPU + {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|Any CPU.ActiveCfg = Release|Any CPU + {BAC24926-D29E-4E42-905B-CDBAB29C0B53}.Release|Any CPU.Build.0 = Release|Any CPU + {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|Any CPU.ActiveCfg = Debug|Any CPU + {934E79A8-2B20-4E0E-A145-08404493A7F6}.Debug|Any CPU.Build.0 = Debug|Any CPU {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 {934E79A8-2B20-4E0E-A145-08404493A7F6}.Debug|x86.Build.0 = Debug|x86 + {934E79A8-2B20-4E0E-A145-08404493A7F6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {934E79A8-2B20-4E0E-A145-08404493A7F6}.Release|Any CPU.Build.0 = Release|Any CPU {934E79A8-2B20-4E0E-A145-08404493A7F6}.Release|x64.ActiveCfg = Release|x64 {934E79A8-2B20-4E0E-A145-08404493A7F6}.Release|x64.Build.0 = Release|x64 {934E79A8-2B20-4E0E-A145-08404493A7F6}.Release|x86.ActiveCfg = Release|x86 {934E79A8-2B20-4E0E-A145-08404493A7F6}.Release|x86.Build.0 = Release|x86 + {98E13BC4-6196-4346-AB4F-92DE33D84589}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {98E13BC4-6196-4346-AB4F-92DE33D84589}.Debug|Any CPU.Build.0 = Debug|Any CPU {98E13BC4-6196-4346-AB4F-92DE33D84589}.Debug|x64.ActiveCfg = Debug|x64 {98E13BC4-6196-4346-AB4F-92DE33D84589}.Debug|x64.Build.0 = Debug|x64 {98E13BC4-6196-4346-AB4F-92DE33D84589}.Debug|x86.ActiveCfg = Debug|x86 {98E13BC4-6196-4346-AB4F-92DE33D84589}.Debug|x86.Build.0 = Debug|x86 + {98E13BC4-6196-4346-AB4F-92DE33D84589}.Release|Any CPU.ActiveCfg = Release|Any CPU + {98E13BC4-6196-4346-AB4F-92DE33D84589}.Release|Any CPU.Build.0 = Release|Any CPU {98E13BC4-6196-4346-AB4F-92DE33D84589}.Release|x64.ActiveCfg = Release|x64 {98E13BC4-6196-4346-AB4F-92DE33D84589}.Release|x64.Build.0 = Release|x64 {98E13BC4-6196-4346-AB4F-92DE33D84589}.Release|x86.ActiveCfg = Release|x86 {98E13BC4-6196-4346-AB4F-92DE33D84589}.Release|x86.Build.0 = Release|x86 + {7987FD7B-AAC9-4C7F-8188-C54FDE35C3D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7987FD7B-AAC9-4C7F-8188-C54FDE35C3D6}.Debug|Any CPU.Build.0 = Debug|Any CPU {7987FD7B-AAC9-4C7F-8188-C54FDE35C3D6}.Debug|x64.ActiveCfg = Debug|x64 {7987FD7B-AAC9-4C7F-8188-C54FDE35C3D6}.Debug|x64.Build.0 = Debug|x64 {7987FD7B-AAC9-4C7F-8188-C54FDE35C3D6}.Debug|x86.ActiveCfg = Debug|x86 {7987FD7B-AAC9-4C7F-8188-C54FDE35C3D6}.Debug|x86.Build.0 = Debug|x86 + {7987FD7B-AAC9-4C7F-8188-C54FDE35C3D6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7987FD7B-AAC9-4C7F-8188-C54FDE35C3D6}.Release|Any CPU.Build.0 = Release|Any CPU {7987FD7B-AAC9-4C7F-8188-C54FDE35C3D6}.Release|x64.ActiveCfg = Release|x64 {7987FD7B-AAC9-4C7F-8188-C54FDE35C3D6}.Release|x64.Build.0 = Release|x64 {7987FD7B-AAC9-4C7F-8188-C54FDE35C3D6}.Release|x86.ActiveCfg = Release|x86 {7987FD7B-AAC9-4C7F-8188-C54FDE35C3D6}.Release|x86.Build.0 = Release|x86 - {61E45B12-B972-136D-6066-1CD28A5429E1}.Debug|x64.ActiveCfg = Debug|x64 - {61E45B12-B972-136D-6066-1CD28A5429E1}.Debug|x64.Build.0 = Debug|x64 - {61E45B12-B972-136D-6066-1CD28A5429E1}.Debug|x86.ActiveCfg = Debug|x86 - {61E45B12-B972-136D-6066-1CD28A5429E1}.Debug|x86.Build.0 = Debug|x86 - {61E45B12-B972-136D-6066-1CD28A5429E1}.Release|x64.ActiveCfg = Release|x64 - {61E45B12-B972-136D-6066-1CD28A5429E1}.Release|x64.Build.0 = Release|x64 - {61E45B12-B972-136D-6066-1CD28A5429E1}.Release|x86.ActiveCfg = Release|x86 - {61E45B12-B972-136D-6066-1CD28A5429E1}.Release|x86.Build.0 = Release|x86 + {E1E456E7-D224-460F-9C65-C6FFAD69F1A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E1E456E7-D224-460F-9C65-C6FFAD69F1A5}.Debug|Any CPU.Build.0 = Debug|Any CPU {E1E456E7-D224-460F-9C65-C6FFAD69F1A5}.Debug|x64.ActiveCfg = Debug|x64 {E1E456E7-D224-460F-9C65-C6FFAD69F1A5}.Debug|x64.Build.0 = Debug|x64 {E1E456E7-D224-460F-9C65-C6FFAD69F1A5}.Debug|x86.ActiveCfg = Debug|x86 {E1E456E7-D224-460F-9C65-C6FFAD69F1A5}.Debug|x86.Build.0 = Debug|x86 + {E1E456E7-D224-460F-9C65-C6FFAD69F1A5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E1E456E7-D224-460F-9C65-C6FFAD69F1A5}.Release|Any CPU.Build.0 = Release|Any CPU {E1E456E7-D224-460F-9C65-C6FFAD69F1A5}.Release|x64.ActiveCfg = Release|x64 {E1E456E7-D224-460F-9C65-C6FFAD69F1A5}.Release|x64.Build.0 = Release|x64 {E1E456E7-D224-460F-9C65-C6FFAD69F1A5}.Release|x86.ActiveCfg = Release|x86 {E1E456E7-D224-460F-9C65-C6FFAD69F1A5}.Release|x86.Build.0 = Release|x86 + {E9735476-7460-42E5-AA5A-8F3244C3B0A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E9735476-7460-42E5-AA5A-8F3244C3B0A5}.Debug|Any CPU.Build.0 = Debug|Any CPU {E9735476-7460-42E5-AA5A-8F3244C3B0A5}.Debug|x64.ActiveCfg = Debug|x64 {E9735476-7460-42E5-AA5A-8F3244C3B0A5}.Debug|x64.Build.0 = Debug|x64 {E9735476-7460-42E5-AA5A-8F3244C3B0A5}.Debug|x86.ActiveCfg = Debug|x86 {E9735476-7460-42E5-AA5A-8F3244C3B0A5}.Debug|x86.Build.0 = Debug|x86 + {E9735476-7460-42E5-AA5A-8F3244C3B0A5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E9735476-7460-42E5-AA5A-8F3244C3B0A5}.Release|Any CPU.Build.0 = Release|Any CPU {E9735476-7460-42E5-AA5A-8F3244C3B0A5}.Release|x64.ActiveCfg = Release|x64 {E9735476-7460-42E5-AA5A-8F3244C3B0A5}.Release|x64.Build.0 = Release|x64 {E9735476-7460-42E5-AA5A-8F3244C3B0A5}.Release|x86.ActiveCfg = Release|x86 {E9735476-7460-42E5-AA5A-8F3244C3B0A5}.Release|x86.Build.0 = Release|x86 + {B7DD9EF9-2AD4-4FB7-9576-35B92502D1AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B7DD9EF9-2AD4-4FB7-9576-35B92502D1AD}.Debug|Any CPU.Build.0 = Debug|Any CPU {B7DD9EF9-2AD4-4FB7-9576-35B92502D1AD}.Debug|x64.ActiveCfg = Debug|x64 {B7DD9EF9-2AD4-4FB7-9576-35B92502D1AD}.Debug|x64.Build.0 = Debug|x64 {B7DD9EF9-2AD4-4FB7-9576-35B92502D1AD}.Debug|x86.ActiveCfg = Debug|x86 {B7DD9EF9-2AD4-4FB7-9576-35B92502D1AD}.Debug|x86.Build.0 = Debug|x86 + {B7DD9EF9-2AD4-4FB7-9576-35B92502D1AD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B7DD9EF9-2AD4-4FB7-9576-35B92502D1AD}.Release|Any CPU.Build.0 = Release|Any CPU {B7DD9EF9-2AD4-4FB7-9576-35B92502D1AD}.Release|x64.ActiveCfg = Release|x64 {B7DD9EF9-2AD4-4FB7-9576-35B92502D1AD}.Release|x64.Build.0 = Release|x64 {B7DD9EF9-2AD4-4FB7-9576-35B92502D1AD}.Release|x86.ActiveCfg = Release|x86 {B7DD9EF9-2AD4-4FB7-9576-35B92502D1AD}.Release|x86.Build.0 = Release|x86 + {38A8E2D8-FB7B-43E2-B7D2-929A64263723}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {38A8E2D8-FB7B-43E2-B7D2-929A64263723}.Debug|Any CPU.Build.0 = Debug|Any CPU {38A8E2D8-FB7B-43E2-B7D2-929A64263723}.Debug|x64.ActiveCfg = Debug|x64 {38A8E2D8-FB7B-43E2-B7D2-929A64263723}.Debug|x64.Build.0 = Debug|x64 {38A8E2D8-FB7B-43E2-B7D2-929A64263723}.Debug|x86.ActiveCfg = Debug|x86 {38A8E2D8-FB7B-43E2-B7D2-929A64263723}.Debug|x86.Build.0 = Debug|x86 + {38A8E2D8-FB7B-43E2-B7D2-929A64263723}.Release|Any CPU.ActiveCfg = Release|Any CPU + {38A8E2D8-FB7B-43E2-B7D2-929A64263723}.Release|Any CPU.Build.0 = Release|Any CPU {38A8E2D8-FB7B-43E2-B7D2-929A64263723}.Release|x64.ActiveCfg = Release|x64 {38A8E2D8-FB7B-43E2-B7D2-929A64263723}.Release|x64.Build.0 = Release|x64 {38A8E2D8-FB7B-43E2-B7D2-929A64263723}.Release|x86.ActiveCfg = Release|x86 {38A8E2D8-FB7B-43E2-B7D2-929A64263723}.Release|x86.Build.0 = Release|x86 - {E0A14053-8CBD-4EB7-911A-477EA8A315ED}.Debug|x64.ActiveCfg = Debug|x64 - {E0A14053-8CBD-4EB7-911A-477EA8A315ED}.Debug|x64.Build.0 = Debug|x64 - {E0A14053-8CBD-4EB7-911A-477EA8A315ED}.Debug|x86.ActiveCfg = Debug|x86 - {E0A14053-8CBD-4EB7-911A-477EA8A315ED}.Debug|x86.Build.0 = Debug|x86 - {E0A14053-8CBD-4EB7-911A-477EA8A315ED}.Release|x64.ActiveCfg = Release|x64 - {E0A14053-8CBD-4EB7-911A-477EA8A315ED}.Release|x64.Build.0 = Release|x64 - {E0A14053-8CBD-4EB7-911A-477EA8A315ED}.Release|x86.ActiveCfg = Release|x86 - {E0A14053-8CBD-4EB7-911A-477EA8A315ED}.Release|x86.Build.0 = Release|x86 + {5B6870A9-0E41-4290-B85E-2A5F8BCF78AF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5B6870A9-0E41-4290-B85E-2A5F8BCF78AF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5B6870A9-0E41-4290-B85E-2A5F8BCF78AF}.Debug|x64.ActiveCfg = Debug|x64 + {5B6870A9-0E41-4290-B85E-2A5F8BCF78AF}.Debug|x64.Build.0 = Debug|x64 + {5B6870A9-0E41-4290-B85E-2A5F8BCF78AF}.Debug|x86.ActiveCfg = Debug|x86 + {5B6870A9-0E41-4290-B85E-2A5F8BCF78AF}.Debug|x86.Build.0 = Debug|x86 + {5B6870A9-0E41-4290-B85E-2A5F8BCF78AF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5B6870A9-0E41-4290-B85E-2A5F8BCF78AF}.Release|Any CPU.Build.0 = Release|Any CPU + {5B6870A9-0E41-4290-B85E-2A5F8BCF78AF}.Release|x64.ActiveCfg = Release|x64 + {5B6870A9-0E41-4290-B85E-2A5F8BCF78AF}.Release|x64.Build.0 = Release|x64 + {5B6870A9-0E41-4290-B85E-2A5F8BCF78AF}.Release|x86.ActiveCfg = Release|x86 + {5B6870A9-0E41-4290-B85E-2A5F8BCF78AF}.Release|x86.Build.0 = Release|x86 + {C17ECE0A-BBAF-430D-9FDA-59A9BA4FB92E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C17ECE0A-BBAF-430D-9FDA-59A9BA4FB92E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C17ECE0A-BBAF-430D-9FDA-59A9BA4FB92E}.Debug|x64.ActiveCfg = Debug|x64 + {C17ECE0A-BBAF-430D-9FDA-59A9BA4FB92E}.Debug|x64.Build.0 = Debug|x64 + {C17ECE0A-BBAF-430D-9FDA-59A9BA4FB92E}.Debug|x86.ActiveCfg = Debug|x86 + {C17ECE0A-BBAF-430D-9FDA-59A9BA4FB92E}.Debug|x86.Build.0 = Debug|x86 + {C17ECE0A-BBAF-430D-9FDA-59A9BA4FB92E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C17ECE0A-BBAF-430D-9FDA-59A9BA4FB92E}.Release|Any CPU.Build.0 = Release|Any CPU + {C17ECE0A-BBAF-430D-9FDA-59A9BA4FB92E}.Release|x64.ActiveCfg = Release|x64 + {C17ECE0A-BBAF-430D-9FDA-59A9BA4FB92E}.Release|x64.Build.0 = Release|x64 + {C17ECE0A-BBAF-430D-9FDA-59A9BA4FB92E}.Release|x86.ActiveCfg = Release|x86 + {C17ECE0A-BBAF-430D-9FDA-59A9BA4FB92E}.Release|x86.Build.0 = Release|x86 + {952375C0-1A8D-403A-B21F-18910D950987}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {952375C0-1A8D-403A-B21F-18910D950987}.Debug|Any CPU.Build.0 = Debug|Any CPU + {952375C0-1A8D-403A-B21F-18910D950987}.Debug|x64.ActiveCfg = Debug|x64 + {952375C0-1A8D-403A-B21F-18910D950987}.Debug|x64.Build.0 = Debug|x64 + {952375C0-1A8D-403A-B21F-18910D950987}.Debug|x86.ActiveCfg = Debug|x86 + {952375C0-1A8D-403A-B21F-18910D950987}.Debug|x86.Build.0 = Debug|x86 + {952375C0-1A8D-403A-B21F-18910D950987}.Release|Any CPU.ActiveCfg = Release|Any CPU + {952375C0-1A8D-403A-B21F-18910D950987}.Release|Any CPU.Build.0 = Release|Any CPU + {952375C0-1A8D-403A-B21F-18910D950987}.Release|x64.ActiveCfg = Release|x64 + {952375C0-1A8D-403A-B21F-18910D950987}.Release|x64.Build.0 = Release|x64 + {952375C0-1A8D-403A-B21F-18910D950987}.Release|x86.ActiveCfg = Release|x86 + {952375C0-1A8D-403A-B21F-18910D950987}.Release|x86.Build.0 = Release|x86 + {627E39C2-4445-4232-B522-EDF76689B808}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {627E39C2-4445-4232-B522-EDF76689B808}.Debug|Any CPU.Build.0 = Debug|Any CPU + {627E39C2-4445-4232-B522-EDF76689B808}.Debug|x64.ActiveCfg = Debug|x64 + {627E39C2-4445-4232-B522-EDF76689B808}.Debug|x64.Build.0 = Debug|x64 + {627E39C2-4445-4232-B522-EDF76689B808}.Debug|x86.ActiveCfg = Debug|x86 + {627E39C2-4445-4232-B522-EDF76689B808}.Debug|x86.Build.0 = Debug|x86 + {627E39C2-4445-4232-B522-EDF76689B808}.Release|Any CPU.ActiveCfg = Release|Any CPU + {627E39C2-4445-4232-B522-EDF76689B808}.Release|Any CPU.Build.0 = Release|Any CPU + {627E39C2-4445-4232-B522-EDF76689B808}.Release|x64.ActiveCfg = Release|x64 + {627E39C2-4445-4232-B522-EDF76689B808}.Release|x64.Build.0 = Release|x64 + {627E39C2-4445-4232-B522-EDF76689B808}.Release|x86.ActiveCfg = Release|x86 + {627E39C2-4445-4232-B522-EDF76689B808}.Release|x86.Build.0 = Release|x86 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -303,16 +494,22 @@ Global {2397EA24-CB8E-482F-8E93-F3D626D255E5} = {0AC29DCD-5AA3-4658-A945-F5B5BCE8FD5E} {92D73867-1202-43E5-B615-FB998ED8F796} = {0AC29DCD-5AA3-4658-A945-F5B5BCE8FD5E} {8F6B306B-3E55-4295-B822-BF29E7781B2B} = {0AC29DCD-5AA3-4658-A945-F5B5BCE8FD5E} - {3EAAFD71-DD96-427D-8793-643DADD2F3A3} = {642147AB-23FD-4715-AE26-90BE6C755FD0} + {F3AFCF41-1F6C-46C9-920A-A19BAA0D1067} = {642147AB-23FD-4715-AE26-90BE6C755FD0} + {EA375184-410A-4986-9135-EBAA26A16366} = {642147AB-23FD-4715-AE26-90BE6C755FD0} + {CD5097B1-03FD-4AEF-9CCF-25DA6E8EA2D2} = {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} + {FDF8C618-7AFF-4756-A138-4AF3AD9F679D} = {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} {E1E456E7-D224-460F-9C65-C6FFAD69F1A5} = {5FED724C-4009-411F-BA19-FEC3642B956D} {E9735476-7460-42E5-AA5A-8F3244C3B0A5} = {5FED724C-4009-411F-BA19-FEC3642B956D} {38A8E2D8-FB7B-43E2-B7D2-929A64263723} = {642147AB-23FD-4715-AE26-90BE6C755FD0} - {E0A14053-8CBD-4EB7-911A-477EA8A315ED} = {6F5F6BB9-64D2-4F82-B6AE-162361DF4965} + {5B6870A9-0E41-4290-B85E-2A5F8BCF78AF} = {6F5F6BB9-64D2-4F82-B6AE-162361DF4965} + {C17ECE0A-BBAF-430D-9FDA-59A9BA4FB92E} = {6F5F6BB9-64D2-4F82-B6AE-162361DF4965} + {952375C0-1A8D-403A-B21F-18910D950987} = {6F5F6BB9-64D2-4F82-B6AE-162361DF4965} + {627E39C2-4445-4232-B522-EDF76689B808} = {6F5F6BB9-64D2-4F82-B6AE-162361DF4965} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E1C98156-D4B8-411B-8A31-0EFC1D63BFBE} diff --git a/TombEditor.Tests/FlybyTimeline/FlybyPreviewTests.cs b/TombEditor.Tests/FlybyTimeline/FlybyPreviewTests.cs deleted file mode 100644 index 5e87a42a50..0000000000 --- a/TombEditor.Tests/FlybyTimeline/FlybyPreviewTests.cs +++ /dev/null @@ -1,91 +0,0 @@ -using System.Numerics; -using TombEditor.Controls.FlybyTimeline.Preview; -using TombLib; -using TombLib.Graphics; -using TombLib.LevelData; - -namespace TombEditor.Tests.FlybyTimeline; - -[TestClass] -public class FlybyPreviewTests -{ - [TestMethod] - public void GetFrameForCamera_ConvertsToWorldSpaceAndRadians() - { - var level = FlybyTestFactory.CreateLevel(); - var room = FlybyTestFactory.CreateRoom(level, 1, new VectorInt3(1024, 256, 2048)); - var camera = FlybyTestFactory.AddCamera(room, 1, 0, new Vector3(128.0f, 64.0f, 256.0f), - rotationX: 15.0f, rotationY: 45.0f, roll: 30.0f, fov: 80.0f); - - var frame = FlybyPreview.GetFrameForCamera(camera); - - Assert.AreEqual(new Vector3(1152.0f, 320.0f, 2304.0f), frame.Position); - Assert.AreEqual(MathC.DegToRad(45.0f), frame.RotationY, 0.001f); - Assert.AreEqual(-MathC.DegToRad(15.0f), frame.RotationX, 0.001f); - Assert.AreEqual(MathC.DegToRad(30.0f), frame.Roll, 0.001f); - Assert.AreEqual(MathC.DegToRad(80.0f), frame.Fov, 0.001f); - } - - [TestMethod] - public void ApplyFrame_UpdatesCameraStateAndTarget() - { - var camera = new FreeCamera(Vector3.Zero, 0.0f, 0.0f, -MathF.PI * 0.5f, MathF.PI * 0.5f, MathC.DegToRad(60.0f)); - var frame = new FlybyFrameState - { - Position = new Vector3(10.0f, 20.0f, 30.0f), - RotationY = MathC.DegToRad(90.0f), - RotationX = 0.0f, - Fov = MathC.DegToRad(70.0f) - }; - - FlybyPreview.ApplyFrame(camera, frame); - - Assert.AreEqual(frame.Position, camera.Position); - Assert.AreEqual(frame.RotationY, camera.RotationY, 0.001f); - Assert.AreEqual(frame.RotationX, camera.RotationX, 0.001f); - Assert.AreEqual(frame.Fov, camera.FieldOfView, 0.001f); - Assert.AreEqual(10.0f + Level.SectorSizeUnit, camera.Target.X, 0.001f); - Assert.AreEqual(20.0f, camera.Target.Y, 0.001f); - Assert.AreEqual(30.0f, camera.Target.Z, 0.001f); - } - - [TestMethod] - public void SetStaticFrame_PinsAndAppliesTheFrame() - { - var savedCamera = new FreeCamera(Vector3.Zero, 0.0f, 0.0f, -MathF.PI * 0.5f, MathF.PI * 0.5f, MathC.DegToRad(60.0f)); - using var preview = new FlybyPreview(savedCamera); - var previewCamera = new FreeCamera(Vector3.Zero, 0.0f, 0.0f, -MathF.PI * 0.5f, MathF.PI * 0.5f, MathC.DegToRad(60.0f)); - var frame = new FlybyFrameState - { - Position = new Vector3(64.0f, 32.0f, 16.0f), - RotationY = MathC.DegToRad(180.0f), - RotationX = MathC.DegToRad(-10.0f), - Roll = MathC.DegToRad(5.0f), - Fov = MathC.DegToRad(75.0f) - }; - - preview.SetStaticFrame(previewCamera, frame); - - Assert.IsTrue(preview.StaticFrame.HasValue); - Assert.AreEqual(frame.Position, preview.StaticFrame.Value.Position); - Assert.AreEqual(frame.Position, previewCamera.Position); - } - - [TestMethod] - public void BeginExternalUpdate_AtEndOfSequenceMarksPreviewFinished() - { - var level = FlybyTestFactory.CreateLevel(); - FlybyTestFactory.CreateLinearSequence(level.Rooms[0], 2, - new Vector3(0.0f, 0.0f, 0.0f), - new Vector3(0.0f, 0.0f, 1024.0f)); - - var savedCamera = new FreeCamera(Vector3.Zero, 0.0f, 0.0f, -MathF.PI * 0.5f, MathF.PI * 0.5f, MathC.DegToRad(60.0f)); - using var preview = new FlybyPreview(level, 2, savedCamera); - float playbackEnd = preview.Cache.Timing.TimelineToPlaybackTime(preview.Cache.TotalDuration + 1.0f); - - preview.BeginExternalUpdate(playbackEnd); - - Assert.IsTrue(preview.IsFinished); - Assert.AreEqual(preview.Cache.TotalDuration, preview.GetCurrentTimeSeconds(), 0.001f); - } -} diff --git a/TombEditor.Tests/FlybyTimeline/FlybySequenceCacheTests.cs b/TombEditor.Tests/FlybyTimeline/FlybySequenceCacheTests.cs deleted file mode 100644 index f3516ade53..0000000000 --- a/TombEditor.Tests/FlybyTimeline/FlybySequenceCacheTests.cs +++ /dev/null @@ -1,103 +0,0 @@ -using System.Numerics; -using TombEditor.Controls.FlybyTimeline; -using TombEditor.Controls.FlybyTimeline.Sequence; - -namespace TombEditor.Tests.FlybyTimeline; - -[TestClass] -public class FlybySequenceCacheTests -{ - [TestMethod] - public void Constructor_IsInvalidWhenFewerThanTwoAssignedCamerasExist() - { - var level = FlybyTestFactory.CreateLevel(); - var camera = FlybyTestFactory.AddCamera(level.Rooms[0], 1, 0, Vector3.Zero); - var cache = FlybySequenceCache.Build([camera], useSmoothPause: false); - - Assert.IsFalse(cache.IsValid); - Assert.AreEqual(0.0f, cache.TotalDuration, 0.001f); - } - - [TestMethod] - public void SampleAtTime_ClampsToFirstAndLastFrame() - { - var level = FlybyTestFactory.CreateLevel(); - var cameras = FlybyTestFactory.CreateLinearSequence(level.Rooms[0], 2, - new Vector3(0.0f, 0.0f, 0.0f), - new Vector3(0.0f, 0.0f, 1024.0f)); - - var cache = FlybySequenceCache.Build(cameras, useSmoothPause: false); - - var firstFrame = cache.SampleAtTime(float.NegativeInfinity); - var lastFrame = cache.SampleAtTime(float.PositiveInfinity); - - Assert.AreEqual(cameras[0].Position, firstFrame.Position); - Assert.AreEqual(cameras[1].Position, lastFrame.Position); - } - - [TestMethod] - public void TimelineAndPlaybackTimeConversions_SkipCutRegions() - { - var level = FlybyTestFactory.CreateLevel(); - var cameras = FlybyTestFactory.CreateLinearSequence(level.Rooms[0], 3, - new Vector3(0.0f, 0.0f, 0.0f), - new Vector3(0.0f, 0.0f, 1024.0f), - new Vector3(0.0f, 0.0f, 2048.0f), - new Vector3(0.0f, 0.0f, 3072.0f)); - - cameras[1].Flags = FlybyConstants.FlagCameraCut; - cameras[1].Timer = 3; - - var cache = FlybySequenceCache.Build(cameras, useSmoothPause: false); - var cutRegion = cache.Timing.CutRegions[0]; - float playbackAfterCut = cache.Timing.TimelineToPlaybackTime(cutRegion.EndTime + FlybyConstants.TimeStep); - float timelineAfterCut = cache.Timing.PlaybackToTimelineTime(playbackAfterCut); - - Assert.IsTrue(playbackAfterCut < cutRegion.EndTime); - Assert.AreEqual(cutRegion.EndTime + FlybyConstants.TimeStep, timelineAfterCut, 0.001f); - } - - [TestMethod] - public void GetSpeedAtTime_ReturnsInvalidInsideCutRegionsAndOutsideRange() - { - var level = FlybyTestFactory.CreateLevel(); - var cameras = FlybyTestFactory.CreateLinearSequence(level.Rooms[0], 4, - new Vector3(0.0f, 0.0f, 0.0f), - new Vector3(0.0f, 0.0f, 1024.0f), - new Vector3(0.0f, 0.0f, 2048.0f), - new Vector3(0.0f, 0.0f, 3072.0f)); - - cameras[1].Flags = FlybyConstants.FlagCameraCut; - cameras[1].Timer = 3; - - var cache = FlybySequenceCache.Build(cameras, useSmoothPause: false); - var cutRegion = cache.Timing.CutRegions[0]; - float insideCut = (cutRegion.StartTime + cutRegion.EndTime) * 0.5f; - - Assert.AreEqual(-1.0f, cache.GetSpeedAtTime(-0.1f), 0.001f); - Assert.AreEqual(-1.0f, cache.GetSpeedAtTime(insideCut), 0.001f); - Assert.IsTrue(cache.GetSpeedAtTime(0.0f) > 0.0f); - } - - [TestMethod] - public void SampleAtTime_AfterCut_ResetsSplineFromSkippedCamera() - { - var level = FlybyTestFactory.CreateLevel(); - var cameras = FlybyTestFactory.CreateLinearSequence(level.Rooms[0], 5, - new Vector3(0.0f, 0.0f, 0.0f), - new Vector3(0.0f, 0.0f, 1024.0f), - new Vector3(1024.0f, 0.0f, 2048.0f), - new Vector3(0.0f, 0.0f, 3072.0f), - new Vector3(0.0f, 0.0f, 4096.0f)); - - cameras[1].Flags = FlybyConstants.FlagCameraCut; - cameras[1].Timer = 3; - - var cache = FlybySequenceCache.Build(cameras, useSmoothPause: false); - var cutRegion = cache.Timing.CutRegions[0]; - var firstPostCutFrame = cache.SampleAtTime(cutRegion.EndTime + FlybyConstants.TimeStep); - - Assert.AreEqual(0.0f, firstPostCutFrame.Position.X, 0.01f); - Assert.IsTrue(firstPostCutFrame.Position.Z > cameras[3].Position.Z); - } -} diff --git a/TombEditor.Tests/FlybyTimeline/FlybySequenceHelperTests.cs b/TombEditor.Tests/FlybyTimeline/FlybySequenceHelperTests.cs deleted file mode 100644 index d70116fe04..0000000000 --- a/TombEditor.Tests/FlybyTimeline/FlybySequenceHelperTests.cs +++ /dev/null @@ -1,169 +0,0 @@ -using System.Numerics; -using TombEditor.Controls.FlybyTimeline; -using TombEditor.Controls.FlybyTimeline.Sequence; -using TombLib; -using TombLib.Graphics; -using TombLib.LevelData; - -namespace TombEditor.Tests.FlybyTimeline; - -[TestClass] -public class FlybySequenceHelperTests -{ - [TestMethod] - public void GetCameras_ReturnsOrderedSequenceSubsetAcrossRooms() - { - var level = FlybyTestFactory.CreateLevel(); - var firstRoom = level.Rooms[0]; - var secondRoom = FlybyTestFactory.CreateRoom(level, 1, new VectorInt3(10, 0, 0)); - - FlybyTestFactory.AddCamera(firstRoom, 7, 5, new Vector3(0.0f, 0.0f, 0.0f)); - FlybyTestFactory.AddCamera(secondRoom, 7, 2, new Vector3(128.0f, 0.0f, 0.0f)); - FlybyTestFactory.AddCamera(firstRoom, 3, 0, new Vector3(256.0f, 0.0f, 0.0f)); - FlybyTestFactory.AddCamera(secondRoom, 7, 9, new Vector3(384.0f, 0.0f, 0.0f)); - - var cameras = FlybySequenceHelper.GetCameras(level, 7); - - CollectionAssert.AreEqual(new ushort[] { 2, 5, 9 }, cameras.Select(camera => camera.Number).ToArray()); - Assert.IsTrue(cameras.All(camera => camera.Sequence == 7)); - } - - [TestMethod] - public void GetAllSequences_ReturnsDistinctSequenceIds() - { - var level = FlybyTestFactory.CreateLevel(); - var firstRoom = level.Rooms[0]; - var secondRoom = FlybyTestFactory.CreateRoom(level, 1); - - FlybyTestFactory.AddCamera(firstRoom, 2, 0, Vector3.Zero); - FlybyTestFactory.AddCamera(firstRoom, 2, 1, new Vector3(0.0f, 0.0f, 1024.0f)); - FlybyTestFactory.AddCamera(secondRoom, 9, 0, new Vector3(0.0f, 0.0f, 2048.0f)); - - var sequences = FlybySequenceHelper.GetAllSequences(level); - - CollectionAssert.AreEquivalent(new ushort[] { 2, 9 }, sequences.ToArray()); - } - - [TestMethod] - public void GetFreezeDuration_ReturnsSecondsOnlyForNonCutFreezeCameras() - { - var freezeCamera = new FlybyCameraInstance - { - Flags = FlybyConstants.FlagFreezeCamera, - Timer = FlybyTestFactory.FreezeFrames(60) - }; - - var cutFreezeCamera = new FlybyCameraInstance - { - Flags = FlybyConstants.FlagFreezeCamera | FlybyConstants.FlagCameraCut, - Timer = FlybyTestFactory.FreezeFrames(60) - }; - - Assert.AreEqual(2.0f, FlybySequenceHelper.GetFreezeDuration(freezeCamera), 0.001f); - Assert.AreEqual(0.0f, FlybySequenceHelper.GetFreezeDuration(cutFreezeCamera), 0.001f); - } - - [TestMethod] - public void FindCameraIndexAtTimeAndFindInsertionIndex_UsePrecomputedTiming() - { - var level = FlybyTestFactory.CreateLevel(); - var cameras = FlybyTestFactory.CreateLinearSequence(level.Rooms[0], 4, - new Vector3(0.0f, 0.0f, 0.0f), - new Vector3(0.0f, 0.0f, 1024.0f), - new Vector3(0.0f, 0.0f, 2048.0f)); - - var timing = FlybySequenceTiming.Build(cameras, useSmoothPause: false); - float midpoint = (timing.GetCameraTime(0) + timing.GetCameraTime(1)) * 0.5f; - float slightlyAfterMidpoint = midpoint + 0.01f; - - Assert.AreEqual(0, FlybySequenceHelper.FindCameraIndexAtTime(cameras, midpoint * 0.5f, timing)); - Assert.AreEqual(0, FlybySequenceHelper.FindCameraIndexAtTime(cameras, midpoint, timing)); - Assert.AreEqual(1, FlybySequenceHelper.FindCameraIndexAtTime(cameras, slightlyAfterMidpoint, timing)); - Assert.AreEqual(2, FlybySequenceHelper.FindCameraIndexAtTime(cameras, timing.GetCameraTime(2), timing)); - Assert.AreEqual(1, FlybySequenceHelper.FindInsertionIndex(cameras, midpoint, timing)); - Assert.AreEqual(cameras.Count, FlybySequenceHelper.FindInsertionIndex(cameras, -0.01f, timing)); - Assert.AreEqual(cameras.Count, FlybySequenceHelper.FindInsertionIndex(cameras, timing.GetCameraTime(cameras.Count - 1), timing)); - Assert.AreEqual(cameras.Count, FlybySequenceHelper.FindInsertionIndex(cameras, float.NaN, timing)); - } - - [TestMethod] - public void FormattersAndFlagHelpers_HandleEdgeCases() - { - Assert.AreEqual("00:01.23", FlybySequenceHelper.FormatTimecode(1.239f)); - Assert.AreEqual("1.24", FlybySequenceHelper.FormatRulerLabel(1.239f)); - Assert.AreEqual("00:00.00", FlybySequenceHelper.FormatTimecode(float.PositiveInfinity)); - Assert.AreEqual("0.00", FlybySequenceHelper.FormatRulerLabel(float.NaN)); - - Assert.IsTrue(FlybySequenceHelper.GetFlagBit(1 << 15, 15)); - Assert.IsFalse(FlybySequenceHelper.GetFlagBit(ushort.MaxValue, 16)); - Assert.IsTrue(FlybySequenceHelper.GetFlagBit(1 << 7, 7)); - Assert.IsFalse(FlybySequenceHelper.GetFlagBit(0, 20)); - Assert.AreEqual((ushort)(1 << 3), FlybySequenceHelper.SetFlagBit(0, 3, true)); - Assert.AreEqual((ushort)0, FlybySequenceHelper.SetFlagBit(1 << 3, 3, false)); - } - - [TestMethod] - public void CameraListsMatchByReference_RequiresSameInstancesInSameOrder() - { - var first = new FlybyCameraInstance(); - var second = new FlybyCameraInstance(); - IReadOnlyList original = [first, second]; - IReadOnlyList sameOrder = [first, second]; - IReadOnlyList reversed = [second, first]; - IReadOnlyList differentInstance = [first, new FlybyCameraInstance()]; - - Assert.IsTrue(FlybySequenceHelper.CameraListsMatchByReference(original, sameOrder)); - Assert.IsFalse(FlybySequenceHelper.CameraListsMatchByReference(original, reversed)); - Assert.IsFalse(FlybySequenceHelper.CameraListsMatchByReference(original, differentInstance)); - Assert.IsFalse(FlybySequenceHelper.CameraListsMatchByReference(original, null)); - } - - [TestMethod] - public void SolveSegmentSpeedForTargetTime_AdjustsTimingTowardRequestedTarget() - { - var level = FlybyTestFactory.CreateLevel(); - var cameras = FlybyTestFactory.CreateLinearSequence(level.Rooms[0], 5, - new Vector3(0.0f, 0.0f, 0.0f), - new Vector3(0.0f, 0.0f, 1024.0f), - new Vector3(0.0f, 0.0f, 2048.0f)); - - var originalTiming = FlybySequenceTiming.Build(cameras, useSmoothPause: false); - float targetTime = originalTiming.GetCameraTime(1) * 0.6f; - - float solvedSpeed = FlybySequenceHelper.SolveSegmentSpeedForTargetTime(cameras, 0, 1, targetTime, useSmoothPause: false); - cameras[0].Speed = solvedSpeed; - - var adjustedTiming = FlybySequenceTiming.Build(cameras, useSmoothPause: false); - - Assert.IsTrue(solvedSpeed >= FlybyConstants.MinSpeed); - Assert.AreEqual(targetTime, adjustedTiming.GetCameraTime(1), 0.05f); - } - - [TestMethod] - public void SnapSpeedToStep_RoundsToNearestIncrement() - { - float snappedDown = FlybySequenceHelper.SnapSpeedToStep(1.124f, FlybyConstants.TimelineDragSpeedStep); - float snappedUp = FlybySequenceHelper.SnapSpeedToStep(1.126f, FlybyConstants.TimelineDragSpeedStep); - - Assert.AreEqual(1.12f, snappedDown, 0.001f); - Assert.AreEqual(1.13f, snappedUp, 0.001f); - } - - [TestMethod] - public void ApplyEditorCameraRotation_CopiesOrientationAndFov() - { - var editorCamera = new FreeCamera(new Vector3(10.0f, 20.0f, 30.0f), 0.0f, 0.0f, - -MathF.PI * 0.5f, MathF.PI * 0.5f, MathC.DegToRad(70.0f)) - { - Target = new Vector3(1010.0f, -480.0f, 530.0f) - }; - - var flyby = new FlybyCameraInstance(); - - FlybySequenceHelper.ApplyEditorCameraRotation(editorCamera, flyby); - - Assert.AreEqual(63.4349f, flyby.RotationY, 0.01f); - Assert.AreEqual(-24.0948f, flyby.RotationX, 0.01f); - Assert.AreEqual(70.0f, flyby.Fov, 0.001f); - } -} diff --git a/TombEditor.Tests/FlybyTimeline/FlybySequenceTimingTests.cs b/TombEditor.Tests/FlybyTimeline/FlybySequenceTimingTests.cs deleted file mode 100644 index 770b16035c..0000000000 --- a/TombEditor.Tests/FlybyTimeline/FlybySequenceTimingTests.cs +++ /dev/null @@ -1,222 +0,0 @@ -using System.Numerics; -using TombEditor.Controls.FlybyTimeline; -using TombEditor.Controls.FlybyTimeline.Sequence; -using TombLib.LevelData; - -namespace TombEditor.Tests.FlybyTimeline; - -[TestClass] -public class FlybySequenceTimingTests -{ - [TestMethod] - public void Build_ReturnsEmptyTimingForEmptySequence() - { - var timing = FlybySequenceTiming.Build([], useSmoothPause: false); - - Assert.AreEqual(0, timing.CameraCount); - Assert.AreEqual(0.0f, timing.TotalDuration, 0.001f); - Assert.AreEqual(0, timing.SplineTimeline.Count); - Assert.AreEqual(0, timing.CutRegions.Count); - } - - [TestMethod] - public void Build_AddsFreezeDurationToLaterCameraTimes() - { - var level = FlybyTestFactory.CreateLevel(); - var cameras = FlybyTestFactory.CreateLinearSequence(level.Rooms[0], 1, - new Vector3(0.0f, 0.0f, 0.0f), - new Vector3(0.0f, 0.0f, 1024.0f), - new Vector3(0.0f, 0.0f, 2048.0f)); - - cameras[1].Flags = FlybyConstants.FlagFreezeCamera; - cameras[1].Timer = FlybyTestFactory.FreezeFrames(30); - - var timing = FlybySequenceTiming.Build(cameras, useSmoothPause: false); - - Assert.AreEqual(1.0f, timing.GetFreezeDuration(1), 0.001f); - Assert.IsTrue(timing.GetCameraTime(2) - timing.GetCameraTime(1) >= 1.0f); - } - - [TestMethod] - public void Build_WithSmoothPauseDelaysTheFreezeBoundary() - { - var level = FlybyTestFactory.CreateLevel(TRVersion.Game.TombEngine); - var cameras = FlybyTestFactory.CreateLinearSequence(level.Rooms[0], 2, - new Vector3(0.0f, 0.0f, 0.0f), - new Vector3(0.0f, 0.0f, 1024.0f), - new Vector3(0.0f, 0.0f, 2048.0f)); - - cameras[1].Flags = FlybyConstants.FlagFreezeCamera; - cameras[1].Timer = FlybyTestFactory.FreezeFrames(30); - - var standardTiming = FlybySequenceTiming.Build(cameras, useSmoothPause: false); - var smoothTiming = FlybySequenceTiming.Build(cameras, useSmoothPause: true); - - Assert.IsTrue(smoothTiming.GetCameraTime(1) > standardTiming.GetCameraTime(1)); - Assert.IsTrue(smoothTiming.TotalDuration > standardTiming.TotalDuration); - } - - [TestMethod] - public void Build_WithFinalFreeze_KeepsSequenceEndAligned() - { - var level = FlybyTestFactory.CreateLevel(); - var cameras = FlybyTestFactory.CreateLinearSequence(level.Rooms[0], 3, - new Vector3(0.0f, 0.0f, 0.0f), - new Vector3(0.0f, 0.0f, 1024.0f), - new Vector3(0.0f, 0.0f, 2048.0f), - new Vector3(0.0f, 0.0f, 3072.0f)); - - cameras[3].Flags = FlybyConstants.FlagFreezeCamera; - cameras[3].Timer = FlybyTestFactory.FreezeFrames(15); - - var timing = FlybySequenceTiming.Build(cameras, useSmoothPause: false); - float expectedEndTime = timing.GetCameraTime(3) + timing.GetFreezeDuration(3); - - Assert.AreEqual(expectedEndTime, timing.TotalDuration, 0.001f); - } - - [TestMethod] - public void Build_CapturesCutRegionsAndBypassDurations() - { - var level = FlybyTestFactory.CreateLevel(); - var cameras = FlybyTestFactory.CreateLinearSequence(level.Rooms[0], 3, - new Vector3(0.0f, 0.0f, 0.0f), - new Vector3(0.0f, 0.0f, 1024.0f), - new Vector3(0.0f, 0.0f, 2048.0f), - new Vector3(0.0f, 0.0f, 3072.0f)); - - cameras[1].Flags = FlybyConstants.FlagCameraCut; - cameras[1].Timer = 3; - - var timing = FlybySequenceTiming.Build(cameras, useSmoothPause: false); - - Assert.AreEqual(1, timing.CutRegions.Count); - Assert.IsTrue(timing.GetCutBypassDuration(1) > 0.0f); - Assert.IsTrue(timing.CutRegions[0].EndTime > timing.CutRegions[0].StartTime); - Assert.IsTrue(timing.TotalDuration >= timing.GetCameraTime(3)); - } - - [TestMethod] - public void Build_ResolvesCutTargetsByCameraNumber() - { - var level = FlybyTestFactory.CreateLevel(); - var cameras = FlybyTestFactory.CreateLinearSequence(level.Rooms[0], 5, - new Vector3(0.0f, 0.0f, 0.0f), - new Vector3(0.0f, 0.0f, 1024.0f), - new Vector3(0.0f, 0.0f, 2048.0f), - new Vector3(0.0f, 0.0f, 3072.0f)); - - cameras[0].Number = 0; - cameras[1].Number = 2; - cameras[2].Number = 4; - cameras[3].Number = 6; - - cameras[1].Flags = FlybyConstants.FlagCameraCut; - cameras[1].Timer = 4; - - var timing = FlybySequenceTiming.Build(cameras, useSmoothPause: false); - float expectedBypass = timing.GetCameraTime(2) - timing.GetCameraTime(1); - float cutRegionDuration = timing.CutRegions[0].EndTime - timing.CutRegions[0].StartTime; - - Assert.AreEqual(1, timing.CutRegions.Count); - Assert.AreEqual(expectedBypass, timing.GetCutBypassDuration(1), 0.001f); - Assert.AreEqual(expectedBypass, cutRegionDuration, FlybyConstants.TimeStep); - } - - [TestMethod] - public void Build_IgnoresAmbiguousCutTargetsWhenCameraNumbersDuplicate() - { - var level = FlybyTestFactory.CreateLevel(); - var cameras = FlybyTestFactory.CreateLinearSequence(level.Rooms[0], 6, - new Vector3(0.0f, 0.0f, 0.0f), - new Vector3(0.0f, 0.0f, 1024.0f), - new Vector3(0.0f, 0.0f, 2048.0f), - new Vector3(0.0f, 0.0f, 3072.0f)); - - cameras[0].Number = 0; - cameras[1].Number = 2; - cameras[2].Number = 2; - cameras[3].Number = 4; - - cameras[0].Flags = FlybyConstants.FlagCameraCut; - cameras[0].Timer = 2; - - var timing = FlybySequenceTiming.Build(cameras, useSmoothPause: false); - - Assert.AreEqual(0.0f, timing.GetCutBypassDuration(0), 0.001f); - Assert.AreEqual(0, timing.CutRegions.Count); - } - - [TestMethod] - public void Build_WithSmoothPauseCutBypassMatchesSkippedFreezeTiming() - { - var level = FlybyTestFactory.CreateLevel(TRVersion.Game.TombEngine); - var cameras = FlybyTestFactory.CreateLinearSequence(level.Rooms[0], 7, - new Vector3(0.0f, 0.0f, 0.0f), - new Vector3(0.0f, 0.0f, 1024.0f), - new Vector3(0.0f, 0.0f, 2048.0f), - new Vector3(0.0f, 0.0f, 3072.0f)); - - cameras[1].Flags = FlybyConstants.FlagCameraCut; - cameras[1].Timer = 3; - cameras[2].Flags = FlybyConstants.FlagFreezeCamera; - cameras[2].Timer = FlybyTestFactory.FreezeFrames(30); - - var timing = FlybySequenceTiming.Build(cameras, useSmoothPause: true); - float expectedBypass = timing.GetCameraTime(3) - timing.GetCameraTime(1); - - Assert.AreEqual(1, timing.CutRegions.Count); - Assert.AreEqual(expectedBypass, timing.CutRegions[0].Duration, FlybyConstants.TimeStep); - } - - [TestMethod] - public void Build_WithSmoothPauseAndFrozenCutTarget_KeepsFinalCameraAligned() - { - var level = FlybyTestFactory.CreateLevel(TRVersion.Game.TombEngine); - var cameras = FlybyTestFactory.CreateLinearSequence(level.Rooms[0], 8, - new Vector3(0.0f, 0.0f, 0.0f), - new Vector3(0.0f, 0.0f, 1024.0f), - new Vector3(0.0f, 0.0f, 2048.0f), - new Vector3(0.0f, 0.0f, 3072.0f), - new Vector3(0.0f, 0.0f, 4096.0f)); - - cameras[1].Flags = FlybyConstants.FlagCameraCut; - cameras[1].Timer = 3; - cameras[3].Flags = FlybyConstants.FlagFreezeCamera; - cameras[3].Timer = FlybyTestFactory.FreezeFrames(30); - - var timing = FlybySequenceTiming.Build(cameras, useSmoothPause: true); - float finalCameraTime = timing.GetCameraTime(4); - - Assert.IsTrue(timing.TotalDuration >= finalCameraTime); - Assert.AreEqual(finalCameraTime, timing.TotalDuration, FlybyConstants.TimeStep); - } - - [TestMethod] - public void Build_CompletesForLongSlowSequences() - { - const int cameraCount = 130; - - var level = FlybyTestFactory.CreateLevel(); - var positions = new Vector3[cameraCount]; - - for (int i = 0; i < positions.Length; i++) - positions[i] = new Vector3(0.0f, 0.0f, i * 1024.0f); - - var cameras = FlybyTestFactory.CreateLinearSequence(level.Rooms[0], 4, positions); - - foreach (var camera in cameras) - camera.Speed = FlybyConstants.MinSpeed; - - var task = Task.Run(() => FlybySequenceTiming.Build(cameras, useSmoothPause: false)); - - Assert.IsTrue(task.Wait(TimeSpan.FromSeconds(5.0))); - - var timing = task.Result; - float lastCameraTime = timing.GetCameraTime(cameras.Count - 1); - - Assert.AreEqual(cameraCount, timing.CameraCount); - Assert.IsTrue(float.IsFinite(lastCameraTime)); - Assert.IsTrue(lastCameraTime > 0.0f); - } -} diff --git a/TombEditor.Tests/FlybyTimeline/FlybyTestFactory.cs b/TombEditor.Tests/FlybyTimeline/FlybyTestFactory.cs deleted file mode 100644 index d5a4c33e75..0000000000 --- a/TombEditor.Tests/FlybyTimeline/FlybyTestFactory.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System.Numerics; -using TombLib; -using TombLib.LevelData; - -namespace TombEditor.Tests.FlybyTimeline; - -internal static class FlybyTestFactory -{ - public static Level CreateLevel(TRVersion.Game version = TRVersion.Game.TR4) - => Level.CreateSimpleLevel(version); - - public static Room CreateRoom(Level level, int roomIndex, VectorInt3? worldPos = null) - { - var room = new Room(level, Room.DefaultRoomDimensions, Room.DefaultRoomDimensions, level.Settings.DefaultAmbientLight, $"Room {roomIndex}"); - - if (worldPos is not null) - room.WorldPos = worldPos.Value; - - level.Rooms[roomIndex] = room; - return room; - } - - public static FlybyCameraInstance AddCamera(Room room, ushort sequence, ushort number, Vector3 position, - float speed = 1.0f, short timer = 0, ushort flags = 0, float rotationX = 0.0f, float rotationY = 0.0f, - float roll = 0.0f, float fov = 80.0f) - { - var camera = new FlybyCameraInstance - { - Sequence = sequence, - Number = number, - Position = position, - Speed = speed, - Timer = timer, - Flags = flags, - RotationX = rotationX, - RotationY = rotationY, - Roll = roll, - Fov = fov - }; - - room.AddObject(room.Level, camera); - return camera; - } - - public static IReadOnlyList CreateLinearSequence(Room room, ushort sequence, - params Vector3[] positions) - { - var cameras = new List(positions.Length); - - for (ushort i = 0; i < positions.Length; i++) - cameras.Add(AddCamera(room, sequence, i, positions[i])); - - return cameras; - } - - public static short FreezeFrames(int frames) - => (short)(frames << 4); -} 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/Services/Plugins/Deployment/TRNGPluginDeploymentService.cs b/TombIDE/TombIDE.ProjectMaster/Services/Plugins/Deployment/TRNGPluginDeploymentService.cs index bd8e83263e..4f0c7dd9ff 100644 --- a/TombIDE/TombIDE.ProjectMaster/Services/Plugins/Deployment/TRNGPluginDeploymentService.cs +++ b/TombIDE/TombIDE.ProjectMaster/Services/Plugins/Deployment/TRNGPluginDeploymentService.cs @@ -1,6 +1,6 @@ using System.IO; using TombIDE.Shared.NewStructure; -using TombLib.Scripting.ClassicScript.Resources; +using TombLib.Scripting.ClassicScript.Mnemonics; namespace TombIDE.ProjectMaster.Services.Plugins.Deployment; @@ -8,6 +8,7 @@ public sealed class TRNGPluginDeploymentService : IPluginDeploymentService { private const string PluginDllPattern = "plugin_*.dll"; private const string PluginScriptPattern = "plugin_*.script"; + private readonly ClassicScriptMnemonicCatalogService _mnemonicCatalogService = new(); public void DeployPlugins(IGameProject project) { @@ -44,7 +45,7 @@ public void SynchronizeScriptReferences(IGameProject project) scriptFile.CopyTo(destinationPath, true); } - // Refresh mnemonic data - MnemonicData.SetupConstants(DefaultPaths.InternalNGCDirectory); + // Refresh ClassicScript mnemonic cache after plugin script sync. + _mnemonicCatalogService.Reload(DefaultPaths.InternalNGCDirectory); } } 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/ScriptingStudio.cs b/TombIDE/TombIDE.ScriptingStudio/Bases/ScriptingStudio.cs new file mode 100644 index 0000000000..9bce3caf43 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/Bases/ScriptingStudio.cs @@ -0,0 +1,82 @@ +using System; +using TombIDE.ScriptingStudio.CommandSurface; +using TombIDE.ScriptingStudio.Shell; +using TombIDE.ScriptingStudio.Services; +using TombIDE.ScriptingStudio.UI; +using TombIDE.ScriptingStudio.WorkspaceProfile; +using TombIDE.Shared; +using TombLib.Scripting.UI.Editors; + +namespace TombIDE.ScriptingStudio.Bases +{ + public abstract class ScriptingStudio : StudioBase + { + private Action _applyUserSettings; + private Action _applyEditorSettings; + private IStudioDocumentCommandHandler _documentCommandHandler; + private IStudioDocumentCommandStatusProvider _documentCommandStatusProvider; + private IStudioDocumentStatusStripProvider _documentStatusStripProvider; + private Action _dockPanelLayoutRestored; + private IStudioPaneContributionProvider _paneContributionProvider; + private ScriptingWorkspaceProfile _workspaceProfile; + private IStudioWorkspaceAutomationProvider _workspaceAutomationProvider; + + protected ScriptingStudio() + : base(IDE.Instance.Project.GetScriptRootDirectory(), IDE.Instance.Project.GetEngineRootDirectoryPath()) + { + } + + protected void InitializeHost( + ScriptingWorkspaceProfile workspaceProfile, + Action applyEditorSettings, + Action applyUserSettings, + IStudioDocumentCommandStatusProvider documentCommandStatusProvider = null, + IStudioDocumentCommandHandler documentCommandHandler = null, + IStudioDocumentStatusStripProvider documentStatusStripProvider = null, + IStudioPaneContributionProvider paneContributionProvider = null, + IStudioWorkspaceAutomationProvider workspaceAutomationProvider = null, + Action dockPanelLayoutRestored = null) + { + _workspaceProfile = workspaceProfile ?? throw new ArgumentNullException(nameof(workspaceProfile)); + _applyEditorSettings = applyEditorSettings ?? throw new ArgumentNullException(nameof(applyEditorSettings)); + _applyUserSettings = applyUserSettings ?? throw new ArgumentNullException(nameof(applyUserSettings)); + _documentCommandStatusProvider = documentCommandStatusProvider; + _documentCommandHandler = documentCommandHandler; + _documentStatusStripProvider = documentStatusStripProvider; + _paneContributionProvider = paneContributionProvider; + _workspaceAutomationProvider = workspaceAutomationProvider; + _dockPanelLayoutRestored = dockPanelLayoutRestored; + + ApplyWorkspaceProfileCommandSurfaceContributions(); + ApplyPaneContributionProvider(); + ApplyWorkspaceProfileStartupPolicy(); + } + + protected sealed override void ApplyUserSettings(IEditorControl editor) + => _applyEditorSettings(editor, Configs); + + protected sealed override void ApplyUserSettings() + => _applyUserSettings(); + + protected sealed override IStudioDocumentCommandStatusProvider DocumentCommandStatusProvider + => _documentCommandStatusProvider; + + protected sealed override IStudioDocumentCommandHandler DocumentCommandHandler + => _documentCommandHandler; + + protected sealed override IStudioDocumentStatusStripProvider DocumentStatusStripProvider + => _documentStatusStripProvider; + + protected sealed override void OnDockPanelLayoutRestored() + => _dockPanelLayoutRestored?.Invoke(); + + protected sealed override IStudioPaneContributionProvider PaneContributionProvider + => _paneContributionProvider; + + protected sealed override ScriptingWorkspaceProfile WorkspaceProfile + => _workspaceProfile; + + protected sealed override IStudioWorkspaceAutomationProvider WorkspaceAutomationProvider + => _workspaceAutomationProvider; + } +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/Bases/StudioBase.cs b/TombIDE/TombIDE.ScriptingStudio/Bases/StudioBase.cs index c4bf0fe0a2..eb615dbc7f 100644 --- a/TombIDE/TombIDE.ScriptingStudio/Bases/StudioBase.cs +++ b/TombIDE/TombIDE.ScriptingStudio/Bases/StudioBase.cs @@ -1,30 +1,73 @@ using DarkUI.Docking; +using DarkUI.Forms; using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using System.Windows.Forms; -using System.Windows.Forms.Integration; +using TombIDE.ScriptingStudio.CommandSurface; using TombIDE.ScriptingStudio.Controls; +using TombIDE.ScriptingStudio.DocumentOutline; +using TombIDE.ScriptingStudio.FileExplorer; +using TombIDE.ScriptingStudio.FindAndReplace; using TombIDE.ScriptingStudio.Helpers; -using TombIDE.ScriptingStudio.Objects; +using TombIDE.ScriptingStudio.Navigation; using TombIDE.ScriptingStudio.Properties; +using TombIDE.ScriptingStudio.Shell; +using TombIDE.ScriptingStudio.Services; +using TombIDE.ScriptingStudio.Shortcuts; +using TombIDE.ScriptingStudio.WorkspaceProfile; using TombIDE.ScriptingStudio.Settings; using TombIDE.ScriptingStudio.ToolStrips; using TombIDE.ScriptingStudio.ToolWindows; using TombIDE.ScriptingStudio.UI; using TombIDE.Shared; using TombLib.Forms; -using TombLib.Scripting.Bases; -using TombLib.Scripting.ClassicScript; -using TombLib.Scripting.Forms; -using TombLib.Scripting.Interfaces; -using TombLib.Scripting.Objects; +using TombLib.Scripting.UI.Bases; +using TombLib.Scripting.UI.Editors; +using TombLib.Scripting.UI.Strings; +using FileExplorerToolWindow = TombIDE.ScriptingStudio.FileExplorer.FileExplorer; namespace TombIDE.ScriptingStudio.Bases { public abstract class StudioBase : Control { + private static readonly UICommand[] CapabilityDrivenDocumentCommands = + { + UICommand.TrimWhiteSpace, + UICommand.ToggleComment, + UICommand.CommentOut, + UICommand.Uncomment, + UICommand.ToggleBookmark, + UICommand.PrevBookmark, + UICommand.NextBookmark, + UICommand.ClearBookmarks, + UICommand.PrevSection, + UICommand.NextSection, + UICommand.ClearString, + UICommand.RemoveLastString, + UICommand.Reindent, + UICommand.GoToDefinition, + UICommand.FindReferences, + UICommand.RenameSymbol, + UICommand.TypeFirstAvailableId, + UICommand.NewFileAtCaret + }; + + private static readonly UICommand[] WorkspaceViewCommands = + { + UICommand.ToolStrip, + UICommand.ContentExplorer, + UICommand.FileExplorer, + UICommand.ReferenceBrowser, + UICommand.CompilerLogs, + UICommand.SearchResults, + UICommand.LuaDiagnostics, + UICommand.LuaReferencesResults, + UICommand.StatusStrip + }; + #region Properties public DocumentMode DocumentMode @@ -32,6 +75,11 @@ public DocumentMode DocumentMode get => MenuStrip.DocumentMode; set { + MenuStrip.DocumentModeContributionItems = GetDocumentMenuStripContributions(CurrentEditor, value); + ToolStrip.DocumentModeContributionItems = GetDocumentToolStripContributions(CurrentEditor, value); + EditorContextMenu.DocumentModeContributionItems = GetDocumentContextMenuContributions(CurrentEditor, value); + UpdateStatusStripContributions(CurrentEditor, value); + MenuStrip.DocumentMode = value; ToolStrip.DocumentMode = value; StatusStrip.DocumentMode = value; @@ -47,8 +95,12 @@ public string ScriptRootDirectoryPath set { EditorTabControl.ScriptRootDirectoryPath = value; - FileExplorer.RootDirectoryPath = value; - FileExplorer.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName; + + if (FileExplorer != null) + { + FileExplorer.RootDirectoryPath = value; + FileExplorer.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName; + } } } @@ -70,6 +122,11 @@ public string ScriptRootDirectoryPath protected ConfigurationCollection Configs; protected FormFindReplace FindReplaceForm; + private readonly StudioShortcutBindingService _shortcutBindings; + private readonly StudioEditorLifecycleCoordinator _editorLifecycleCoordinator; + private readonly Dictionary _viewControlRegistry = new(StringComparer.Ordinal); + private readonly Dictionary _paneRegistryByKey = new(StringComparer.Ordinal); + private readonly Dictionary _paneRegistryByCommand = new(); public StudioMenuStrip MenuStrip; public StudioToolStrip ToolStrip; @@ -86,7 +143,7 @@ public string ScriptRootDirectoryPath public EditorTabControl EditorTabControl; public ContentExplorer ContentExplorer; - public FileExplorer FileExplorer; + public FileExplorerToolWindow FileExplorer; public CompilerLogs CompilerLogs; public SearchResults SearchResults; @@ -108,6 +165,8 @@ public string ScriptRootDirectoryPath protected ToolStripMenuItem ReferenceBrowserViewItem; protected ToolStripMenuItem CompilerLogsViewItem; protected ToolStripMenuItem SearchResultsViewItem; + protected ToolStripMenuItem LuaDiagnosticsViewItem; + protected ToolStripMenuItem LuaReferencesResultsViewItem; protected ToolStripMenuItem StatusStripViewItem; #endregion Fields @@ -117,15 +176,26 @@ public string ScriptRootDirectoryPath public StudioBase(string scriptRootDirectoryPath, string engineDirectoryPath) { Configs = new ConfigurationCollection(); + _shortcutBindings = new StudioShortcutBindingService(); InitializeToolStrips(); InitializeTabControl(); InitializeContentExplorer(); InitializeFileExplorer(); InitializeFindReplaceForm(); + _editorLifecycleCoordinator = new StudioEditorLifecycleCoordinator( + EditorTabControl, + ApplyUserSettings, + UpdateUI, + UpdateUndoRedoSaveStates, + OnToolStripItemClicked, + CanExecuteCommand, + _shortcutBindings); + _editorLifecycleCoordinator.Attach(); CompilerLogs = new CompilerLogs(); - SearchResults = new SearchResults(EditorTabControl); + SearchResults = new SearchResults(NavigateToSearchResult); + RegisterBuiltInPaneContributions(); IDE.Instance.IDEEventRaised += OnIDEEventRaised; @@ -135,23 +205,26 @@ public StudioBase(string scriptRootDirectoryPath, string engineDirectoryPath) private void InitializeToolStrips() { - MenuStrip = new StudioMenuStrip() { Dock = DockStyle.Top, StudioMode = StudioMode }; + MenuStrip = new StudioMenuStrip() { Dock = DockStyle.Top, ShortcutBindingService = _shortcutBindings, StudioMode = StudioMode.None }; MenuStrip.ItemClicked += ToolStrip_ItemClicked; MenuStrip.StudioModeChanged += MenuStrip_StudioModeChanged; - ToolStrip = new StudioToolStrip() { Dock = DockStyle.Top, StudioMode = StudioMode }; + ToolStrip = new StudioToolStrip() { Dock = DockStyle.Top, StudioMode = StudioMode.None }; ToolStrip.ItemClicked += ToolStrip_ItemClicked; ToolStrip.StudioModeChanged += ToolStrip_StudioModeChanged; StatusStrip = new StudioStatusStrip() { Dock = DockStyle.Bottom }; - EditorContextMenu = new EditorContextMenu(); + EditorContextMenu = new EditorContextMenu() { ShortcutBindingService = _shortcutBindings }; EditorContextMenu.ItemClicked += ToolStrip_ItemClicked; Controls.Add(StatusStrip); Controls.Add(ToolStrip); Controls.Add(MenuStrip); + RegisterViewControl(nameof(ToolStrip), ToolStrip); + RegisterViewControl(nameof(StatusStrip), StatusStrip); + InitializeFrequentlyAccessedMenuStripItems(); InitializeFrequentlyAccessedToolStripItems(); } @@ -160,7 +233,6 @@ private void InitializeTabControl() { EditorTabControl = new EditorTabControl(); EditorTabControl.SelectedIndexChanged += EditorTabControl_SelectedIndexChanged; - EditorTabControl.FileOpened += EditorTabControl_FileOpened; EditorTabControl.TabClosing += EditorTabControl_TabClosing; EditorTabControlDocument = new DarkDocument(); @@ -176,7 +248,7 @@ private void InitializeContentExplorer() private void InitializeFileExplorer() { - FileExplorer = new FileExplorer(); + FileExplorer = new FileExplorerToolWindow(); FileExplorer.FileOpened += FileExplorer_FileOpened; FileExplorer.FileChanged += FileExplorer_FileChanged; FileExplorer.FileRenamed += FileExplorer_FileRenamed; @@ -218,6 +290,7 @@ private void InitializeDockPanel() // Apply the current layout DockPanel.RestoreDockPanelState(DockPanelState, FindDockContentByKey); + OnDockPanelLayoutRestored(); ApplyMessageFilters(); } @@ -236,7 +309,11 @@ 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; + + ApplyWorkspaceCommandSurface(); } private void InitializeFrequentlyAccessedToolStripItems() @@ -245,6 +322,8 @@ private void InitializeFrequentlyAccessedToolStripItems() RedoToolStripButton = ToolStrip.FindItem(UICommand.Redo); SaveToolStripButton = ToolStrip.FindItem(UICommand.Save); SaveAllToolStripButton = ToolStrip.FindItem(UICommand.SaveAll); + + ApplyWorkspaceCommandSurface(); } private void ApplyMessageFilters() @@ -253,6 +332,45 @@ private void ApplyMessageFilters() Application.AddMessageFilter(DockPanel.DockResizeFilter); } + protected void ApplyWorkspaceProfileCommandSurfaceContributions() + { + if (WorkspaceProfile is null) + return; + + MenuStrip.StudioModeContributionItems = WorkspaceProfile.MenuStripContributions; + ToolStrip.StudioModeContributionItems = WorkspaceProfile.ToolStripContributions; + + MenuStrip.RebuildStudioModeItems(); + ToolStrip.RebuildStudioModeItems(); + UpdateStatusStripContributions(CurrentEditor, DocumentMode); + } + + protected void ApplyWorkspaceProfileStartupPolicy() + { + if (WorkspaceProfile is null) + return; + + DockPanelState = WorkspaceProfile.LoadDockPanelState(); + FileExplorer.ExcludedDirectoryFilter = WorkspaceProfile.FileExplorerExcludedDirectoryFilter; + FileExplorer.Filter = WorkspaceProfile.FileExplorerFilter; + FileExplorer.CommentPrefix = WorkspaceProfile.CommentPrefix; + + WorkspaceProfile.RegisterEditors(EditorTabControl); + EditorTabControl.CheckPreviousSession(); + + if (!string.IsNullOrWhiteSpace(WorkspaceProfile.InitialFilePath)) + EditorTabControl.OpenFile(WorkspaceProfile.InitialFilePath); + } + + protected void ApplyPaneContributionProvider() + { + if (PaneContributionProvider is null) + return; + + foreach (StudioPaneContribution contribution in PaneContributionProvider.GetPaneContributions()) + RegisterPaneContribution(contribution); + } + #endregion Construction #region Override / new region @@ -286,7 +404,14 @@ protected override void OnVisibleChanged(EventArgs e) protected virtual void OnIDEEventRaised(IIDEEvent obj) { if (obj is IDE.ProgramClosingEvent e) + { e.CanClose = EditorTabControl.AskSaveAll(); + + if (WorkspaceProfile is not null) + WorkspaceProfile.SaveDockPanelState(DockPanel.GetDockPanelState()); + } + + WorkspaceAutomationProvider?.HandleIDEEvent(obj); } protected virtual void OnToolStripItemClicked(UICommand e) @@ -295,17 +420,117 @@ 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(); + + protected virtual IStudioDocumentCommandStatusProvider DocumentCommandStatusProvider + => null; + + protected virtual IStudioDocumentCommandHandler DocumentCommandHandler + => null; + + protected virtual ScriptingWorkspaceProfile WorkspaceProfile + => null; + + protected virtual IStudioDocumentCommandSurfaceProvider DocumentCommandSurfaceProvider + => XmlDocumentCommandSurfaceProvider.Instance; + + protected virtual IStudioPaneContributionProvider PaneContributionProvider + => null; + + protected virtual IStudioDocumentStatusStripProvider DocumentStatusStripProvider + => null; + + protected virtual IStudioWorkspaceAutomationProvider WorkspaceAutomationProvider + => null; + + protected virtual IReadOnlyList GetDocumentContextMenuContributions(IEditorControl editor, DocumentMode documentMode) + => editor is null || DocumentCommandSurfaceProvider is null + ? [] + : DocumentCommandSurfaceProvider.GetContextMenuItems(editor, documentMode); + + protected virtual IReadOnlyList GetDocumentMenuStripContributions(IEditorControl editor, DocumentMode documentMode) + => editor is null || DocumentCommandSurfaceProvider is null + ? [] + : DocumentCommandSurfaceProvider.GetMenuStripItems(editor, documentMode); + + protected virtual IReadOnlyList GetDocumentToolStripContributions(IEditorControl editor, DocumentMode documentMode) + => editor is null || DocumentCommandSurfaceProvider is null + ? [] + : DocumentCommandSurfaceProvider.GetToolStripItems(editor, documentMode); + + protected virtual IReadOnlyList GetDocumentStatusStripSegments(IEditorControl editor, DocumentMode documentMode) + => editor is null || DocumentStatusStripProvider is null + ? [] + : DocumentStatusStripProvider.GetSegments(editor, documentMode); + + protected bool CanExecuteCommand(UICommand command) + { + switch (command) + { + case UICommand.Undo: + return CanExecuteUndo(); + + case UICommand.Redo: + return CanExecuteRedo(); + + case UICommand.Save: + return CurrentEditor != null && CurrentEditor.IsContentChanged; + + case UICommand.SaveAs: + case UICommand.Cut: + case UICommand.Copy: + case UICommand.Paste: + case UICommand.Find: + case UICommand.SelectAll: + return CurrentEditor != null; + + case UICommand.SaveAll: + return !EditorTabControl.IsEveryTabSaved(); + + default: + if (TryGetDocumentCommandEnabled(command, out bool isEnabled)) + return isEnabled; + + return true; + } + } + #endregion Virtual region #region Abstract region - public abstract StudioMode StudioMode { get; } - protected abstract void ApplyUserSettings(IEditorControl editor); protected abstract void ApplyUserSettings(); - protected abstract void Build(); - protected abstract void RestoreDefaultLayout(); - protected abstract void ShowDocumentation(); + protected virtual void Build() + => WorkspaceAutomationProvider?.Build(); + + protected virtual void RestoreDefaultLayout() + { + if (WorkspaceProfile is null) + return; + + DockPanelState = WorkspaceProfile.DefaultLayout; + + DockPanel.RemoveContent(); + DockPanel.RestoreDockPanelState(DockPanelState, FindDockContentByKey); + OnDockPanelLayoutRestored(); + } + + protected virtual void ShowDocumentation() + => WorkspaceAutomationProvider?.ShowDocumentation(); #endregion Abstract region @@ -326,47 +551,22 @@ private void ToolStrip_ItemClicked(object sender, EventArgs e) private void EditorTabControl_SelectedIndexChanged(object sender, EventArgs e) => UpdateUI(); - private void EditorTabControl_FileOpened(object sender, EventArgs e) - { - var editor = sender as IEditorControl; - - editor.ContentChangedWorkerRunCompleted += Editor_ContentChangedWorkerRunCompleted; - - if (editor is TextEditorBase textEditor) - { - textEditor.KeyDown += TextEditor_KeyDown; - textEditor.TextChanged += TextEditor_TextChanged; - } - - ApplyUserSettings(editor); - - UpdateUI(); - } - private void EditorTabControl_TabClosing(object sender, TabControlCancelEventArgs e) { if (!e.Cancel && EditorTabControl.TabCount == 1) DocumentMode = DocumentMode.None; } - private void Editor_ContentChangedWorkerRunCompleted(object sender, EventArgs e) - => UpdateUndoRedoSaveStates(); - - private void TextEditor_KeyDown(object sender, System.Windows.Input.KeyEventArgs e) - { - 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); - } - - private void TextEditor_TextChanged(object sender, EventArgs e) - => UpdateUndoRedoSaveStates(); - private void ContentExplorer_ObjectClicked(object sender, ObjectClickedEventArgs e) => CurrentEditor.GoToObject(e.ObjectName, e.IdentifyingObject); private void FileExplorer_FileOpened(object sender, FileOpenedEventArgs e) - => EditorTabControl.OpenFile(e.FilePath, e.EditorType); + { + if (e.OpenSourceView) + EditorTabControl.OpenSourceFile(e.FilePath); + else + EditorTabControl.OpenFile(e.FilePath, e.EditorType); + } private void FileExplorer_FileChanged(object sender, FileSystemEventArgs e) { @@ -382,14 +582,7 @@ private void FileExplorer_FileDeleted(object sender, FileSystemEventArgs e) private void FormFindReplace_FindAllPerformed(object sender, FindReplaceEventArgs e) { - if (!DockPanel.ContainsContent(SearchResults)) - { - SearchResults.DockArea = DarkDockArea.Bottom; - DockPanel.AddContent(SearchResults); - } - - SearchResults.DockGroup.SetVisibleContent(SearchResults); - + ShowPane(UICommand.SearchResults); SearchResults.UpdateResults(e); } @@ -400,38 +593,35 @@ private void FormFindReplace_FindAllPerformed(object sender, FindReplaceEventArg protected void UpdateUI() { if (CurrentEditor != null) - DocumentMode = FileHelper.GetDocumentModeOfEditor(CurrentEditor); + DocumentMode = EditorTabControl.GetDocumentMode(CurrentEditor); if (EditorTabControl.SelectedTab != null) { - if (CurrentEditor is TextEditorBase) - { - ElementHost elementHost = EditorTabControl.SelectedTab.Controls.OfType().FirstOrDefault(); + Control hostedControl = EditorTabControl.GetHostedControl(EditorTabControl.SelectedTab); - if (elementHost != null) - elementHost.ContextMenuStrip = EditorContextMenu; - } - else if (CurrentEditor is Control control) - control.ContextMenuStrip = EditorContextMenu; + if (hostedControl != null) + hostedControl.ContextMenuStrip = EditorContextMenu; } ContentExplorer.EditorControl = CurrentEditor; StatusStrip.EditorControl = CurrentEditor; + UpdateStatusStripContributions(CurrentEditor, DocumentMode); UpdateUndoRedoSaveStates(); + UpdateDocumentCommandStates(); } 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; @@ -447,13 +637,148 @@ protected void UpdateUndoRedoSaveStates() SaveAllToolStripButton.Enabled = SaveAllMenuItem.Enabled; } + protected void UpdateDocumentCommandStates() + { + foreach (UICommand command in CapabilityDrivenDocumentCommands) + SetCommandEnabled(command, CanExecuteCommand(command)); + } + + protected void SetCommandEnabled(UICommand command, bool isEnabled) + { + SetCommandEnabled(MenuStrip.FindItem(command), isEnabled); + SetCommandEnabled(ToolStrip.FindItem(command), isEnabled); + SetCommandEnabled(EditorContextMenu.FindItem(command), isEnabled); + } + + private bool TryGetDocumentCommandEnabled(UICommand command, out bool isEnabled) + { + if (TryGetBuiltInDocumentCommandEnabled(command, out isEnabled)) + { + if (CurrentEditor is not null + && DocumentCommandStatusProvider is not null + && DocumentCommandStatusProvider.TryGetEnabled(CurrentEditor, command, out bool providerEnabled)) + { + isEnabled = providerEnabled; + } + + return true; + } + + if (CurrentEditor is not null + && DocumentCommandStatusProvider is not null + && DocumentCommandStatusProvider.TryGetEnabled(CurrentEditor, command, out isEnabled)) + { + return true; + } + + isEnabled = false; + return false; + } + + private bool TryGetBuiltInDocumentCommandEnabled(UICommand command, out bool isEnabled) + { + if (CurrentEditor is TextEditorBase) + { + switch (command) + { + case UICommand.TabsToSpaces: + case UICommand.SpacesToTabs: + case UICommand.Reindent: + case UICommand.TrimWhiteSpace: + case UICommand.ToggleComment: + case UICommand.CommentOut: + case UICommand.Uncomment: + case UICommand.ToggleBookmark: + case UICommand.PrevBookmark: + case UICommand.NextBookmark: + case UICommand.ClearBookmarks: + isEnabled = true; + return true; + } + } + + if (CurrentEditor is StringEditor) + { + switch (command) + { + case UICommand.PrevSection: + case UICommand.NextSection: + case UICommand.ClearString: + case UICommand.RemoveLastString: + isEnabled = true; + return true; + } + } + + switch (command) + { + case UICommand.TrimWhiteSpace: + case UICommand.ToggleComment: + case UICommand.CommentOut: + case UICommand.Uncomment: + case UICommand.ToggleBookmark: + case UICommand.PrevBookmark: + case UICommand.NextBookmark: + case UICommand.ClearBookmarks: + case UICommand.PrevSection: + case UICommand.NextSection: + case UICommand.ClearString: + case UICommand.RemoveLastString: + case UICommand.Reindent: + case UICommand.GoToDefinition: + case UICommand.FindReferences: + case UICommand.RenameSymbol: + case UICommand.TypeFirstAvailableId: + case UICommand.NewFileAtCaret: + isEnabled = false; + return true; + default: + isEnabled = false; + return false; + } + } + + private static void SetCommandEnabled(ToolStripItem item, bool isEnabled) + { + if (item != null) + item.Enabled = isEnabled; + } + protected void UpdateViewMenu() { + ApplyWorkspaceCommandSurface(); + foreach (FieldInfo field in GetType().GetFields(BindingFlags.Instance | BindingFlags.NonPublic)) if (field.Name.EndsWith("ViewItem") && field.GetValue(this) is ToolStripMenuItem fieldValue) SetCheckedIfNotNull(fieldValue); } + private void ApplyWorkspaceCommandSurface() + { + if (WorkspaceProfile is null) + return; + + foreach (UICommand command in WorkspaceViewCommands) + SetCommandVisible(command, WorkspaceProfile.SupportsView(command)); + + SetCommandVisible(UICommand.Build, WorkspaceProfile.SupportsBuild); + SetCommandVisible(UICommand.ShowLogsAfterBuild, WorkspaceProfile.SupportsBuild); + SetCommandVisible(UICommand.ScriptingDocumentation, WorkspaceProfile.SupportsDocumentation); + } + + private void SetCommandVisible(UICommand command, bool isVisible) + { + SetCommandVisible(MenuStrip.FindItem(command), isVisible); + SetCommandVisible(ToolStrip.FindItem(command), isVisible); + SetCommandVisible(EditorContextMenu.FindItem(command), isVisible); + } + + private static void SetCommandVisible(ToolStripItem item, bool isVisible) + { + if (item != null) + item.Visible = isVisible; + } + private void HandleGlobalCommands(UICommand command) { switch (command) @@ -463,12 +788,15 @@ private void HandleGlobalCommands(UICommand command) case UICommand.Save: EditorTabControl.SaveFile(); break; case UICommand.SaveAs: EditorTabControl.SaveFileAs(); break; case UICommand.SaveAll: EditorTabControl.SaveAll(); break; - case UICommand.Build: Build(); break; + case UICommand.Build: + if (WorkspaceProfile is null || WorkspaceProfile.SupportsBuild) + Build(); + break; 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; @@ -487,7 +815,10 @@ private void HandleGlobalCommands(UICommand command) case UICommand.RestoreDefaultLayout: RestoreDefaultLayout(); break; // Help - case UICommand.ScriptingDocumentation: ShowDocumentation(); break; + case UICommand.ScriptingDocumentation: + if (WorkspaceProfile is null || WorkspaceProfile.SupportsDocumentation) + ShowDocumentation(); + break; case UICommand.About: ShowAboutForm(); break; } @@ -499,6 +830,9 @@ private void HandleGlobalCommands(UICommand command) protected virtual void HandleDocumentCommands(UICommand command) { + if (DocumentCommandHandler?.TryHandle(command) == true) + return; + if (CurrentEditor is TextEditorBase textEditor) switch (command) { @@ -506,12 +840,13 @@ 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; case UICommand.PrevBookmark: textEditor.GoToPrevBookmark(); break; case UICommand.NextBookmark: textEditor.GoToNextBookmark(); break; - case UICommand.ClearBookmarks: textEditor.ClearAllBookmarks(this); break; + case UICommand.ClearBookmarks: textEditor.ClearAllBookmarks(() => ConfirmBookmarkClear(this)); break; } if (CurrentEditor is StringEditor stringEditor) @@ -532,11 +867,39 @@ protected virtual void HandleDocumentCommands(UICommand command) } } + private static bool ConfirmBookmarkClear(IWin32Window promptOwner) + => DarkMessageBox.Show( + promptOwner, + "Are you sure you want to clear all bookmarks from the current document?", + "Are you sure?", + MessageBoxButtons.YesNo, + MessageBoxIcon.Question) == DialogResult.Yes; + + 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()); var menuItem = MenuStrip.FindItem(command) as ToolStripMenuItem; + if (control is null || menuItem is null) + return; + if (control is DarkToolWindow toolWindow) { ToggleToolWindow(toolWindow); @@ -563,17 +926,27 @@ protected void ShowAboutForm() /// NOTE: Can only catch public fields. Returns null on failure. /// protected DarkDockContent FindDockContentByKey(string key) - => GetControlByKey(key); + => _paneRegistryByKey.TryGetValue(key, out DarkDockContent content) + ? content + : GetControlByKey(key); /// /// NOTE: Can only catch public fields. Returns null on failure. /// protected T GetControlByKey(string key) where T : Control { + if (_viewControlRegistry.TryGetValue(key, out Control registeredControl)) + return registeredControl as T; + FieldInfo field = GetType().GetField(key); return field != null ? (field.GetValue(this) as T) : null; } + protected T GetPaneContent(UICommand command) where T : DarkDockContent + => _paneRegistryByCommand.TryGetValue(command, out DarkDockContent content) + ? content as T + : null; + protected void ToggleToolWindow(DarkToolWindow toolWindow) { if (toolWindow.DockPanel == null) @@ -589,9 +962,12 @@ protected void SetCheckedIfNotNull(ToolStripMenuItem item) UICommand command = (item.Tag as UIElementArgs).Command; if (command == UICommand.ToolStrip || command == UICommand.StatusStrip) - item.Checked = GetControlByKey(command.ToString()).Visible; + item.Checked = GetControlByKey(command.ToString())?.Visible ?? false; else - item.Checked = DockPanel != null && DockPanel.ContainsContent(GetControlByKey(command.ToString())); + { + DarkToolWindow toolWindow = GetControlByKey(command.ToString()); + item.Checked = toolWindow != null && DockPanel != null && DockPanel.ContainsContent(toolWindow); + } } } @@ -602,6 +978,19 @@ protected void UpdateSettings() UpdateSetting(UICommand.ReindentOnSave); } + protected void ApplyUserSettingsToOpenEditors(Action afterApply = null, Action afterAll = null) + { + foreach (TabPage tab in EditorTabControl.TabPages) + { + IEditorControl editor = EditorTabControl.GetEditorOfTab(tab); + ApplyUserSettings(editor); + afterApply?.Invoke(editor); + } + + afterAll?.Invoke(); + UpdateSettings(); + } + protected void UpdateSetting(UICommand command) { var menuItem = MenuStrip.FindItem(command) as ToolStripMenuItem; @@ -632,12 +1021,79 @@ protected void ToggleSetting(UICommand command) protected void ShowSettingsForm() { - using (var form = new FormTextEditorSettings(StudioMode)) - if (form.ShowDialog() == DialogResult.OK) - { - Configs = new ConfigurationCollection(); - ApplyUserSettings(); - } + if (WorkspaceProfile is null) + return; + + var viewModel = new ScriptingSettingsWindowViewModel(WorkspaceProfile, DocumentMode); + var window = new ScriptingSettingsWindow { DataContext = viewModel }; + + if (FindForm() is Form ownerForm) + new System.Windows.Interop.WindowInteropHelper(window).Owner = ownerForm.Handle; + + if (window.ShowDialog() == true) + { + Configs = new ConfigurationCollection(); + ApplyUserSettings(); + } + } + + protected void ShowPane(UICommand command, DarkDockArea defaultDockArea = DarkDockArea.Bottom) + { + if (GetPaneContent(command) is not DarkToolWindow toolWindow) + return; + + if (!DockPanel.ContainsContent(toolWindow)) + { + toolWindow.DockArea = defaultDockArea; + DockPanel.AddContent(toolWindow); + } + + toolWindow.DockGroup.SetVisibleContent(toolWindow); + } + + private void RegisterBuiltInPaneContributions() + { + RegisterPaneContribution(new StudioPaneContribution(UICommand.ContentExplorer, nameof(ContentExplorer), () => ContentExplorer)); + RegisterPaneContribution(new StudioPaneContribution(UICommand.FileExplorer, nameof(FileExplorer), () => FileExplorer)); + RegisterPaneContribution(new StudioPaneContribution(UICommand.CompilerLogs, nameof(CompilerLogs), () => CompilerLogs)); + RegisterPaneContribution(new StudioPaneContribution(UICommand.SearchResults, nameof(SearchResults), () => SearchResults)); + } + + private void RegisterPaneContribution(StudioPaneContribution contribution) + { + if (contribution is null || _paneRegistryByCommand.ContainsKey(contribution.Command)) + return; + + DarkDockContent content = contribution.CreateContent(); + if (content is null) + return; + + _paneRegistryByCommand[contribution.Command] = content; + _paneRegistryByKey[contribution.SerializationKey] = content; + RegisterViewControl(contribution.Command.ToString(), content); + RegisterViewControl(contribution.SerializationKey, content); + } + + private void RegisterViewControl(string key, Control control) + { + if (string.IsNullOrWhiteSpace(key) || control is null) + return; + + _viewControlRegistry[key] = control; + } + + private void UpdateStatusStripContributions(IEditorControl editor, DocumentMode documentMode) + { + var segments = new HashSet(); + + if (WorkspaceProfile?.StatusStripSegments is not null) + foreach (StudioStatusStripSegment segment in WorkspaceProfile.StatusStripSegments) + segments.Add(segment); + + foreach (StudioStatusStripSegment segment in GetDocumentStatusStripSegments(editor, documentMode)) + segments.Add(segment); + + StatusStrip.SegmentContributions = segments.ToArray(); } #endregion Other methods diff --git a/TombIDE/TombIDE.ScriptingStudio/ClassicScript/ClassicScriptDocumentCommandHandler.cs b/TombIDE/TombIDE.ScriptingStudio/ClassicScript/ClassicScriptDocumentCommandHandler.cs new file mode 100644 index 0000000000..927951408a --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/ClassicScript/ClassicScriptDocumentCommandHandler.cs @@ -0,0 +1,51 @@ +using System; +using System.Threading.Tasks; +using TombIDE.ScriptingStudio.CommandSurface; +using TombIDE.ScriptingStudio.UI; +using TombLib.Scripting.ClassicScript; + +namespace TombIDE.ScriptingStudio.ClassicScript; + +public sealed class ClassicScriptDocumentCommandHandler : IStudioDocumentCommandHandler +{ + private readonly ClassicScriptDocumentCommandCallbacks _callbacks; + + public ClassicScriptDocumentCommandHandler(ClassicScriptDocumentCommandCallbacks callbacks) + { + _callbacks = callbacks ?? throw new ArgumentNullException(nameof(callbacks)); + } + + public bool TryHandle(UICommand command) + { + if (_callbacks.GetCurrentEditor() is not ClassicScriptEditor editor) + return false; + + switch (command) + { + case UICommand.Reindent: + _ = _callbacks.ReindentAsync(editor); + return true; + + case UICommand.TrimWhiteSpace: + _ = _callbacks.TrimWhitespaceAsync(editor); + return true; + + case UICommand.TypeFirstAvailableId: + _callbacks.TypeFirstAvailableId(editor); + return true; + + case UICommand.NewFileAtCaret: + _callbacks.CreateNewFileAtCaret(editor); + return true; + } + + return false; + } +} + +public sealed record ClassicScriptDocumentCommandCallbacks( + Func GetCurrentEditor, + Func ReindentAsync, + Func TrimWhitespaceAsync, + Action TypeFirstAvailableId, + Action CreateNewFileAtCaret); \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/ClassicScript/ClassicScriptDocumentCommandStatusProvider.cs b/TombIDE/TombIDE.ScriptingStudio/ClassicScript/ClassicScriptDocumentCommandStatusProvider.cs new file mode 100644 index 0000000000..cd41adeeb8 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/ClassicScript/ClassicScriptDocumentCommandStatusProvider.cs @@ -0,0 +1,27 @@ +using TombIDE.ScriptingStudio.CommandSurface; +using TombIDE.ScriptingStudio.UI; +using TombLib.Scripting.ClassicScript; +using TombLib.Scripting.UI.Editors; + +namespace TombIDE.ScriptingStudio.ClassicScript +{ + public sealed class ClassicScriptDocumentCommandStatusProvider : IStudioDocumentCommandStatusProvider + { + public bool TryGetEnabled(IEditorControl editor, UICommand command, out bool isEnabled) + { + bool hasClassicScriptEditor = editor is ClassicScriptEditor; + + switch (command) + { + case UICommand.TypeFirstAvailableId: + case UICommand.NewFileAtCaret: + isEnabled = hasClassicScriptEditor; + return true; + + default: + isEnabled = false; + return false; + } + } + } +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/ClassicScript/ClassicScriptDocumentStatusStripProvider.cs b/TombIDE/TombIDE.ScriptingStudio/ClassicScript/ClassicScriptDocumentStatusStripProvider.cs new file mode 100644 index 0000000000..9f2a58390b --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/ClassicScript/ClassicScriptDocumentStatusStripProvider.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using TombIDE.ScriptingStudio.Shell; +using TombIDE.ScriptingStudio.UI; +using TombLib.Scripting.UI.Editors; + +namespace TombIDE.ScriptingStudio.ClassicScript; + +internal sealed class ClassicScriptDocumentStatusStripProvider : IStudioDocumentStatusStripProvider +{ + public IReadOnlyList GetSegments(IEditorControl editor, DocumentMode documentMode) + => documentMode == DocumentMode.ClassicScript + ? new[] { StudioStatusStripSegment.SyntaxPreview } + : Array.Empty(); +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/ClassicScript/ClassicScriptReferenceBrowserDataProvider.cs b/TombIDE/TombIDE.ScriptingStudio/ClassicScript/ClassicScriptReferenceBrowserDataProvider.cs new file mode 100644 index 0000000000..774fd2a10b --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/ClassicScript/ClassicScriptReferenceBrowserDataProvider.cs @@ -0,0 +1,19 @@ +using System.Data; +using TombLib.Scripting.ClassicScript.Mnemonics; +using TombLib.Scripting.Specifications.ClassicScript.ReferenceTables; + +namespace TombIDE.ScriptingStudio.ClassicScript; + +internal sealed class ClassicScriptReferenceBrowserDataProvider +{ + private readonly ClassicScriptMnemonicCatalogService _mnemonicCatalogService = new(); + private readonly ClassicScriptReferenceTableService _referenceTableService = new(); + + public DataTable GetTable(ReferenceItemType itemType) + { + if (itemType == ReferenceItemType.MnemonicConstants) + return _mnemonicCatalogService.CreateMnemonicTable(); + + return _referenceTableService.GetTable(itemType.ToString()); + } +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/ClassicScript/ClassicScriptWorkspaceAutomationProvider.cs b/TombIDE/TombIDE.ScriptingStudio/ClassicScript/ClassicScriptWorkspaceAutomationProvider.cs new file mode 100644 index 0000000000..fd17fc5a66 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/ClassicScript/ClassicScriptWorkspaceAutomationProvider.cs @@ -0,0 +1,207 @@ +using DarkUI.Forms; +using System; +using System.Diagnostics; +using System.IO; +using System.Windows.Forms; +using TombIDE.ScriptingStudio.Services; +using TombIDE.ScriptingStudio.TextEditing; +using TombIDE.ScriptingStudio.WorkspaceProfile; +using TombIDE.Shared; +using TombIDE.Shared.SharedClasses; +using TombLib.LevelData; +using TombLib.Scripting.ClassicScript.Compilers; + +namespace TombIDE.ScriptingStudio.ClassicScript; + +internal sealed record ClassicScriptWorkspaceAutomationCallbacks( + Action AppendScript, + Action AddNewLevelNameString, + Func AddNewPluginEntry, + Func AddNewNGString, + Func IsLevelScriptDefined, + Func IsLevelLanguageStringDefined, + Action RenameRequestedLevelScript, + Action RenameRequestedLanguageString, + Action ApplyUserSettings, + Action SaveAll, + Action ShowCompilerLogsPane, + Action UpdateCompilerLogs); + +internal sealed class ClassicScriptWorkspaceAutomationProvider : IStudioWorkspaceAutomationProvider +{ + private readonly ClassicScriptWorkspaceAutomationCallbacks _callbacks; + private readonly string _engineDirectoryPath; + private readonly IWin32Window _promptOwner; + private readonly string _scriptRootDirectoryPath; + private readonly StudioSilentActionService _silentActionService; + private readonly ScriptingWorkspaceProfile _workspaceProfile; + + public ClassicScriptWorkspaceAutomationProvider( + IWin32Window promptOwner, + ScriptingWorkspaceProfile workspaceProfile, + StudioSilentActionService silentActionService, + string scriptRootDirectoryPath, + string engineDirectoryPath, + ClassicScriptWorkspaceAutomationCallbacks callbacks) + { + _promptOwner = promptOwner ?? throw new ArgumentNullException(nameof(promptOwner)); + _workspaceProfile = workspaceProfile ?? throw new ArgumentNullException(nameof(workspaceProfile)); + _silentActionService = silentActionService ?? throw new ArgumentNullException(nameof(silentActionService)); + _scriptRootDirectoryPath = scriptRootDirectoryPath ?? string.Empty; + _engineDirectoryPath = engineDirectoryPath ?? string.Empty; + _callbacks = callbacks ?? throw new ArgumentNullException(nameof(callbacks)); + } + + public void HandleIDEEvent(IIDEEvent ideEvent) + { + if (ideEvent is null) + return; + + if (IsSilentAction(ideEvent)) + { + HandleSilentAction(ideEvent); + return; + } + + if (ideEvent is IDE.ScriptEditor_ReloadSyntaxHighlightingEvent) + _callbacks.ApplyUserSettings(); + } + + public void Build() + { + _callbacks.SaveAll(); + + if (_workspaceProfile.GameVersion == TRVersion.Game.TR4) + CompileTR4Script(); + else if (_workspaceProfile.GameVersion == TRVersion.Game.TRNG) + CompileTRNGScript(); + } + + public void ShowDocumentation() + { + string pdfPath = Path.Combine(DefaultPaths.ResourcesDirectory, "ClassicScript", "TRNG Script Reference Manual.pdf"); + OpenPathIfExists(pdfPath); + } + + private static bool IsSilentAction(IIDEEvent ideEvent) + => ideEvent is IDE.ScriptEditor_AppendScriptEvent + || ideEvent is IDE.ScriptEditor_AddNewLevelStringEvent + || ideEvent is IDE.ScriptEditor_AddNewPluginEntryEvent + || ideEvent is IDE.ScriptEditor_AddNewNGStringEvent + || ideEvent is IDE.ScriptEditor_ScriptPresenceCheckEvent + || ideEvent is IDE.ScriptEditor_StringPresenceCheckEvent + || ideEvent is IDE.ScriptEditor_RenameLevelEvent; + + private void HandleSilentAction(IIDEEvent ideEvent) + { + TabPage cachedTab = _silentActionService.RememberSelectedTab(); + string scriptFilePath = PathHelper.GetScriptFilePath(_scriptRootDirectoryPath, TRVersion.Game.TR4); + string languageFilePath = PathHelper.GetLanguageFilePath(_scriptRootDirectoryPath, TRVersion.Game.TR4); + string ngLanguageFilePath = PathHelper.GetLanguageFilePath(_scriptRootDirectoryPath, TRVersion.Game.TRNG); + + if (ideEvent is IDE.ScriptEditor_AppendScriptEvent appendEvent && appendEvent.Result.HasContent) + { + SilentActionFileState scriptFileState = _silentActionService.CaptureFileState(scriptFilePath); + _callbacks.AppendScript(appendEvent.Result.GameFlowScript); + _silentActionService.Complete(cachedTab, true, _silentActionService.CreateCompletion(scriptFileState)); + } + else if (ideEvent is IDE.ScriptEditor_AddNewLevelStringEvent addLevelStringEvent) + { + SilentActionFileState languageFileState = _silentActionService.CaptureSourceFileState(languageFilePath); + _callbacks.AddNewLevelNameString(addLevelStringEvent.LevelName); + _silentActionService.Complete(cachedTab, true, _silentActionService.CreateCompletion(languageFileState)); + } + else if (ideEvent is IDE.ScriptEditor_AddNewPluginEntryEvent addPluginEntryEvent) + { + SilentActionFileState scriptFileState = _silentActionService.CaptureFileState(scriptFilePath); + bool isChanged = _callbacks.AddNewPluginEntry(addPluginEntryEvent.PluginString); + _silentActionService.Complete(cachedTab, isChanged, _silentActionService.CreateCompletion(scriptFileState)); + } + else if (ideEvent is IDE.ScriptEditor_AddNewNGStringEvent addNgStringEvent) + { + SilentActionFileState ngLanguageFileState = _silentActionService.CaptureSourceFileState(ngLanguageFilePath); + bool isChanged = _callbacks.AddNewNGString(addNgStringEvent.NGString); + _silentActionService.Complete(cachedTab, isChanged, _silentActionService.CreateCompletion(ngLanguageFileState)); + } + else if (ideEvent is IDE.ScriptEditor_ScriptPresenceCheckEvent scriptPresenceEvent) + { + SilentActionFileState scriptFileState = _silentActionService.CaptureFileState(scriptFilePath); + IDE.Instance.ScriptDefined = _callbacks.IsLevelScriptDefined(scriptPresenceEvent.LevelName); + _silentActionService.Complete(cachedTab, false, _silentActionService.CreateCompletion(scriptFileState, saveAffectedFile: false)); + } + else if (ideEvent is IDE.ScriptEditor_StringPresenceCheckEvent stringPresenceEvent) + { + SilentActionFileState languageFileState = _silentActionService.CaptureSourceFileState(languageFilePath); + IDE.Instance.StringDefined = _callbacks.IsLevelLanguageStringDefined(stringPresenceEvent.String); + _silentActionService.Complete(cachedTab, false, _silentActionService.CreateCompletion(languageFileState, saveAffectedFile: false)); + } + else if (ideEvent is IDE.ScriptEditor_RenameLevelEvent renameLevelEvent) + { + SilentActionFileState scriptFileState = _silentActionService.CaptureFileState(scriptFilePath); + SilentActionFileState languageFileState = _silentActionService.CaptureSourceFileState(languageFilePath); + _callbacks.RenameRequestedLevelScript(renameLevelEvent.OldName, renameLevelEvent.NewName); + _callbacks.RenameRequestedLanguageString(renameLevelEvent.OldName, renameLevelEvent.NewName); + _silentActionService.Complete( + cachedTab, + true, + _silentActionService.CreateCompletion(scriptFileState), + _silentActionService.CreateCompletion(languageFileState)); + } + } + + private void CompileTR4Script() + { + try + { + string logs = TR4Compiler.Compile(_scriptRootDirectoryPath, _engineDirectoryPath); + + if (IDE.Instance.IDEConfiguration.ShowCompilerLogsAfterBuild) + _callbacks.ShowCompilerLogsPane(); + + _callbacks.UpdateCompilerLogs(logs); + } + catch (Exception exception) + { + ShowError(exception.Message); + } + } + + private void CompileTRNGScript() + { + try + { + bool success = NGCompiler.Compile( + _scriptRootDirectoryPath, + _engineDirectoryPath, + IDE.Instance.IDEConfiguration.UseNewIncludeMethod); + + string logFilePath = Path.Combine(DefaultPaths.VGEDirectory, "LastCompilerLog.txt"); + _callbacks.UpdateCompilerLogs(File.ReadAllText(logFilePath)); + + if (!success) + ShowError("Script compilation yielded an error. Please check the logs."); + + if (IDE.Instance.IDEConfiguration.ShowCompilerLogsAfterBuild || !success) + _callbacks.ShowCompilerLogsPane(); + } + catch (Exception exception) + { + ShowError(exception.Message); + } + } + + private void ShowError(string message) + => DarkMessageBox.Show(_promptOwner, message, "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); + + private static void OpenPathIfExists(string filePath) + { + if (!File.Exists(filePath)) + return; + + Process.Start(new ProcessStartInfo + { + FileName = filePath, + UseShellExecute = true + }); + } +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/Forms/FormReferenceInfo.Designer.cs b/TombIDE/TombIDE.ScriptingStudio/ClassicScript/FormReferenceInfo.Designer.cs similarity index 99% rename from TombIDE/TombIDE.ScriptingStudio/Forms/FormReferenceInfo.Designer.cs rename to TombIDE/TombIDE.ScriptingStudio/ClassicScript/FormReferenceInfo.Designer.cs index 04b0a26de5..aef2235aa3 100644 --- a/TombIDE/TombIDE.ScriptingStudio/Forms/FormReferenceInfo.Designer.cs +++ b/TombIDE/TombIDE.ScriptingStudio/ClassicScript/FormReferenceInfo.Designer.cs @@ -1,4 +1,4 @@ -namespace TombIDE.ScriptingStudio.Forms +namespace TombIDE.ScriptingStudio.ClassicScript { partial class FormReferenceInfo { diff --git a/TombIDE/TombIDE.ScriptingStudio/Forms/FormReferenceInfo.cs b/TombIDE/TombIDE.ScriptingStudio/ClassicScript/FormReferenceInfo.cs similarity index 77% rename from TombIDE/TombIDE.ScriptingStudio/Forms/FormReferenceInfo.cs rename to TombIDE/TombIDE.ScriptingStudio/ClassicScript/FormReferenceInfo.cs index cdec6d200e..3074e5d9be 100644 --- a/TombIDE/TombIDE.ScriptingStudio/Forms/FormReferenceInfo.cs +++ b/TombIDE/TombIDE.ScriptingStudio/ClassicScript/FormReferenceInfo.cs @@ -4,10 +4,9 @@ using System.Drawing; using System.Windows.Forms; using TombIDE.Shared; -using TombLib.Scripting.ClassicScript.Enums; -using TombLib.Scripting.ClassicScript.Utils; +using TombLib.Scripting.ClassicScript.Navigation; -namespace TombIDE.ScriptingStudio.Forms +namespace TombIDE.ScriptingStudio.ClassicScript { // TODO: Refactor !!! @@ -25,12 +24,12 @@ public FormReferenceInfo() checkBox_CloseTabs.Checked = IDE.Instance.IDEConfiguration.InfoBox_CloseTabsOnClose; } - public void Show(string flag, ReferenceType type) + public void Show(ClassicScriptReferenceInfo referenceInfo) { if (!Visible) Show(); - OpenDescriptionFile(flag, type); + OpenReferenceInfo(referenceInfo); Focus(); @@ -85,16 +84,18 @@ private void checkBox_CloseTabs_CheckedChanged(object sender, EventArgs e) => #region Methods - private void OpenDescriptionFile(string flag, ReferenceType type) // TODO: Refactor + private void OpenReferenceInfo(ClassicScriptReferenceInfo referenceInfo) { + string keyword = referenceInfo.Keyword; + foreach (TabPage tab in tabControl.TabPages) - if (tab.Text.Equals(flag, StringComparison.OrdinalIgnoreCase)) + if (tab.Text.Equals(keyword, StringComparison.OrdinalIgnoreCase)) { tabControl.SelectTab(tab); return; } - var newTabPage = new TabPage(flag.ToUpper()) + var newTabPage = new TabPage(keyword.ToUpperInvariant()) { UseVisualStyleBackColor = false, BackColor = Color.FromArgb(42, 42, 42), @@ -104,7 +105,7 @@ private void OpenDescriptionFile(string flag, ReferenceType type) // TODO: Refac var textBox = new RichTextBox { - Text = RddaReader.GetKeywordDescription(flag.TrimEnd('='), type), + Text = referenceInfo.Description, ForeColor = Color.Gainsboro, BackColor = Color.FromArgb(48, 48, 48), Font = new Font("Segoe UI", 12f), @@ -120,20 +121,11 @@ private void OpenDescriptionFile(string flag, ReferenceType type) // TODO: Refac Text = "Information about " + tabControl.SelectedTab.Text; - if (string.IsNullOrEmpty(textBox.Text)) + if (!string.IsNullOrEmpty(referenceInfo.MissingDescriptionMessage)) { - string message; - - if (flag.StartsWith("$")) - message = "Couldn't identify the hexadecimal value for the given context."; - else if (int.TryParse(flag, out _)) - message = "Couldn't identify the decimal value for the given context."; - else - message = "No description found for the " + flag.ToUpper() + " flag."; - TopMost = false; - DarkMessageBox.Show(this, message, "Information", + DarkMessageBox.Show(this, referenceInfo.MissingDescriptionMessage, "Information", MessageBoxButtons.OK, MessageBoxIcon.Information); TopMost = IDE.Instance.IDEConfiguration.InfoBox_AlwaysOnTop; diff --git a/TombIDE/TombIDE.ScriptingStudio/Forms/FormReferenceInfo.resx b/TombIDE/TombIDE.ScriptingStudio/ClassicScript/FormReferenceInfo.resx similarity index 100% rename from TombIDE/TombIDE.ScriptingStudio/Forms/FormReferenceInfo.resx rename to TombIDE/TombIDE.ScriptingStudio/ClassicScript/FormReferenceInfo.resx diff --git a/TombIDE/TombIDE.ScriptingStudio/ToolWindows/ReferenceBrowser.Designer.cs b/TombIDE/TombIDE.ScriptingStudio/ClassicScript/ReferenceBrowser.Designer.cs similarity index 99% rename from TombIDE/TombIDE.ScriptingStudio/ToolWindows/ReferenceBrowser.Designer.cs rename to TombIDE/TombIDE.ScriptingStudio/ClassicScript/ReferenceBrowser.Designer.cs index ab6bcfbd4d..db1ba9c656 100644 --- a/TombIDE/TombIDE.ScriptingStudio/ToolWindows/ReferenceBrowser.Designer.cs +++ b/TombIDE/TombIDE.ScriptingStudio/ClassicScript/ReferenceBrowser.Designer.cs @@ -1,4 +1,4 @@ -namespace TombIDE.ScriptingStudio.ToolWindows +namespace TombIDE.ScriptingStudio.ClassicScript { partial class ReferenceBrowser { diff --git a/TombIDE/TombIDE.ScriptingStudio/ToolWindows/ReferenceBrowser.cs b/TombIDE/TombIDE.ScriptingStudio/ClassicScript/ReferenceBrowser.cs similarity index 89% rename from TombIDE/TombIDE.ScriptingStudio/ToolWindows/ReferenceBrowser.cs rename to TombIDE/TombIDE.ScriptingStudio/ClassicScript/ReferenceBrowser.cs index 5a02461adf..479c3e9869 100644 --- a/TombIDE/TombIDE.ScriptingStudio/ToolWindows/ReferenceBrowser.cs +++ b/TombIDE/TombIDE.ScriptingStudio/ClassicScript/ReferenceBrowser.cs @@ -4,19 +4,17 @@ using System; using System.ComponentModel; using System.Data; -using System.IO; using System.Text; using System.Windows.Forms; -using System.Xml; -using TombIDE.ScriptingStudio.Objects; using TombIDE.Shared; -using TombLib.Scripting.ClassicScript.Enums; -using TombLib.Scripting.ClassicScript.Resources; +using TombLib.Scripting.ClassicScript.Navigation; -namespace TombIDE.ScriptingStudio.ToolWindows +namespace TombIDE.ScriptingStudio.ClassicScript { public partial class ReferenceBrowser : DarkToolWindow { + private readonly ClassicScriptReferenceBrowserDataProvider _dataProvider = new ClassicScriptReferenceBrowserDataProvider(); + public ReferenceBrowser() { InitializeComponent(); @@ -52,22 +50,10 @@ private void UpdateDataGrid() try { - string fileName = treeView.SelectedNodes[0].Tag.ToString(); - string xmlPath = Path.Combine(DefaultPaths.ReferencesDirectory, fileName + ".xml"); - - using (var reader = XmlReader.Create(xmlPath)) - { - var dataSet = new DataSet(); - dataSet.ReadXml(reader); + DataTable dataTable = _dataProvider.GetTable((ReferenceItemType)treeView.SelectedNodes[0].Tag); - DataTable dataTable = dataSet.Tables[0]; - - if (treeView.SelectedNodes[0] == treeView.Nodes[0]) - MnemonicData.AddPluginMnemonics(dataTable); - - AddFilterRowString(dataTable); - SetFriendlyColumnHeaders(); - } + AddFilterRowString(dataTable); + SetFriendlyColumnHeaders(); } catch (Exception ex) { diff --git a/TombIDE/TombIDE.ScriptingStudio/ToolWindows/ReferenceBrowser.resx b/TombIDE/TombIDE.ScriptingStudio/ClassicScript/ReferenceBrowser.resx similarity index 100% rename from TombIDE/TombIDE.ScriptingStudio/ToolWindows/ReferenceBrowser.resx rename to TombIDE/TombIDE.ScriptingStudio/ClassicScript/ReferenceBrowser.resx diff --git a/TombIDE/TombIDE.ScriptingStudio/ClassicScript/ReferenceDefinitionEventArgs.cs b/TombIDE/TombIDE.ScriptingStudio/ClassicScript/ReferenceDefinitionEventArgs.cs new file mode 100644 index 0000000000..4a6f386215 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/ClassicScript/ReferenceDefinitionEventArgs.cs @@ -0,0 +1,16 @@ +using System; +using TombLib.Scripting.ClassicScript.Navigation; + +namespace TombIDE.ScriptingStudio.ClassicScript; + +public class ReferenceDefinitionEventArgs : EventArgs +{ + public string Keyword { get; } + public ReferenceType Type { get; } + + public ReferenceDefinitionEventArgs(string keyword, ReferenceType type) + { + Keyword = keyword; + Type = type; + } +} diff --git a/TombIDE/TombIDE.ScriptingStudio/ClassicScript/ReferenceItemType.cs b/TombIDE/TombIDE.ScriptingStudio/ClassicScript/ReferenceItemType.cs new file mode 100644 index 0000000000..9ea11a6d41 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/ClassicScript/ReferenceItemType.cs @@ -0,0 +1,15 @@ +namespace TombIDE.ScriptingStudio.ClassicScript; + +internal enum ReferenceItemType +{ + MnemonicConstants, + EnemyDamageValues, + KeyboardScancodes, + OCBList, + OldCommandsList, + NewCommandsList, + SoundIndices, + MoveableSlotIndices, + StaticObjectIndices, + VariablePlaceholders +} diff --git a/TombIDE/TombIDE.ScriptingStudio/ClassicScriptStudio.cs b/TombIDE/TombIDE.ScriptingStudio/ClassicScriptStudio.cs index b8b0b16347..0db3784176 100644 --- a/TombIDE/TombIDE.ScriptingStudio/ClassicScriptStudio.cs +++ b/TombIDE/TombIDE.ScriptingStudio/ClassicScriptStudio.cs @@ -1,177 +1,123 @@ using DarkUI.Docking; using DarkUI.Forms; using System; -using System.Data; using System.Diagnostics; using System.IO; -using System.Linq; using System.Windows.Forms; using TombIDE.ScriptingStudio.Bases; +using TombIDE.ScriptingStudio.ClassicScript; using TombIDE.ScriptingStudio.Controls; -using TombIDE.ScriptingStudio.Forms; -using TombIDE.ScriptingStudio.Objects; -using TombIDE.ScriptingStudio.ToolWindows; +using TombIDE.ScriptingStudio.Helpers; +using TombIDE.ScriptingStudio.Shell; +using TombIDE.ScriptingStudio.TextEditing; +using TombIDE.ScriptingStudio.WorkspaceProfile; using TombIDE.ScriptingStudio.UI; using TombIDE.Shared; using TombIDE.Shared.SharedClasses; -using TombLib.Scripting.Bases; +using TombLib.Scripting.Editing; using TombLib.Scripting.ClassicScript; -using TombLib.Scripting.ClassicScript.Enums; -using TombLib.Scripting.ClassicScript.Objects; -using TombLib.Scripting.ClassicScript.Parsers; -using TombLib.Scripting.ClassicScript.Resources; -using TombLib.Scripting.ClassicScript.Utils; +using TombLib.Scripting.ClassicScript.Compilers; +using TombLib.Scripting.ClassicScript.Documents; +using TombLib.Scripting.ClassicScript.Navigation; using TombLib.Scripting.ClassicScript.Writers; -using TombLib.Scripting.Enums; -using TombLib.Scripting.Interfaces; +using TombLib.Scripting.UI.Bases; +using TombLib.Scripting.UI.Cleaning; +using TombLib.Scripting.UI.Editing; +using TombLib.Scripting.UI.Editors; +using TombLib.Scripting.UI.Strings; namespace TombIDE.ScriptingStudio { - public sealed class ClassicScriptStudio : StudioBase + public sealed class ClassicScriptStudio : TombIDE.ScriptingStudio.Bases.ScriptingStudio { - public override StudioMode StudioMode => StudioMode.ClassicScript; - #region Fields private FormReferenceInfo FormReferenceInfo = new FormReferenceInfo(); - - public ReferenceBrowser ReferenceBrowser = new ReferenceBrowser(); + private readonly ClassicScriptDocumentLookupService _documentLookupService = new(); + private readonly ClassicScriptReferenceDefinitionService _referenceDefinitionService = new(); + private readonly ClassicScriptReferenceInfoService _referenceInfoService = new(); + private readonly EditorTabControlTextEditorHost _textEditorHost; + private readonly ITextFormattingProvider _trimWhitespaceProvider = new TextDocumentFormatterProvider(TrimTrailingWhitespaceFormatter.Instance); + private readonly TextWorkspaceEditApplier _workspaceEditApplier; + private readonly TextWorkspaceCommandService _workspaceCommandService; #endregion Fields #region Construction - public ClassicScriptStudio() : base(IDE.Instance.Project.GetScriptRootDirectory(), IDE.Instance.Project.GetEngineRootDirectoryPath()) + public ClassicScriptStudio(ScriptingWorkspaceProfile workspaceProfile) : base() { - DockPanelState = IDE.Instance.IDEConfiguration.CS_DockPanelState; + ArgumentNullException.ThrowIfNull(workspaceProfile); + var paneContributionProvider = new StaticStudioPaneContributionProvider( + new[] + { + new StudioPaneContribution(UICommand.ReferenceBrowser, nameof(ReferenceBrowser), () => new ReferenceBrowser()) + }); + _textEditorHost = new EditorTabControlTextEditorHost(EditorTabControl); + var silentActionService = new StudioSilentActionService(EditorTabControl); + _workspaceEditApplier = new TextWorkspaceEditApplier(_textEditorHost); + _workspaceCommandService = new TextWorkspaceCommandService(_workspaceEditApplier); + var documentCommandHandler = new ClassicScriptDocumentCommandHandler( + new ClassicScriptDocumentCommandCallbacks( + () => CurrentEditor as ClassicScriptEditor, + editor => _workspaceCommandService.FormatDocumentAsync(editor, new TextDocumentFormatterProvider(editor.Formatter)), + editor => _workspaceCommandService.FormatDocumentAsync(editor, _trimWhitespaceProvider), + editor => editor.InputFreeIndex(), + CreateNewFileAtCaretPosition)); + var workspaceAutomationProvider = new ClassicScriptWorkspaceAutomationProvider( + this, + workspaceProfile, + silentActionService, + ScriptRootDirectoryPath, + EngineDirectoryPath, + new ClassicScriptWorkspaceAutomationCallbacks( + AppendScript, + AddNewLevelNameString, + AddNewPluginEntry, + AddNewNGString, + IsLevelScriptDefined, + IsLevelLanguageStringDefined, + RenameRequestedLevelScript, + RenameRequestedLanguageString, + ApplyUserSettings, + EditorTabControl.SaveAll, + () => ShowPane(UICommand.CompilerLogs), + CompilerLogs.UpdateLogs)); + InitializeHost( + workspaceProfile, + (editor, configs) => editor.UpdateSettings(configs.ClassicScript), + () => ApplyUserSettingsToOpenEditors(afterAll: () => StatusStrip.ReloadContributionSettings()), + documentCommandStatusProvider: new ClassicScriptDocumentCommandStatusProvider(), + documentCommandHandler: documentCommandHandler, + documentStatusStripProvider: new ClassicScriptDocumentStatusStripProvider(), + paneContributionProvider: paneContributionProvider, + workspaceAutomationProvider: workspaceAutomationProvider); EditorTabControl.FileOpened += EditorTabControl_FileOpened; ReferenceBrowser.ReferenceDefinitionRequested += ReferenceBrowser_ReferenceDefinitionRequested; - - FileExplorer.Filter = "*.txt"; - FileExplorer.CommentPrefix = ";"; - - EditorTabControl.PlainTextTypeOverride = typeof(ClassicScriptEditor); - - EditorTabControl.CheckPreviousSession(); - - string initialFilePath = PathHelper.GetScriptFilePath(IDE.Instance.Project.GetScriptRootDirectory(), TombLib.LevelData.TRVersion.Game.TR4); - - if (!string.IsNullOrWhiteSpace(initialFilePath)) - EditorTabControl.OpenFile(initialFilePath); } #endregion Construction - #region IDE Events - - protected override void OnIDEEventRaised(IIDEEvent obj) - { - base.OnIDEEventRaised(obj); - - IDEEvent_HandleSilentActions(obj); - } - - private bool IsSilentAction(IIDEEvent obj) - => obj is IDE.ScriptEditor_AppendScriptEvent - || obj is IDE.ScriptEditor_AddNewLevelStringEvent - || obj is IDE.ScriptEditor_AddNewPluginEntryEvent - || obj is IDE.ScriptEditor_AddNewNGStringEvent - || obj is IDE.ScriptEditor_ScriptPresenceCheckEvent - || obj is IDE.ScriptEditor_StringPresenceCheckEvent - || obj is IDE.ScriptEditor_RenameLevelEvent; - - private void IDEEvent_HandleSilentActions(IIDEEvent obj) - { - if (IsSilentAction(obj)) - { - TabPage cachedTab = EditorTabControl.SelectedTab; - - TabPage scriptFileTab = EditorTabControl.FindTabPage(PathHelper.GetScriptFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TR4)); - bool wasScriptFileAlreadyOpened = scriptFileTab != null; - bool wasScriptFileFileChanged = wasScriptFileAlreadyOpened && EditorTabControl.GetEditorOfTab(scriptFileTab).IsContentChanged; - - TabPage languageFileTab = EditorTabControl.FindTabPage(PathHelper.GetLanguageFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TR4)); - bool wasLanguageFileAlreadyOpened = languageFileTab != null; - bool wasLanguageFileFileChanged = wasLanguageFileAlreadyOpened && EditorTabControl.GetEditorOfTab(languageFileTab).IsContentChanged; - - if (obj is IDE.ScriptEditor_AppendScriptEvent asle && asle.Result.HasContent) - { - AppendScript(asle.Result.GameFlowScript); - EndSilentScriptAction(cachedTab, true, !wasScriptFileFileChanged, !wasScriptFileAlreadyOpened); - } - else if (obj is IDE.ScriptEditor_AddNewLevelStringEvent anlse) - { - AddNewLevelNameString(anlse.LevelName); - EndSilentScriptAction(cachedTab, true, !wasLanguageFileFileChanged, !wasLanguageFileAlreadyOpened); - } - else if (obj is IDE.ScriptEditor_AddNewPluginEntryEvent anpee) - { - bool isChanged = AddNewPluginEntry(anpee.PluginString); - EndSilentScriptAction(cachedTab, isChanged, !wasScriptFileFileChanged, !wasScriptFileAlreadyOpened); - } - else if (obj is IDE.ScriptEditor_AddNewNGStringEvent anngse) - { - bool isChanged = AddNewNGString(anngse.NGString); - EndSilentScriptAction(cachedTab, isChanged, !wasLanguageFileFileChanged, !wasLanguageFileAlreadyOpened); - } - else if (obj is IDE.ScriptEditor_ScriptPresenceCheckEvent scrpce) - { - IDE.Instance.ScriptDefined = IsLevelScriptDefined(scrpce.LevelName); - EndSilentScriptAction(cachedTab, false, false, !wasScriptFileAlreadyOpened); - } - else if (obj is IDE.ScriptEditor_StringPresenceCheckEvent strpce) - { - IDE.Instance.StringDefined = IsLevelLanguageStringDefined(strpce.String); - EndSilentScriptAction(cachedTab, false, false, !wasLanguageFileAlreadyOpened); - } - else if (obj is IDE.ScriptEditor_RenameLevelEvent rle) - { - string oldName = rle.OldName; - string newName = rle.NewName; - - RenameRequestedLevelScript(oldName, newName); - RenameRequestedLanguageString(oldName, newName); - - EndSilentScriptAction(cachedTab, true, !wasLanguageFileFileChanged, !wasLanguageFileAlreadyOpened); - } - } - else if (obj is IDE.ScriptEditor_ReloadSyntaxHighlightingEvent) - { - ApplyUserSettings(); - } - else if (obj is IDE.ProgramClosingEvent) - { - IDE.Instance.IDEConfiguration.CS_DockPanelState = DockPanel.GetDockPanelState(); - IDE.Instance.IDEConfiguration.Save(); - } - } - private void AppendScript(string scriptText) { - EditorTabControl.OpenFile(PathHelper.GetScriptFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TR4)); - - if (CurrentEditor is TextEditorBase editor) - { - editor.AppendText(Environment.NewLine + scriptText + Environment.NewLine); - editor.ScrollToLine(editor.LineCount); - } + TextEditorBase editor = _textEditorHost.OpenTextEditor(PathHelper.GetScriptFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TR4)); + editor.AppendText(Environment.NewLine + scriptText + Environment.NewLine); + editor.ScrollToLine(editor.LineCount); } private void AddNewLevelNameString(string levelName) { - EditorTabControl.OpenFile(PathHelper.GetLanguageFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TR4), EditorType.Text); - - if (CurrentEditor is TextEditorBase editor) - LanguageStringWriter.WriteNewLevelNameString(editor, levelName); + TextEditorBase editor = _textEditorHost.OpenTextEditor( + PathHelper.GetLanguageFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TR4), + openSourceView: true); + LanguageStringWriter.WriteNewLevelNameString(editor, levelName); } private bool AddNewPluginEntry(string pluginString) { - EditorTabControl.OpenFile(PathHelper.GetScriptFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TR4)); - - if (CurrentEditor is ClassicScriptEditor editor) + if (_textEditorHost.OpenEditor(PathHelper.GetScriptFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TR4)) is ClassicScriptEditor editor) return editor.TryAddNewPluginEntry(pluginString); return false; @@ -179,9 +125,9 @@ private bool AddNewPluginEntry(string pluginString) private bool AddNewNGString(string ngString) { - EditorTabControl.OpenFile(PathHelper.GetLanguageFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TRNG), EditorType.Text); - - if (CurrentEditor is TextEditorBase editor) + if (_textEditorHost.OpenTextEditor( + PathHelper.GetLanguageFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TRNG), + openSourceView: true) is TextEditorBase editor) return LanguageStringWriter.WriteNewNGString(editor, ngString); return false; @@ -189,62 +135,32 @@ private bool AddNewNGString(string ngString) private void RenameRequestedLevelScript(string oldName, string newName) { - EditorTabControl.OpenFile(PathHelper.GetScriptFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TR4)); - - if (CurrentEditor is TextEditorBase editor) - ScriptReplacer.RenameLevelScript(editor, oldName, newName); + TextEditorBase editor = _textEditorHost.OpenTextEditor(PathHelper.GetScriptFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TR4)); + ScriptReplacer.RenameLevelScript(editor, oldName, newName); } private void RenameRequestedLanguageString(string oldName, string newName) { - EditorTabControl.OpenFile(PathHelper.GetLanguageFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TR4), EditorType.Text); - - if (CurrentEditor is TextEditorBase editor) - ScriptReplacer.RenameLanguageString(editor, oldName, newName); + TextEditorBase editor = _textEditorHost.OpenTextEditor( + PathHelper.GetLanguageFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TR4), + openSourceView: true); + ScriptReplacer.RenameLanguageString(editor, oldName, newName); } private bool IsLevelScriptDefined(string levelName) { - EditorTabControl.OpenFile(PathHelper.GetScriptFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TR4)); - - if (CurrentEditor is TextEditorBase editor) - return DocumentParser.IsLevelScriptDefined(editor.Document, levelName); - - return false; + TextEditorBase editor = _textEditorHost.OpenTextEditor(PathHelper.GetScriptFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TR4)); + return _documentLookupService.IsLevelScriptDefined(editor.Document, levelName); } private bool IsLevelLanguageStringDefined(string levelName) { - EditorTabControl.OpenFile(PathHelper.GetLanguageFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TR4), EditorType.Text); - - if (CurrentEditor is TextEditorBase editor) - return DocumentParser.IsLevelLanguageStringDefined(editor.Document, levelName); - - return false; - } - - private void EndSilentScriptAction(TabPage previousTab, bool indicateChange, bool saveAffectedFile, bool closeAffectedTab) - { - if (indicateChange) - { - CurrentEditor.LastModified = DateTime.Now; - IDE.Instance.ScriptEditor_IndicateExternalChange(); - } - - if (saveAffectedFile) - EditorTabControl.SaveFile(EditorTabControl.SelectedTab); - - if (closeAffectedTab) - EditorTabControl.TabPages.Remove(EditorTabControl.SelectedTab); - - EditorTabControl.EnsureTabFileSynchronization(); - - if (previousTab != null) - EditorTabControl.SelectTab(previousTab); + TextEditorBase editor = _textEditorHost.OpenTextEditor( + PathHelper.GetLanguageFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TR4), + openSourceView: true); + return _documentLookupService.IsLevelLanguageStringDefined(editor.Document, levelName); } - #endregion IDE Events - #region Events private void EditorTabControl_FileOpened(object sender, EventArgs e) @@ -277,58 +193,18 @@ private void TextEditor_KeyDown(object sender, System.Windows.Input.KeyEventArgs private void TextEditor_WordDefinitionRequested(object sender, WordDefinitionEventArgs e) { - string word = e.Word; - - ReferenceType type = ReferenceType.MnemonicConstant; + if (sender is not ClassicScriptEditor editor) + return; - if (e.Type == WordType.Header) - type = ReferenceType.OldCommand; - else if (e.Type == WordType.Command) - type = RddaReader.GetCommandType(word); - else if (e.Type == WordType.Directive) - type = ReferenceType.NewCommand; - else if (e.Type == WordType.Hexadecimal || e.Type == WordType.Decimal) - { - try - { - var textEditor = CurrentEditor as ClassicScriptEditor; - - if (textEditor == null) - return; - - int offset = e.HoveredOffset != -1 ? e.HoveredOffset : textEditor.CaretOffset; - string currentFlagPrefix = ArgumentParser.GetFlagPrefixOfCurrentArgument(textEditor.Document, offset); - - if (currentFlagPrefix != null) - { - DataTable dataTable = MnemonicData.MnemonicConstantsDataTable; - DataRow row = null; - - if (e.Type == WordType.Hexadecimal) - { - row = dataTable.Rows.Cast().FirstOrDefault(r - => r[1].ToString().Equals(word, StringComparison.OrdinalIgnoreCase) - && r[2].ToString().StartsWith(currentFlagPrefix, StringComparison.OrdinalIgnoreCase)); - } - else if (e.Type == WordType.Decimal) - { - row = dataTable.Rows.Cast().FirstOrDefault(r - => r[0].ToString().Equals(word, StringComparison.OrdinalIgnoreCase) - && r[2].ToString().StartsWith(currentFlagPrefix, StringComparison.OrdinalIgnoreCase)); - } - - if (row != null) - word = row[2].ToString(); - } - } - catch { } - } + int offset = e.HoveredOffset != -1 ? e.HoveredOffset : editor.CaretOffset; + ClassicScriptReferenceDefinition definition = _referenceDefinitionService.ResolveReference(editor.Document, e.Word, e.Type, offset); + ClassicScriptReferenceInfo referenceInfo = _referenceInfoService.GetReferenceInfo(definition.Keyword, definition.Type); - FormReferenceInfo.Show(word, type); + FormReferenceInfo.Show(referenceInfo); } private void ReferenceBrowser_ReferenceDefinitionRequested(object sender, ReferenceDefinitionEventArgs e) - => FormReferenceInfo.Show(e.Keyword, e.Type); + => FormReferenceInfo.Show(_referenceInfoService.GetReferenceInfo(e.Keyword, e.Type)); #endregion Events @@ -351,9 +227,9 @@ private void CreateNewFileAtCaretPosition(ClassicScriptEditor editor) private void OpenIncludeFile(ClassicScriptEditor editor) { - string fullFilePath = CommandParser.GetFullIncludePath(editor.Document, editor.CaretOffset); + string fullFilePath = _documentLookupService.TryGetIncludeFilePath(editor.Document, editor.CaretOffset); - if (File.Exists(fullFilePath)) + if (!string.IsNullOrWhiteSpace(fullFilePath) && File.Exists(fullFilePath)) EditorTabControl.OpenFile(fullFilePath); else DarkMessageBox.Show(this, "Couldn't find the target file.", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); @@ -368,15 +244,7 @@ private void CompileTR4Script() string logs = TR4Compiler.Compile(ScriptRootDirectoryPath, EngineDirectoryPath); if (IDE.Instance.IDEConfiguration.ShowCompilerLogsAfterBuild) - { - if (!DockPanel.ContainsContent(CompilerLogs)) - { - CompilerLogs.DockArea = DarkDockArea.Bottom; - DockPanel.AddContent(CompilerLogs); - } - - CompilerLogs.DockGroup.SetVisibleContent(CompilerLogs); - } + ShowPane(UICommand.CompilerLogs); CompilerLogs.UpdateLogs(logs); } @@ -401,15 +269,7 @@ private void CompileTRNGScript() DarkMessageBox.Show(this, "Script compilation yielded an error. Please check the logs.", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); if (IDE.Instance.IDEConfiguration.ShowCompilerLogsAfterBuild || !success) - { - if (!DockPanel.ContainsContent(CompilerLogs)) - { - CompilerLogs.DockArea = DarkDockArea.Bottom; - DockPanel.AddContent(CompilerLogs); - } - - CompilerLogs.DockGroup.SetVisibleContent(CompilerLogs); - } + ShowPane(UICommand.CompilerLogs); } catch (Exception ex) { @@ -417,69 +277,8 @@ private void CompileTRNGScript() } } - protected override void ApplyUserSettings(IEditorControl editor) - => editor.UpdateSettings(Configs.ClassicScript); - - protected override void ApplyUserSettings() - { - foreach (TabPage tab in EditorTabControl.TabPages) - ApplyUserSettings(EditorTabControl.GetEditorOfTab(tab)); - - StatusStrip.SyntaxPreview.ReloadSettings(); - - UpdateSettings(); - } - - protected override void Build() - { - EditorTabControl.SaveAll(); - - if (IDE.Instance.Project.GameVersion == TombLib.LevelData.TRVersion.Game.TR4) - CompileTR4Script(); - else if (IDE.Instance.Project.GameVersion == TombLib.LevelData.TRVersion.Game.TRNG) - CompileTRNGScript(); - } - - protected override void RestoreDefaultLayout() - { - DockPanelState = DefaultLayouts.ClassicScriptLayout; - - DockPanel.RemoveContent(); - DockPanel.RestoreDockPanelState(DockPanelState, FindDockContentByKey); - } - - protected override void HandleDocumentCommands(UICommand command) - { - if (CurrentEditor is ClassicScriptEditor editor) - { - switch (command) - { - case UICommand.TypeFirstAvailableId: - editor.InputFreeIndex(); - break; - - case UICommand.NewFileAtCaret: - CreateNewFileAtCaretPosition(editor); - break; - } - } - - base.HandleDocumentCommands(command); - } - - protected override void ShowDocumentation() - { - string pdfPath = Path.Combine(DefaultPaths.ResourcesDirectory, "ClassicScript", "TRNG Script Reference Manual.pdf"); - - var process = new ProcessStartInfo - { - FileName = pdfPath, - UseShellExecute = true - }; - - if (File.Exists(pdfPath)) - Process.Start(process); - } + private ReferenceBrowser ReferenceBrowser + => GetPaneContent(UICommand.ReferenceBrowser); #endregion Other methods } diff --git a/TombIDE/TombIDE.ScriptingStudio/CommandSurface/IStudioDocumentCommandHandler.cs b/TombIDE/TombIDE.ScriptingStudio/CommandSurface/IStudioDocumentCommandHandler.cs new file mode 100644 index 0000000000..222dcc7e4a --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/CommandSurface/IStudioDocumentCommandHandler.cs @@ -0,0 +1,9 @@ +using TombIDE.ScriptingStudio.UI; + +namespace TombIDE.ScriptingStudio.CommandSurface +{ + public interface IStudioDocumentCommandHandler + { + bool TryHandle(UICommand command); + } +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/CommandSurface/IStudioDocumentCommandStatusProvider.cs b/TombIDE/TombIDE.ScriptingStudio/CommandSurface/IStudioDocumentCommandStatusProvider.cs new file mode 100644 index 0000000000..1a1408d2d6 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/CommandSurface/IStudioDocumentCommandStatusProvider.cs @@ -0,0 +1,10 @@ +using TombIDE.ScriptingStudio.UI; +using TombLib.Scripting.UI.Editors; + +namespace TombIDE.ScriptingStudio.CommandSurface +{ + public interface IStudioDocumentCommandStatusProvider + { + bool TryGetEnabled(IEditorControl editor, UICommand command, out bool isEnabled); + } +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/CommandSurface/IStudioDocumentCommandSurfaceProvider.cs b/TombIDE/TombIDE.ScriptingStudio/CommandSurface/IStudioDocumentCommandSurfaceProvider.cs new file mode 100644 index 0000000000..4adb864742 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/CommandSurface/IStudioDocumentCommandSurfaceProvider.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using TombIDE.ScriptingStudio.UI; +using TombLib.Scripting.UI.Editors; + +namespace TombIDE.ScriptingStudio.CommandSurface; + +public interface IStudioDocumentCommandSurfaceProvider +{ + IReadOnlyList GetContextMenuItems(IEditorControl editor, DocumentMode documentMode); + + IReadOnlyList GetMenuStripItems(IEditorControl editor, DocumentMode documentMode); + + IReadOnlyList GetToolStripItems(IEditorControl editor, DocumentMode documentMode); +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/CommandSurface/XmlDocumentCommandSurfaceProvider.cs b/TombIDE/TombIDE.ScriptingStudio/CommandSurface/XmlDocumentCommandSurfaceProvider.cs new file mode 100644 index 0000000000..dfe3b80950 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/CommandSurface/XmlDocumentCommandSurfaceProvider.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using TombIDE.ScriptingStudio.UI; +using TombLib.Scripting.UI.Editors; + +namespace TombIDE.ScriptingStudio.CommandSurface; + +internal sealed class XmlDocumentCommandSurfaceProvider : IStudioDocumentCommandSurfaceProvider +{ + public static XmlDocumentCommandSurfaceProvider Instance { get; } = new(); + + private XmlDocumentCommandSurfaceProvider() + { } + + public IReadOnlyList GetContextMenuItems(IEditorControl editor, DocumentMode documentMode) + => GetItems("ContextMenus", documentMode); + + public IReadOnlyList GetMenuStripItems(IEditorControl editor, DocumentMode documentMode) + => GetItems("MenuStrips", documentMode); + + public IReadOnlyList GetToolStripItems(IEditorControl editor, DocumentMode documentMode) + => GetItems("ToolStrips", documentMode); + + private static IReadOnlyList GetItems(string surfaceFolder, DocumentMode documentMode) + { + if (documentMode == DocumentMode.None) + return []; + + return ToolStripXmlReader.GetItemsFromXml($"UI.DocumentModePresets.{surfaceFolder}.{documentMode}.xml"); + } +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/ConfigurationCollection.cs b/TombIDE/TombIDE.ScriptingStudio/ConfigurationCollection.cs index c580c1f7d8..45bde23452 100644 --- a/TombIDE/TombIDE.ScriptingStudio/ConfigurationCollection.cs +++ b/TombIDE/TombIDE.ScriptingStudio/ConfigurationCollection.cs @@ -1,9 +1,9 @@ using System.Reflection; -using TombLib.Scripting.Bases; using TombLib.Scripting.ClassicScript; using TombLib.Scripting.GameFlowScript; using TombLib.Scripting.Lua; -using TombLib.Scripting.Tomb1Main; +using TombLib.Scripting.TRX; +using TombLib.Scripting.UI.Bases; namespace TombIDE.ScriptingStudio { @@ -12,7 +12,7 @@ public class ConfigurationCollection public ClassicScriptEditorConfiguration ClassicScript = new ClassicScriptEditorConfiguration().Load(); public LuaEditorConfiguration Lua = new LuaEditorConfiguration().Load(); public GameFlowEditorConfiguration GameFlowScript = new GameFlowEditorConfiguration().Load(); - public T1MEditorConfiguration Tomb1Main = new T1MEditorConfiguration().Load(); + public TRXEditorConfiguration TRX = TRXEditorConfiguration.LoadWithLegacyFallback(); public void SaveAllConfigs() { diff --git a/TombIDE/TombIDE.ScriptingStudio/Controls/DocumentRenamedEventArgs.cs b/TombIDE/TombIDE.ScriptingStudio/Controls/DocumentRenamedEventArgs.cs new file mode 100644 index 0000000000..b6e5f60e73 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/Controls/DocumentRenamedEventArgs.cs @@ -0,0 +1,16 @@ +using System; + +namespace TombIDE.ScriptingStudio.Controls +{ + 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/Controls/EditorTabControl.cs b/TombIDE/TombIDE.ScriptingStudio/Controls/EditorTabControl.cs index 4a0b82ad9e..a1ad401e04 100644 --- a/TombIDE/TombIDE.ScriptingStudio/Controls/EditorTabControl.cs +++ b/TombIDE/TombIDE.ScriptingStudio/Controls/EditorTabControl.cs @@ -6,15 +6,15 @@ using System.IO; using System.Linq; using System.Windows.Forms; -using System.Windows.Forms.Integration; -using TombIDE.ScriptingStudio.Forms; +using TombIDE.ScriptingStudio.EditorTabs; +using TombIDE.ScriptingStudio.FileExplorer; using TombIDE.ScriptingStudio.Helpers; using TombIDE.ScriptingStudio.Properties; +using TombIDE.ScriptingStudio.UI; using TombIDE.Shared; using TombIDE.Shared.SharedClasses; -using TombLib.Scripting.Bases; -using TombLib.Scripting.Enums; -using TombLib.Scripting.Interfaces; +using TombLib.Scripting.ClassicScript.Documents; +using TombLib.Scripting.UI.Editors; namespace TombIDE.ScriptingStudio.Controls { @@ -39,9 +39,7 @@ public string ScriptRootDirectoryPath public IEditorControl CurrentEditor => SelectedTab != null ? GetEditorOfTab(SelectedTab) : null; - public bool ReloadQueueRunning { get; private set; } - - public Type PlainTextTypeOverride { get; set; } = null; + public bool ReloadQueueRunning => _fileReloadCoordinator.IsRunning; #endregion Properties @@ -54,10 +52,9 @@ public string ScriptRootDirectoryPath private ToolTip _toolTip = new ToolTip(); - /// - /// This list is used to store file paths of files which should be reloaded after the main window regains focus. - /// - private List _pendingFileReloads = new List(); + private readonly EditorFactoryService _editorFactory = new EditorFactoryService(); + private readonly EditorTabHostService _editorHostService = new EditorTabHostService(); + private readonly FileReloadCoordinator _fileReloadCoordinator = new FileReloadCoordinator(); private Version _currentEngineVersion = new(0, 0); @@ -148,6 +145,46 @@ private void RestoreSession(string[] files) #region File opening + public Control GetHostedControl(TabPage tab) + => _editorHostService.GetHostedControl(tab); + + public DocumentMode GetDocumentMode(IEditorControl editor) + => editor is null ? DocumentMode.None : _editorFactory.GetDocumentMode(editor); + + public TabPage FindSourceTabPage(string filePath) + => FindTabPage(filePath, _editorFactory.GetSourceViewEditorType(filePath)); + + public void OpenSourceFile(string filePath, bool silentSession = false) + => OpenFile(filePath, _editorFactory.GetSourceViewEditorType(filePath), silentSession); + + public void RegisterJson5Editor(Func factory, DocumentMode documentMode) + => RegisterEditor(EditorType.Text, documentMode, FileHelper.IsJson5File, static _ => true, factory); + + public void RegisterLuaEditor(Func factory, DocumentMode documentMode) + => RegisterEditor(EditorType.Text, documentMode, FileHelper.IsLuaFile, static _ => true, factory); + + public void RegisterPlainTextEditor(Func factory, DocumentMode documentMode) + => _editorFactory.SetPlainTextEditorFactory(factory, documentMode); + + public void RegisterStringsEditor(Func factory) + => RegisterEditor( + EditorType.Strings, + DocumentMode.Strings, + FileHelper.IsTextFile, + filePath => FileHelper.GetClassicScriptFileKind(filePath) == ClassicScriptFileKind.Strings, + factory); + + public void RegisterTextEditor( + Func factory, + DocumentMode documentMode) + => RegisterTextEditor(factory, documentMode, _ => true); + + public void RegisterTextEditor( + Func factory, + DocumentMode documentMode, + Func isDefaultForFile) + => RegisterEditor(EditorType.Text, documentMode, FileHelper.IsTextFile, isDefaultForFile, factory); + public void OpenFile(string filePath, EditorType editorType = EditorType.Default, bool silentSession = false) { TabPage fileTabPage = FindTabPage(filePath, editorType); @@ -160,15 +197,10 @@ public void OpenFile(string filePath, EditorType editorType = EditorType.Default private void OpenFileInNewTabPage(string filePath, EditorType editorType, bool silentSession) { - Type editorClassType = EditorTypeHelper.GetEditorClassType(filePath, editorType); - - if (PlainTextTypeOverride != null && editorClassType == typeof(TextEditorBase)) - editorClassType = PlainTextTypeOverride; + IEditorControl newEditor = InitializeEditor(filePath, editorType, silentSession); - if (editorClassType != null) + if (newEditor is not null) { - IEditorControl newEditor = InitializeEditor(editorClassType, filePath, silentSession); - string tabPageTitle = BuildTabPageTitleText(newEditor.FilePath, newEditor.EditorType); var newTabPage = new TabPage(tabPageTitle); Control tabPageContent = InitializeTabPageContent(newEditor); @@ -181,11 +213,13 @@ private void OpenFileInNewTabPage(string filePath, EditorType editorType, bool s } } - private IEditorControl InitializeEditor(Type editorClassType, string filePath, bool silentSession) + private IEditorControl InitializeEditor(string filePath, EditorType editorType, bool silentSession) { - object[] args = new object[] { _currentEngineVersion }; + IEditorControl newEditor = _editorFactory.CreateEditor(filePath, editorType, _currentEngineVersion); + + if (newEditor is null) + return null; - var newEditor = Activator.CreateInstance(editorClassType, args) as IEditorControl; newEditor.ContentChangedWorkerRunCompleted += Editor_ContentChangedWorkerRunCompleted; if (File.Exists(filePath)) @@ -197,74 +231,36 @@ private IEditorControl InitializeEditor(Type editorClassType, string filePath, b } private Control InitializeTabPageContent(IEditorControl editor) - { - bool isWPF = editor.GetType().IsSubclassOf(typeof(System.Windows.UIElement)); + => _editorHostService.CreateHostControl(editor); - if (isWPF) - return new ElementHost { Dock = DockStyle.Fill, Child = editor as System.Windows.UIElement }; - else - return editor as Control; - } + private void RegisterEditor( + EditorType editorType, + DocumentMode documentMode, + Func supportsFile, + Func isDefaultForFile, + Func factory) + => _editorFactory.Register(new EditorRegistration(editorType, documentMode, supportsFile, isDefaultForFile, factory)); #endregion File opening #region File reloading public void AddFileToReloadQueue(string filePath) - { - if (!_pendingFileReloads.Contains(filePath)) - _pendingFileReloads.Add(filePath); - } + => _fileReloadCoordinator.QueueFile(filePath); public void TryRunFileReloadQueue() - { - if (ReloadQueueRunning) // Prevents calling the method again if it's already running - return; + => _fileReloadCoordinator.ProcessQueuedFiles(GetOpenEditorsOfFile, ShowFileReloadPrompt); - ReloadQueueRunning = true; - - for (int i = 0; i < _pendingFileReloads.Count; i++) - try - { - string file = _pendingFileReloads[i]; - IEnumerable tabPagesOfFile = FindTabPagesOfFile(file); - - if (tabPagesOfFile.Count() > 0) - TryAskFileReload(tabPagesOfFile); - } - catch (Exception) { } + private IReadOnlyList GetOpenEditorsOfFile(string filePath) + => FindTabPagesOfFile(filePath) + .Select(GetEditorOfTab) + .Where(editor => editor is not null) + .ToList(); - _pendingFileReloads.Clear(); - ReloadQueueRunning = false; - } - - private void TryAskFileReload(IEnumerable tabPagesOfFile) - { - DialogResult? result = null; - - foreach (TabPage tab in tabPagesOfFile) - { - IEditorControl editor = GetEditorOfTab(tab); - string fileContent = File.ReadAllText(editor.FilePath); - - if (editor.Content != fileContent) - { - if (result == null) - result = MessageBox.Show(this, - string.Format(Strings.Default.AskFileReload, editor.FilePath), Strings.Default.FileReloadMBT, - MessageBoxButtons.YesNo, MessageBoxIcon.Question); - - if (result == DialogResult.Yes) - { - // Re-read the file, because the user might've changed the file another time - fileContent = File.ReadAllText(editor.FilePath); - editor.Content = fileContent; - } - else if (result == DialogResult.No) - editor.TryRunContentChangedWorker(); - } - } - } + private DialogResult ShowFileReloadPrompt(string filePath) + => MessageBox.Show(this, + string.Format(Strings.Default.AskFileReload, filePath), Strings.Default.FileReloadMBT, + MessageBoxButtons.YesNo, MessageBoxIcon.Question); #endregion File reloading @@ -363,6 +359,7 @@ public FileSavingResult SaveFileAs() public FileSavingResult SaveFileAs(TabPage tab) { IEditorControl editor = GetEditorOfTab(tab); + string oldFilePath = editor.FilePath; string[] ignoredPaths = Array.Empty(); @@ -372,10 +369,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; @@ -392,7 +416,7 @@ private void SaveOtherTabPagesOfFile(IEditorControl excludedEditor) && tabEditor.EditorType != excludedEditor.EditorType) { if (tabEditor.Content != excludedEditor.Content) - tabEditor.Content = excludedEditor.Content; + tabEditor.ApplyPersistedContent(excludedEditor.Content); tabEditor.TryRunContentChangedWorker(); @@ -425,7 +449,7 @@ public TabPage FindTabPage(IEditorControl editor) public TabPage FindTabPage(string filePath, EditorType editorType = EditorType.Default) { if (editorType == EditorType.Default) - editorType = EditorTypeHelper.GetDefaultEditorType(filePath); + editorType = _editorFactory.GetDefaultEditorType(filePath); foreach (TabPage tab in TabPages) { @@ -500,17 +524,7 @@ public IEditorControl GetEditorOfTab(int index) => GetEditorOfTab(TabPages[index]); public IEditorControl GetEditorOfTab(TabPage tab) - { - IEnumerable elementHosts = tab?.Controls.OfType(); - - if (elementHosts?.Count() > 0) - return elementHosts.First().Child as IEditorControl; - else - { - IEnumerable editors = tab?.Controls.OfType(); - return editors?.Count() > 0 ? editors.First() : null; - } - } + => _editorHostService.GetEditor(tab); #endregion Editor finding @@ -520,6 +534,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 +708,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,13 +727,12 @@ public void RenameDocumentTabPage(string oldFilePath, string newFilePath) UpdateTabPageName(editor); } + + OnDocumentRenamed(new DocumentRenamedEventArgs(oldFilePath, newFilePath)); } private string BuildTabPageTitleText(string filePath, EditorType editorType) - { - string tabTypeText = editorType == EditorType.Text ? string.Empty : $" [{editorType}]"; - return $"{Path.GetFileName(filePath)}{tabTypeText}"; - } + => _editorFactory.BuildTabTitle(filePath, editorType); /// /// The difference between this and the AreAllFilesSaved() method is that this one
diff --git a/TombIDE/TombIDE.ScriptingStudio/Controls/SyntaxPreview.cs b/TombIDE/TombIDE.ScriptingStudio/Controls/SyntaxPreview.cs index 4045944b77..258af935be 100644 --- a/TombIDE/TombIDE.ScriptingStudio/Controls/SyntaxPreview.cs +++ b/TombIDE/TombIDE.ScriptingStudio/Controls/SyntaxPreview.cs @@ -5,6 +5,7 @@ using System.Windows.Forms; using TombLib.Scripting.ClassicScript; using TombLib.Scripting.ClassicScript.Resources; +using TombLib.Scripting.Specifications.ClassicScript; namespace TombIDE.ScriptingStudio.Controls { @@ -82,7 +83,7 @@ private void DoSyntaxHighlighting() SelectionColor = ColorTranslator.FromHtml(_config.ColorScheme.Values.HtmlColor); // Set the colors - SetTextColor(@"\[\b(" + string.Join("|", Keywords.Sections) + @"|Any)\b\]", ColorTranslator.FromHtml(_config.ColorScheme.Sections.HtmlColor)); + SetTextColor(@"\[\b(" + string.Join("|", ClassicScriptKeywords.Sections) + @"|Any)\b\]", ColorTranslator.FromHtml(_config.ColorScheme.Sections.HtmlColor)); SetTextColor(Patterns.StandardCommands, ColorTranslator.FromHtml(_config.ColorScheme.StandardCommands.HtmlColor)); SetTextColor(Patterns.NewCommands, ColorTranslator.FromHtml(_config.ColorScheme.NewCommands.HtmlColor)); SetTextColor("(ENABLED|DISABLED|#INCLUDE|#DEFINE|#FIRST_ID)", ColorTranslator.FromHtml(_config.ColorScheme.References.HtmlColor)); diff --git a/TombIDE/TombIDE.ScriptingStudio/ToolWindows/ContentExplorer.Designer.cs b/TombIDE/TombIDE.ScriptingStudio/DocumentOutline/ContentExplorer.Designer.cs similarity index 94% rename from TombIDE/TombIDE.ScriptingStudio/ToolWindows/ContentExplorer.Designer.cs rename to TombIDE/TombIDE.ScriptingStudio/DocumentOutline/ContentExplorer.Designer.cs index 43c66fcfdc..80aa937ea5 100644 --- a/TombIDE/TombIDE.ScriptingStudio/ToolWindows/ContentExplorer.Designer.cs +++ b/TombIDE/TombIDE.ScriptingStudio/DocumentOutline/ContentExplorer.Designer.cs @@ -1,4 +1,4 @@ -namespace TombIDE.ScriptingStudio.ToolWindows +namespace TombIDE.ScriptingStudio.DocumentOutline { partial class ContentExplorer { @@ -40,7 +40,7 @@ private void InitializeComponent() | System.Windows.Forms.AnchorStyles.Right))); this.searchTextBox.Location = new System.Drawing.Point(3, 28); this.searchTextBox.Name = "searchTextBox"; - this.searchTextBox.SearchText = "Search Contents..."; + this.searchTextBox.SearchText = "Search Outline..."; this.searchTextBox.SearchTextColor = System.Drawing.Color.DarkGray; this.searchTextBox.Size = new System.Drawing.Size(219, 22); this.searchTextBox.TabIndex = 4; @@ -53,7 +53,7 @@ private void InitializeComponent() this.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(60)))), ((int)(((byte)(63)))), ((int)(((byte)(65))))); this.Controls.Add(this.searchTextBox); this.Controls.Add(this.treeView); - this.DockText = "Content Explorer"; + this.DockText = "Document Outline"; this.Name = "ContentExplorer"; this.SerializationKey = "ContentExplorer"; this.Size = new System.Drawing.Size(225, 225); diff --git a/TombIDE/TombIDE.ScriptingStudio/DocumentOutline/ContentExplorer.cs b/TombIDE/TombIDE.ScriptingStudio/DocumentOutline/ContentExplorer.cs new file mode 100644 index 0000000000..40b8593af7 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/DocumentOutline/ContentExplorer.cs @@ -0,0 +1,168 @@ +#nullable enable + +using DarkUI.Controls; +using DarkUI.Docking; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using TombIDE.ScriptingStudio.UI; +using TombIDE.Shared; +using TombLib.Scripting; +using TombLib.Scripting.UI.ContentNodes; +using TombLib.Scripting.UI.Editors; + +namespace TombIDE.ScriptingStudio.DocumentOutline +{ + public partial class ContentExplorer : DarkToolWindow + { + private readonly ContentNodesRefreshCoordinator _refreshCoordinator; + private readonly ContentNodesProviderFactory _nodesProviderFactory; + private IEditorControl? _editorControl; + + #region Properties + + private DocumentMode _documentMode; + + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public DocumentMode DocumentMode + { + get => _documentMode; + set + { + _documentMode = value; + UpdateNodesProvider(); + } + } + + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public IEditorControl? EditorControl + { + get => _editorControl; + set + { + if (_editorControl is not null) + _editorControl.ContentChangedWorkerRunCompleted -= EditorControl_ContentChangedWorkerRunCompleted; + + _editorControl = value; + + if (_editorControl is not null) + _editorControl.ContentChangedWorkerRunCompleted += EditorControl_ContentChangedWorkerRunCompleted; + + UpdateTreeView(); + } + } + + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public ContentNodesProviderBase? NodesProvider { get; private set; } + + #endregion Properties + + #region Construction + + public ContentExplorer() + : this(new ContentNodesProviderFactory()) + { + } + + internal ContentExplorer(ContentNodesProviderFactory nodesProviderFactory) + { + ArgumentNullException.ThrowIfNull(nodesProviderFactory); + + _refreshCoordinator = new ContentNodesRefreshCoordinator(); + _nodesProviderFactory = nodesProviderFactory; + InitializeComponent(); + + DockText = Strings.Default.ContentExplorer; + searchTextBox.SearchText = Strings.Default.SearchContent; + } + + #endregion Construction + + #region Events + + public event ObjectClickedEventHandler? ObjectClicked; + protected virtual void OnObjectClicked(ObjectClickedEventArgs e) + => ObjectClicked?.Invoke(this, e); + + private void textBox_Search_TextChanged(object sender, EventArgs e) + => UpdateTreeView(); + + private void treeView_Click(object sender, EventArgs e) + { + if (treeView.SelectedNodes.Count > 0) + { + DarkTreeNode selectedNode = treeView.SelectedNodes.First(); + OnObjectClicked(new ObjectClickedEventArgs(selectedNode.Text, selectedNode.Tag)); + } + } + + private void EditorControl_ContentChangedWorkerRunCompleted(object? sender, EventArgs e) + => UpdateTreeView(); + + #endregion Events + + #region Methods + + public void SelectNode(string nodeText) + { + DarkTreeNode? node = treeView.Nodes.Find(x => x.Text == nodeText.Trim('[').Trim(']')); + + if (node is not null) + treeView.SelectNode(node); + + treeView.Invalidate(); + } + + private void UpdateTreeView() + { + if (EditorControl is null || NodesProvider is null) + { + _refreshCoordinator.InvalidatePendingRequests(); + ClearTreeView(); + return; + } + + string filter = string.IsNullOrWhiteSpace(searchTextBox.Text) + ? string.Empty + : searchTextBox.Text.Trim(); + + ContentNodesProviderBase nodesProvider = NodesProvider; + string content = EditorControl.Content; + + _refreshCoordinator.RequestRefresh(nodesProvider, content, filter, CanApplyRefreshResult, ApplyNodes); + } + + private void UpdateNodesProvider() + { + NodesProvider = _nodesProviderFactory.Create(_documentMode); + _refreshCoordinator.InvalidatePendingRequests(); + + UpdateTreeView(); + } + + private bool CanApplyRefreshResult(ContentNodesProviderBase nodesProvider) + => !IsDisposed && !Disposing && ReferenceEquals(nodesProvider, NodesProvider); + + private void ApplyNodes(IReadOnlyList nodes) + { + treeView.Nodes.Clear(); + + if (nodes.Count > 0) + treeView.Nodes.AddRange(nodes.ToArray()); + + treeView.Invalidate(); + } + + private void ClearTreeView() + { + treeView.Nodes.Clear(); + treeView.Invalidate(); + } + + #endregion Methods + } +} diff --git a/TombIDE/TombIDE.ScriptingStudio/ToolWindows/ContentExplorer.resx b/TombIDE/TombIDE.ScriptingStudio/DocumentOutline/ContentExplorer.resx similarity index 100% rename from TombIDE/TombIDE.ScriptingStudio/ToolWindows/ContentExplorer.resx rename to TombIDE/TombIDE.ScriptingStudio/DocumentOutline/ContentExplorer.resx diff --git a/TombIDE/TombIDE.ScriptingStudio/DocumentOutline/ContentNodesProviderFactory.cs b/TombIDE/TombIDE.ScriptingStudio/DocumentOutline/ContentNodesProviderFactory.cs new file mode 100644 index 0000000000..6fd5947cee --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/DocumentOutline/ContentNodesProviderFactory.cs @@ -0,0 +1,21 @@ +#nullable enable + +using TombIDE.ScriptingStudio.UI; +using TombLib.Scripting.ClassicScript.ContentNodes; +using TombLib.Scripting.GameFlowScript.ContentNodes; +using TombLib.Scripting.TRX.ContentNodes; +using TombLib.Scripting.UI.ContentNodes; + +namespace TombIDE.ScriptingStudio.DocumentOutline; + +internal sealed class ContentNodesProviderFactory +{ + public ContentNodesProviderBase? Create(DocumentMode documentMode) => documentMode switch + { + DocumentMode.ClassicScript => new ClassicScriptNodesProvider(), + DocumentMode.GameFlowScript => new GameFlowNodesProvider(), + DocumentMode.TRX => new TRXNodesProvider(), + DocumentMode.Strings => new StringFileNodesProvider(), + _ => null + }; +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/DocumentOutline/ContentNodesRefreshCoordinator.cs b/TombIDE/TombIDE.ScriptingStudio/DocumentOutline/ContentNodesRefreshCoordinator.cs new file mode 100644 index 0000000000..17e95f4e18 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/DocumentOutline/ContentNodesRefreshCoordinator.cs @@ -0,0 +1,58 @@ +#nullable enable + +using DarkUI.Controls; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using TombLib.Scripting.UI.ContentNodes; + +namespace TombIDE.ScriptingStudio.DocumentOutline; + +internal sealed class ContentNodesRefreshCoordinator +{ + private int _latestRefreshRequestId; + + public void InvalidatePendingRequests() + => Interlocked.Increment(ref _latestRefreshRequestId); + + public void RequestRefresh( + ContentNodesProviderBase nodesProvider, + string content, + string filter, + Func canApplyRefresh, + Action> applyNodes) + { + ArgumentNullException.ThrowIfNull(nodesProvider); + ArgumentNullException.ThrowIfNull(canApplyRefresh); + ArgumentNullException.ThrowIfNull(applyNodes); + + int requestId = Interlocked.Increment(ref _latestRefreshRequestId); + _ = RefreshAsync(nodesProvider, content ?? string.Empty, filter ?? string.Empty, requestId, canApplyRefresh, applyNodes); + } + + private async Task RefreshAsync( + ContentNodesProviderBase nodesProvider, + string content, + string filter, + int requestId, + Func canApplyRefresh, + Action> applyNodes) + { + IReadOnlyList nodes; + + try + { + nodes = await Task.Run(() => nodesProvider.GetNodes(content, filter)); + } + catch + { + return; + } + + if (requestId != Volatile.Read(ref _latestRefreshRequestId) || !canApplyRefresh(nodesProvider)) + return; + + applyNodes(nodes); + } +} \ No newline at end of file diff --git a/TombLib/TombLib.Scripting/Objects/ObjectClickedEventArgs.cs b/TombIDE/TombIDE.ScriptingStudio/DocumentOutline/ObjectClickedEventArgs.cs similarity index 82% rename from TombLib/TombLib.Scripting/Objects/ObjectClickedEventArgs.cs rename to TombIDE/TombIDE.ScriptingStudio/DocumentOutline/ObjectClickedEventArgs.cs index c6ab2dcfae..4b5f70dd92 100644 --- a/TombLib/TombLib.Scripting/Objects/ObjectClickedEventArgs.cs +++ b/TombIDE/TombIDE.ScriptingStudio/DocumentOutline/ObjectClickedEventArgs.cs @@ -1,6 +1,6 @@ -using System; +using System; -namespace TombLib.Scripting.Objects +namespace TombIDE.ScriptingStudio.DocumentOutline { public class ObjectClickedEventArgs : EventArgs { @@ -13,4 +13,4 @@ public ObjectClickedEventArgs(string objectName, object identifyingObject = null IdentifyingObject = identifyingObject; } } -} +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/EditorTabs/EditorFactoryService.cs b/TombIDE/TombIDE.ScriptingStudio/EditorTabs/EditorFactoryService.cs new file mode 100644 index 0000000000..1b16bb0403 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/EditorTabs/EditorFactoryService.cs @@ -0,0 +1,90 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using TombIDE.ScriptingStudio.UI; +using TombLib.Scripting.UI.Bases; +using TombLib.Scripting.UI.Editors; + +namespace TombIDE.ScriptingStudio.EditorTabs; + +internal sealed class EditorFactoryService +{ + private readonly ConditionalWeakTable _editorRegistrations = new(); + private readonly List _registrations = []; + + private DocumentMode _plainTextDocumentMode = DocumentMode.PlainText; + private Func? _plainTextEditorFactoryOverride; + + public string BuildTabTitle(string filePath, EditorType editorType) + { + string tabTypeText = editorType == GetDefaultEditorType(filePath) ? string.Empty : $" [{editorType}]"; + return $"{Path.GetFileName(filePath)}{tabTypeText}"; + } + + public IEditorControl CreateEditor(string filePath, EditorType editorType, Version engineVersion) + { + EditorRegistration? registration = ResolveRegistration(filePath, editorType); + IEditorControl editor = (registration?.Factory ?? ResolvePlainTextFactory()).Invoke(engineVersion); + + if (registration is not null) + _editorRegistrations.Add(editor, registration); + + return editor; + } + + public DocumentMode GetDocumentMode(IEditorControl editor) + { + if (_editorRegistrations.TryGetValue(editor, out EditorRegistration? registration)) + return registration.DocumentMode; + + return _registrations.FirstOrDefault(candidate => candidate.EditorType == editor.EditorType)?.DocumentMode + ?? _plainTextDocumentMode; + } + + public EditorType GetDefaultEditorType(string filePath) + => ResolveDefaultRegistration(filePath)?.EditorType ?? EditorType.Text; + + public EditorType GetSourceViewEditorType(string filePath) + => _registrations.FirstOrDefault(registration => registration.SupportsFile(filePath) && registration.EditorType == EditorType.Text)?.EditorType + ?? GetDefaultEditorType(filePath); + + public void Register(EditorRegistration registration) + { + ArgumentNullException.ThrowIfNull(registration); + + _registrations.Add(registration); + } + + public void SetPlainTextEditorFactory(Func? factory, DocumentMode documentMode) + { + _plainTextEditorFactoryOverride = factory; + _plainTextDocumentMode = documentMode; + } + + private EditorRegistration? ResolveDefaultRegistration(string filePath) + => _registrations.FirstOrDefault(registration => registration.SupportsFile(filePath) && registration.IsDefaultForFile(filePath)) + ?? _registrations.FirstOrDefault(registration => registration.SupportsFile(filePath)); + + private Func ResolvePlainTextFactory() + { + if (_plainTextEditorFactoryOverride is not null) + return _plainTextEditorFactoryOverride; + + return static engineVersion => new PlainTextEditor(engineVersion); + } + + private EditorRegistration? ResolveRegistration(string filePath, EditorType editorType) + { + EditorRegistration? defaultRegistration = ResolveDefaultRegistration(filePath); + + if (editorType == EditorType.Default) + return defaultRegistration; + + return _registrations.FirstOrDefault(registration => registration.SupportsFile(filePath) && registration.EditorType == editorType) + ?? defaultRegistration; + } +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/EditorTabs/EditorRegistration.cs b/TombIDE/TombIDE.ScriptingStudio/EditorTabs/EditorRegistration.cs new file mode 100644 index 0000000000..1cde902b65 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/EditorTabs/EditorRegistration.cs @@ -0,0 +1,34 @@ +#nullable enable + +using System; +using TombIDE.ScriptingStudio.UI; +using TombLib.Scripting.UI.Editors; + +namespace TombIDE.ScriptingStudio.EditorTabs; + +internal sealed class EditorRegistration +{ + public EditorRegistration( + EditorType editorType, + DocumentMode documentMode, + Func supportsFile, + Func isDefaultForFile, + Func factory) + { + EditorType = editorType; + DocumentMode = documentMode; + SupportsFile = supportsFile ?? throw new ArgumentNullException(nameof(supportsFile)); + IsDefaultForFile = isDefaultForFile ?? throw new ArgumentNullException(nameof(isDefaultForFile)); + Factory = factory ?? throw new ArgumentNullException(nameof(factory)); + } + + public EditorType EditorType { get; } + + public DocumentMode DocumentMode { get; } + + public Func Factory { get; } + + public Func IsDefaultForFile { get; } + + public Func SupportsFile { get; } +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/EditorTabs/EditorTabHostService.cs b/TombIDE/TombIDE.ScriptingStudio/EditorTabs/EditorTabHostService.cs new file mode 100644 index 0000000000..45d5ee3cd0 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/EditorTabs/EditorTabHostService.cs @@ -0,0 +1,48 @@ +#nullable enable + +using System; +using System.Windows.Forms; +using System.Windows.Forms.Integration; +using TombLib.Scripting.UI.Editors; + +namespace TombIDE.ScriptingStudio.EditorTabs; + +internal sealed class EditorTabHostService +{ + public Control CreateHostControl(IEditorControl editor) + { + ArgumentNullException.ThrowIfNull(editor); + + if (editor is Control control) + { + control.Dock = DockStyle.Fill; + return control; + } + + if (editor is System.Windows.UIElement element) + { + return new ElementHost + { + Dock = DockStyle.Fill, + Child = element + }; + } + + throw new InvalidOperationException($"Unsupported editor host type: {editor.GetType().FullName}"); + } + + public IEditorControl? GetEditor(TabPage? tabPage) + { + Control? hostedControl = GetHostedControl(tabPage); + + return hostedControl switch + { + ElementHost { Child: IEditorControl editor } => editor, + IEditorControl editor => editor, + _ => null + }; + } + + public Control? GetHostedControl(TabPage? tabPage) + => tabPage?.Controls.Count > 0 ? tabPage.Controls[0] : null; +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/EditorTabs/FileReloadCoordinator.cs b/TombIDE/TombIDE.ScriptingStudio/EditorTabs/FileReloadCoordinator.cs new file mode 100644 index 0000000000..b8443294a4 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/EditorTabs/FileReloadCoordinator.cs @@ -0,0 +1,88 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Windows.Forms; +using TombLib.Scripting.UI.Editors; + +namespace TombIDE.ScriptingStudio.EditorTabs; + +internal sealed class FileReloadCoordinator +{ + private readonly List _pendingFileReloads = []; + + public bool IsRunning { get; private set; } + + public void QueueFile(string filePath) + { + if (string.IsNullOrWhiteSpace(filePath) || _pendingFileReloads.Contains(filePath)) + return; + + _pendingFileReloads.Add(filePath); + } + + public void ProcessQueuedFiles( + Func> getEditorsOfFile, + Func promptReload) + { + ArgumentNullException.ThrowIfNull(getEditorsOfFile); + ArgumentNullException.ThrowIfNull(promptReload); + + if (IsRunning) + return; + + IsRunning = true; + + try + { + foreach (string filePath in _pendingFileReloads) + { + try + { + IReadOnlyList editors = getEditorsOfFile(filePath); + + if (editors.Count > 0) + TryReloadEditors(editors, promptReload); + } + catch + { + } + } + } + finally + { + _pendingFileReloads.Clear(); + IsRunning = false; + } + } + + private static void TryReloadEditors(IReadOnlyList editors, Func promptReload) + { + DialogResult? result = null; + + foreach (IEditorControl editor in editors) + { + if (string.IsNullOrWhiteSpace(editor.FilePath) || !File.Exists(editor.FilePath)) + continue; + + string fileContent = File.ReadAllText(editor.FilePath); + + if (editor.Content == fileContent) + continue; + + if (result is null) + result = promptReload(editor.FilePath); + + if (result == DialogResult.Yes) + { + fileContent = File.ReadAllText(editor.FilePath); + editor.ApplyPersistedContent(fileContent); + } + else if (result == DialogResult.No) + { + editor.TryRunContentChangedWorker(); + } + } + } +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/EventDelegates.cs b/TombIDE/TombIDE.ScriptingStudio/EventDelegates.cs index e4ae0314e9..a366e326ad 100644 --- a/TombIDE/TombIDE.ScriptingStudio/EventDelegates.cs +++ b/TombIDE/TombIDE.ScriptingStudio/EventDelegates.cs @@ -1,4 +1,17 @@ -using TombIDE.ScriptingStudio.Objects; +using TombIDE.ScriptingStudio.ClassicScript; +using TombIDE.ScriptingStudio.DocumentOutline; +using TombIDE.ScriptingStudio.FindAndReplace; +using TombIDE.ScriptingStudio.FileExplorer; +using TombLib.Scripting.UI.DataGrid; + +namespace TombLib.Scripting +{ + public delegate void FindReplaceEventHandler(object sender, FindReplaceEventArgs e); + + public delegate void CellValueChangedEventHandler(object sender, CellContentChangedEventArgs e); + + public delegate void ObjectClickedEventHandler(object sender, ObjectClickedEventArgs e); +} namespace TombIDE.ScriptingStudio { diff --git a/TombIDE/TombIDE.ScriptingStudio/ToolWindows/FileExplorer.Designer.cs b/TombIDE/TombIDE.ScriptingStudio/FileExplorer/FileExplorer.Designer.cs similarity index 99% rename from TombIDE/TombIDE.ScriptingStudio/ToolWindows/FileExplorer.Designer.cs rename to TombIDE/TombIDE.ScriptingStudio/FileExplorer/FileExplorer.Designer.cs index d1c3adca71..7c2fcf7575 100644 --- a/TombIDE/TombIDE.ScriptingStudio/ToolWindows/FileExplorer.Designer.cs +++ b/TombIDE/TombIDE.ScriptingStudio/FileExplorer/FileExplorer.Designer.cs @@ -1,4 +1,4 @@ -namespace TombIDE.ScriptingStudio.ToolWindows +namespace TombIDE.ScriptingStudio.FileExplorer { partial class FileExplorer { diff --git a/TombIDE/TombIDE.ScriptingStudio/ToolWindows/FileExplorer.cs b/TombIDE/TombIDE.ScriptingStudio/FileExplorer/FileExplorer.cs similarity index 96% rename from TombIDE/TombIDE.ScriptingStudio/ToolWindows/FileExplorer.cs rename to TombIDE/TombIDE.ScriptingStudio/FileExplorer/FileExplorer.cs index b807d72bfc..c84068df3f 100644 --- a/TombIDE/TombIDE.ScriptingStudio/ToolWindows/FileExplorer.cs +++ b/TombIDE/TombIDE.ScriptingStudio/FileExplorer/FileExplorer.cs @@ -7,14 +7,12 @@ using System.IO; using System.Linq; using System.Windows.Forms; -using TombIDE.ScriptingStudio.Forms; using TombIDE.ScriptingStudio.Helpers; -using TombIDE.ScriptingStudio.Objects; using TombIDE.Shared; using TombIDE.Shared.SharedClasses; -using TombLib.Scripting.Enums; +using TombLib.Scripting.UI.Editors; -namespace TombIDE.ScriptingStudio.ToolWindows +namespace TombIDE.ScriptingStudio.FileExplorer { public partial class FileExplorer : DarkToolWindow { @@ -152,7 +150,7 @@ private void treeView_KeyDown(object sender, KeyEventArgs e) private void menuItem_NewFile_Click(object sender, EventArgs e) => CreateNewFile(); private void menuItem_NewFolder_Click(object sender, EventArgs e) => CreateNewFolder(); private void menuItem_ViewInEditor_Click(object sender, EventArgs e) => OpenSelectedFile(); - private void menuItem_ViewCode_Click(object sender, EventArgs e) => OpenSelectedFile(EditorType.Text); + private void menuItem_ViewCode_Click(object sender, EventArgs e) => OpenSelectedFile(openSourceView: true); private void menuItem_Rename_Click(object sender, EventArgs e) => RenameItem(); private void menuItem_Delete_Click(object sender, EventArgs e) => DeleteItem(); @@ -163,7 +161,7 @@ private void menuItem_OpenInExplorer_Click(object sender, EventArgs e) #region Event methods - private void OpenSelectedFile(EditorType editorType = EditorType.Default) + private void OpenSelectedFile(EditorType editorType = EditorType.Default, bool openSourceView = false) { if (treeView.SelectedNodes.Count == 0) return; @@ -178,7 +176,9 @@ private void OpenSelectedFile(EditorType editorType = EditorType.Default) if (!IsSuppoertdFileFormat(selectedNodeFileInfo)) return; - OnFileOpened(new FileOpenedEventArgs(selectedNodeFileInfo.FullName, editorType)); + OnFileOpened(openSourceView + ? FileOpenedEventArgs.CreateSourceView(selectedNodeFileInfo.FullName) + : new FileOpenedEventArgs(selectedNodeFileInfo.FullName, editorType)); } public string CreateNewFile() diff --git a/TombIDE/TombIDE.ScriptingStudio/ToolWindows/FileExplorer.resx b/TombIDE/TombIDE.ScriptingStudio/FileExplorer/FileExplorer.resx similarity index 100% rename from TombIDE/TombIDE.ScriptingStudio/ToolWindows/FileExplorer.resx rename to TombIDE/TombIDE.ScriptingStudio/FileExplorer/FileExplorer.resx diff --git a/TombIDE/TombIDE.ScriptingStudio/FileExplorer/FileOpenedEventArgs.cs b/TombIDE/TombIDE.ScriptingStudio/FileExplorer/FileOpenedEventArgs.cs new file mode 100644 index 0000000000..5d0ca6a8b6 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/FileExplorer/FileOpenedEventArgs.cs @@ -0,0 +1,22 @@ +using System; +using TombLib.Scripting.UI.Editors; + +namespace TombIDE.ScriptingStudio.FileExplorer +{ + public class FileOpenedEventArgs : EventArgs + { + public string FilePath { get; } + public EditorType EditorType { get; } + public bool OpenSourceView { get; } + + public FileOpenedEventArgs(string filePath, EditorType editorType = EditorType.Default, bool openSourceView = false) + { + FilePath = filePath; + EditorType = editorType; + OpenSourceView = openSourceView; + } + + public static FileOpenedEventArgs CreateSourceView(string filePath) + => new FileOpenedEventArgs(filePath, EditorType.Default, true); + } +} diff --git a/TombIDE/TombIDE.ScriptingStudio/Forms/FormFileCreation.Designer.cs b/TombIDE/TombIDE.ScriptingStudio/FileExplorer/FormFileCreation.Designer.cs similarity index 98% rename from TombIDE/TombIDE.ScriptingStudio/Forms/FormFileCreation.Designer.cs rename to TombIDE/TombIDE.ScriptingStudio/FileExplorer/FormFileCreation.Designer.cs index 1465b7cde4..c4f6480c28 100644 --- a/TombIDE/TombIDE.ScriptingStudio/Forms/FormFileCreation.Designer.cs +++ b/TombIDE/TombIDE.ScriptingStudio/FileExplorer/FormFileCreation.Designer.cs @@ -1,4 +1,4 @@ -namespace TombIDE.ScriptingStudio.Forms +namespace TombIDE.ScriptingStudio.FileExplorer { partial class FormFileCreation { @@ -60,7 +60,7 @@ private void InitializeComponent() this.comboBox_FileFormat.FormattingEnabled = true; this.comboBox_FileFormat.Items.AddRange(new object[] { ".TXT (Text file)", - ".JSON5 (TR1X / TR2X File)", + ".JSON5 (TRX File)", ".LUA (Lua file)"}); this.comboBox_FileFormat.Location = new System.Drawing.Point(71, 370); this.comboBox_FileFormat.Margin = new System.Windows.Forms.Padding(0, 3, 3, 6); diff --git a/TombIDE/TombIDE.ScriptingStudio/Forms/FormFileCreation.cs b/TombIDE/TombIDE.ScriptingStudio/FileExplorer/FormFileCreation.cs similarity index 98% rename from TombIDE/TombIDE.ScriptingStudio/Forms/FormFileCreation.cs rename to TombIDE/TombIDE.ScriptingStudio/FileExplorer/FormFileCreation.cs index b320cd08b8..0dcee36eac 100644 --- a/TombIDE/TombIDE.ScriptingStudio/Forms/FormFileCreation.cs +++ b/TombIDE/TombIDE.ScriptingStudio/FileExplorer/FormFileCreation.cs @@ -7,7 +7,7 @@ using TombIDE.Shared; using TombIDE.Shared.SharedClasses; -namespace TombIDE.ScriptingStudio.Forms +namespace TombIDE.ScriptingStudio.FileExplorer { internal partial class FormFileCreation : DarkForm { diff --git a/TombIDE/TombIDE.ScriptingStudio/Forms/FormFileCreation.resx b/TombIDE/TombIDE.ScriptingStudio/FileExplorer/FormFileCreation.resx similarity index 100% rename from TombIDE/TombIDE.ScriptingStudio/Forms/FormFileCreation.resx rename to TombIDE/TombIDE.ScriptingStudio/FileExplorer/FormFileCreation.resx diff --git a/TombIDE/TombIDE.ScriptingStudio/Forms/FormFolderCreation.Designer.cs b/TombIDE/TombIDE.ScriptingStudio/FileExplorer/FormFolderCreation.Designer.cs similarity index 99% rename from TombIDE/TombIDE.ScriptingStudio/Forms/FormFolderCreation.Designer.cs rename to TombIDE/TombIDE.ScriptingStudio/FileExplorer/FormFolderCreation.Designer.cs index 3290548740..f6325a1b80 100644 --- a/TombIDE/TombIDE.ScriptingStudio/Forms/FormFolderCreation.Designer.cs +++ b/TombIDE/TombIDE.ScriptingStudio/FileExplorer/FormFolderCreation.Designer.cs @@ -1,4 +1,4 @@ -namespace TombIDE.ScriptingStudio.Forms +namespace TombIDE.ScriptingStudio.FileExplorer { partial class FormFolderCreation { diff --git a/TombIDE/TombIDE.ScriptingStudio/Forms/FormFolderCreation.cs b/TombIDE/TombIDE.ScriptingStudio/FileExplorer/FormFolderCreation.cs similarity index 98% rename from TombIDE/TombIDE.ScriptingStudio/Forms/FormFolderCreation.cs rename to TombIDE/TombIDE.ScriptingStudio/FileExplorer/FormFolderCreation.cs index b063570212..f96a0c70dc 100644 --- a/TombIDE/TombIDE.ScriptingStudio/Forms/FormFolderCreation.cs +++ b/TombIDE/TombIDE.ScriptingStudio/FileExplorer/FormFolderCreation.cs @@ -6,7 +6,7 @@ using TombIDE.ScriptingStudio.Helpers; using TombIDE.Shared.SharedClasses; -namespace TombIDE.ScriptingStudio.Forms +namespace TombIDE.ScriptingStudio.FileExplorer { internal partial class FormFolderCreation : DarkForm { diff --git a/TombIDE/TombIDE.ScriptingStudio/Forms/FormFolderCreation.resx b/TombIDE/TombIDE.ScriptingStudio/FileExplorer/FormFolderCreation.resx similarity index 100% rename from TombIDE/TombIDE.ScriptingStudio/Forms/FormFolderCreation.resx rename to TombIDE/TombIDE.ScriptingStudio/FileExplorer/FormFolderCreation.resx diff --git a/TombIDE/TombIDE.ScriptingStudio/Forms/FormRenameItem.Designer.cs b/TombIDE/TombIDE.ScriptingStudio/FileExplorer/FormRenameItem.Designer.cs similarity index 99% rename from TombIDE/TombIDE.ScriptingStudio/Forms/FormRenameItem.Designer.cs rename to TombIDE/TombIDE.ScriptingStudio/FileExplorer/FormRenameItem.Designer.cs index d63660e22f..d859eea0a8 100644 --- a/TombIDE/TombIDE.ScriptingStudio/Forms/FormRenameItem.Designer.cs +++ b/TombIDE/TombIDE.ScriptingStudio/FileExplorer/FormRenameItem.Designer.cs @@ -1,4 +1,4 @@ -namespace TombIDE.ScriptingStudio.Forms +namespace TombIDE.ScriptingStudio.FileExplorer { partial class FormRenameItem { diff --git a/TombIDE/TombIDE.ScriptingStudio/Forms/FormRenameItem.cs b/TombIDE/TombIDE.ScriptingStudio/FileExplorer/FormRenameItem.cs similarity index 96% rename from TombIDE/TombIDE.ScriptingStudio/Forms/FormRenameItem.cs rename to TombIDE/TombIDE.ScriptingStudio/FileExplorer/FormRenameItem.cs index 74a011152c..77a2f593dd 100644 --- a/TombIDE/TombIDE.ScriptingStudio/Forms/FormRenameItem.cs +++ b/TombIDE/TombIDE.ScriptingStudio/FileExplorer/FormRenameItem.cs @@ -4,7 +4,7 @@ using System.Windows.Forms; using TombIDE.Shared.SharedClasses; -namespace TombIDE.ScriptingStudio.Forms +namespace TombIDE.ScriptingStudio.FileExplorer { internal partial class FormRenameItem : DarkForm { diff --git a/TombIDE/TombIDE.ScriptingStudio/Forms/FormRenameItem.resx b/TombIDE/TombIDE.ScriptingStudio/FileExplorer/FormRenameItem.resx similarity index 100% rename from TombIDE/TombIDE.ScriptingStudio/Forms/FormRenameItem.resx rename to TombIDE/TombIDE.ScriptingStudio/FileExplorer/FormRenameItem.resx diff --git a/TombLib/TombLib.Scripting/Objects/FindReplaceEventArgs.cs b/TombIDE/TombIDE.ScriptingStudio/FindAndReplace/FindReplaceEventArgs.cs similarity index 79% rename from TombLib/TombLib.Scripting/Objects/FindReplaceEventArgs.cs rename to TombIDE/TombIDE.ScriptingStudio/FindAndReplace/FindReplaceEventArgs.cs index 645c7ebb17..9fcdfa42e6 100644 --- a/TombLib/TombLib.Scripting/Objects/FindReplaceEventArgs.cs +++ b/TombIDE/TombIDE.ScriptingStudio/FindAndReplace/FindReplaceEventArgs.cs @@ -1,7 +1,7 @@ -using System; +using System; using System.Collections.Generic; -namespace TombLib.Scripting.Objects +namespace TombIDE.ScriptingStudio.FindAndReplace { public class FindReplaceEventArgs : EventArgs { @@ -10,4 +10,4 @@ public class FindReplaceEventArgs : EventArgs public FindReplaceEventArgs(List collection) => SourceCollection = collection; } -} +} \ No newline at end of file diff --git a/TombLib/TombLib.Scripting/Objects/FindReplaceItem.cs b/TombIDE/TombIDE.ScriptingStudio/FindAndReplace/FindReplaceItem.cs similarity index 89% rename from TombLib/TombLib.Scripting/Objects/FindReplaceItem.cs rename to TombIDE/TombIDE.ScriptingStudio/FindAndReplace/FindReplaceItem.cs index 7b54a59c51..abd736a6fb 100644 --- a/TombLib/TombLib.Scripting/Objects/FindReplaceItem.cs +++ b/TombIDE/TombIDE.ScriptingStudio/FindAndReplace/FindReplaceItem.cs @@ -1,4 +1,4 @@ -namespace TombLib.Scripting.Objects +namespace TombIDE.ScriptingStudio.FindAndReplace { public class FindReplaceItem { @@ -15,4 +15,4 @@ public FindReplaceItem(int lineNumber, string lineText, string matchSegmentText, MatchSegmentIndex = matchSegmentIndex; } } -} +} \ No newline at end of file diff --git a/TombLib/TombLib.Scripting/Objects/FindReplaceSource.cs b/TombIDE/TombIDE.ScriptingStudio/FindAndReplace/FindReplaceSource.cs similarity index 69% rename from TombLib/TombLib.Scripting/Objects/FindReplaceSource.cs rename to TombIDE/TombIDE.ScriptingStudio/FindAndReplace/FindReplaceSource.cs index b0cf98fa79..38772a0170 100644 --- a/TombLib/TombLib.Scripting/Objects/FindReplaceSource.cs +++ b/TombIDE/TombIDE.ScriptingStudio/FindAndReplace/FindReplaceSource.cs @@ -1,6 +1,6 @@ -using System.Collections.Generic; +using System.Collections.Generic; -namespace TombLib.Scripting.Objects +namespace TombIDE.ScriptingStudio.FindAndReplace { public class FindReplaceSource : List { @@ -8,7 +8,8 @@ public class FindReplaceSource : List public FindReplaceSource() { } + public FindReplaceSource(string name) => Name = name; } -} +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/FindAndReplace/FindingOrder.cs b/TombIDE/TombIDE.ScriptingStudio/FindAndReplace/FindingOrder.cs new file mode 100644 index 0000000000..288f0a49ab --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/FindAndReplace/FindingOrder.cs @@ -0,0 +1,7 @@ +namespace TombIDE.ScriptingStudio.FindAndReplace; + +public enum FindingOrder +{ + Previous, + Next +} diff --git a/TombLib/TombLib.Scripting/Forms/FormFindReplace.Designer.cs b/TombIDE/TombIDE.ScriptingStudio/FindAndReplace/FormFindReplace.Designer.cs similarity index 99% rename from TombLib/TombLib.Scripting/Forms/FormFindReplace.Designer.cs rename to TombIDE/TombIDE.ScriptingStudio/FindAndReplace/FormFindReplace.Designer.cs index 98e754cb01..140d8cdce8 100644 --- a/TombLib/TombLib.Scripting/Forms/FormFindReplace.Designer.cs +++ b/TombIDE/TombIDE.ScriptingStudio/FindAndReplace/FormFindReplace.Designer.cs @@ -1,4 +1,4 @@ -namespace TombLib.Scripting.Forms +namespace TombIDE.ScriptingStudio.FindAndReplace { partial class FormFindReplace { diff --git a/TombLib/TombLib.Scripting/Forms/FormFindReplace.cs b/TombIDE/TombIDE.ScriptingStudio/FindAndReplace/FormFindReplace.cs similarity index 97% rename from TombLib/TombLib.Scripting/Forms/FormFindReplace.cs rename to TombIDE/TombIDE.ScriptingStudio/FindAndReplace/FormFindReplace.cs index 43035ecf7c..7bdc1dc77c 100644 --- a/TombLib/TombLib.Scripting/Forms/FormFindReplace.cs +++ b/TombIDE/TombIDE.ScriptingStudio/FindAndReplace/FormFindReplace.cs @@ -1,4 +1,4 @@ -using DarkUI.Forms; +using DarkUI.Forms; using ICSharpCode.AvalonEdit.Document; using System; using System.Collections.Generic; @@ -8,11 +8,10 @@ using System.Text.RegularExpressions; using System.Windows.Forms; using System.Windows.Forms.Integration; -using TombLib.Scripting.Bases; -using TombLib.Scripting.Enums; -using TombLib.Scripting.Objects; +using TombLib.Scripting; +using TombLib.Scripting.UI.Bases; -namespace TombLib.Scripting.Forms +namespace TombIDE.ScriptingStudio.FindAndReplace { public partial class FormFindReplace : DarkForm { @@ -65,24 +64,24 @@ protected override void OnClosing(CancelEventArgs e) base.OnClosing(e); } - private void button_FindPrev_Click(object sender, EventArgs e) => Find(FindingOrder.Prev); + private void button_FindPrev_Click(object sender, EventArgs e) => Find(FindingOrder.Previous); private void button_FindNext_Click(object sender, EventArgs e) => Find(FindingOrder.Next); private void button_Find_Click(object sender, EventArgs e) { if (radioButton_Up.Checked) - Find(FindingOrder.Prev); + Find(FindingOrder.Previous); else if (radioButton_Down.Checked) Find(FindingOrder.Next); } - private void button_ReplacePrev_Click(object sender, EventArgs e) => Replace(FindingOrder.Prev); + private void button_ReplacePrev_Click(object sender, EventArgs e) => Replace(FindingOrder.Previous); private void button_ReplaceNext_Click(object sender, EventArgs e) => Replace(FindingOrder.Next); private void button_Replace_Click(object sender, EventArgs e) { if (radioButton_Up.Checked) - Replace(FindingOrder.Prev); + Replace(FindingOrder.Previous); else if (radioButton_Down.Checked) Replace(FindingOrder.Next); } @@ -160,7 +159,7 @@ private void FindMatchInAnotherTab(FindingOrder order, string pattern, RegexOpti else switch (order) { - case FindingOrder.Prev: + case FindingOrder.Previous: FindPrevInPrevTab(); // Go to the previous tab to find matches there break; @@ -193,7 +192,7 @@ private void FindPrevInPrevTab() { MoveCaretToDocumentEnd(nextTarget); - Find(FindingOrder.Prev); + Find(FindingOrder.Previous); } } } @@ -212,7 +211,6 @@ private void FindNextInNextTab() else { _targetTabControl.SelectedIndex++; - TextEditorBase nextTarget = GetTextEditorOfTab(_targetTabControl.SelectedTab); // The tab has changed, therefore we can get the current tab's TextEditor if (nextTarget == null) @@ -220,7 +218,6 @@ private void FindNextInNextTab() else { MoveCaretToDocumentStart(nextTarget); - Find(FindingOrder.Next); } } @@ -230,7 +227,7 @@ private void SelectMatch(FindingOrder order, TextEditorBase textEditor, MatchCol { switch (order) { - case FindingOrder.Prev: + case FindingOrder.Previous: { // Get the last match of that section, since we're going upwards Match lastMatch = sectionMatchCollection[sectionMatchCollection.Count - 1]; @@ -259,7 +256,7 @@ private void EndSuccessfulSearch(FindingOrder order, TextEditorBase textEditor) { switch (order) { - case FindingOrder.Prev: + case FindingOrder.Previous: MoveCaretToDocumentStart(textEditor); ShowWarning("Reached the start of the document with no more matches found."); // Search ends here break; @@ -275,7 +272,7 @@ private MatchCollection GetMatchCollectionFromSection(FindingOrder order, TextEd { switch (order) { - case FindingOrder.Prev: + case FindingOrder.Previous: string textBeforeSelection = GetTextBeforeSelection(textEditor.Text, textEditor.SelectionStart); return Regex.Matches(textBeforeSelection, pattern, options); @@ -447,6 +444,7 @@ private void ReplaceAll() // TODO: Refactor MoveCaretToDocumentStart(currentTabTextEditor); } } + else if (radioButton_AllTabs.Checked) { matchCount = GetAllTabsMatchCount(pattern, options); @@ -586,4 +584,4 @@ private string GetTextAfterSelection(string documentText, int selectionEndIndex) #endregion Other methods } -} +} \ No newline at end of file diff --git a/TombLib/TombLib.Scripting/Forms/FormFindReplace.resx b/TombIDE/TombIDE.ScriptingStudio/FindAndReplace/FormFindReplace.resx similarity index 99% rename from TombLib/TombLib.Scripting/Forms/FormFindReplace.resx rename to TombIDE/TombIDE.ScriptingStudio/FindAndReplace/FormFindReplace.resx index 422cca5764..ffbe25279a 100644 --- a/TombLib/TombLib.Scripting/Forms/FormFindReplace.resx +++ b/TombIDE/TombIDE.ScriptingStudio/FindAndReplace/FormFindReplace.resx @@ -1,4 +1,4 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 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/TombIDE/TombIDE.ScriptingStudio/GameFlowScript/GameFlowDocumentCommandHandler.cs b/TombIDE/TombIDE.ScriptingStudio/GameFlowScript/GameFlowDocumentCommandHandler.cs new file mode 100644 index 0000000000..4f1a482ac8 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/GameFlowScript/GameFlowDocumentCommandHandler.cs @@ -0,0 +1,42 @@ +using System; +using System.Threading.Tasks; +using TombIDE.ScriptingStudio.CommandSurface; +using TombIDE.ScriptingStudio.UI; +using TombLib.Scripting.GameFlowScript; + +namespace TombIDE.ScriptingStudio.GameFlowScript; + +public sealed class GameFlowDocumentCommandHandler : IStudioDocumentCommandHandler +{ + private readonly GameFlowDocumentCommandCallbacks _callbacks; + + public GameFlowDocumentCommandHandler(GameFlowDocumentCommandCallbacks callbacks) + { + _callbacks = callbacks ?? throw new ArgumentNullException(nameof(callbacks)); + } + + public bool TryHandle(UICommand command) + { + if (command == UICommand.Tomb3ExtraCommands) + { + _callbacks.ShowExtraCommandsDocumentation(); + return true; + } + + if (_callbacks.GetCurrentEditor() is not GameFlowEditor editor) + return false; + + if (command == UICommand.Reindent || command == UICommand.TrimWhiteSpace) + { + _ = _callbacks.FormatAsync(editor); + return true; + } + + return false; + } +} + +public sealed record GameFlowDocumentCommandCallbacks( + Func GetCurrentEditor, + Func FormatAsync, + Action ShowExtraCommandsDocumentation); \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/GameFlowScript/GameFlowWorkspaceAutomationProvider.cs b/TombIDE/TombIDE.ScriptingStudio/GameFlowScript/GameFlowWorkspaceAutomationProvider.cs new file mode 100644 index 0000000000..5af6ee4a6c --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/GameFlowScript/GameFlowWorkspaceAutomationProvider.cs @@ -0,0 +1,136 @@ +using DarkUI.Forms; +using System; +using System.Diagnostics; +using System.IO; +using System.Windows.Forms; +using TombIDE.ScriptingStudio.Services; +using TombIDE.ScriptingStudio.TextEditing; +using TombIDE.ScriptingStudio.WorkspaceProfile; +using TombIDE.Shared; +using TombIDE.Shared.SharedClasses; +using TombLib.LevelData; +using TombLib.Scripting.GameFlowScript.Compilers; +using TombLib.Scripting.Specifications.GameFlow; + +namespace TombIDE.ScriptingStudio.GameFlowScript; + +internal sealed record GameFlowWorkspaceAutomationCallbacks( + Action AppendScript, + Func IsLevelScriptDefined, + Action RenameRequestedLevelScript, + Action SaveAll, + Action ShowCompilerLogsPane, + Action UpdateCompilerLogs); + +internal sealed class GameFlowWorkspaceAutomationProvider : IStudioWorkspaceAutomationProvider +{ + private readonly GameFlowWorkspaceAutomationCallbacks _callbacks; + private readonly string _engineDirectoryPath; + private readonly IWin32Window _promptOwner; + private readonly string _scriptRootDirectoryPath; + private readonly StudioSilentActionService _silentActionService; + private readonly ScriptingWorkspaceProfile _workspaceProfile; + + public GameFlowWorkspaceAutomationProvider( + IWin32Window promptOwner, + ScriptingWorkspaceProfile workspaceProfile, + StudioSilentActionService silentActionService, + string scriptRootDirectoryPath, + string engineDirectoryPath, + GameFlowWorkspaceAutomationCallbacks callbacks) + { + _promptOwner = promptOwner ?? throw new ArgumentNullException(nameof(promptOwner)); + _workspaceProfile = workspaceProfile ?? throw new ArgumentNullException(nameof(workspaceProfile)); + _silentActionService = silentActionService ?? throw new ArgumentNullException(nameof(silentActionService)); + _scriptRootDirectoryPath = scriptRootDirectoryPath ?? string.Empty; + _engineDirectoryPath = engineDirectoryPath ?? string.Empty; + _callbacks = callbacks ?? throw new ArgumentNullException(nameof(callbacks)); + } + + public void HandleIDEEvent(IIDEEvent ideEvent) + { + if (ideEvent is null || !IsSilentAction(ideEvent)) + return; + + TabPage cachedTab = _silentActionService.RememberSelectedTab(); + string scriptFilePath = PathHelper.GetScriptFilePath(_scriptRootDirectoryPath, TRVersion.Game.TR2); + + if (ideEvent is IDE.ScriptEditor_AppendScriptEvent appendEvent && appendEvent.Result.HasContent) + { + SilentActionFileState scriptFileState = _silentActionService.CaptureFileState(scriptFilePath); + _callbacks.AppendScript(appendEvent.Result.GameFlowScript); + _silentActionService.Complete(cachedTab, true, _silentActionService.CreateCompletion(scriptFileState)); + } + else if (ideEvent is IDE.ScriptEditor_ScriptPresenceCheckEvent scriptPresenceEvent) + { + SilentActionFileState scriptFileState = _silentActionService.CaptureFileState(scriptFilePath); + IDE.Instance.ScriptDefined = _callbacks.IsLevelScriptDefined(scriptPresenceEvent.LevelName); + _silentActionService.Complete(cachedTab, false, _silentActionService.CreateCompletion(scriptFileState, saveAffectedFile: false)); + } + else if (ideEvent is IDE.ScriptEditor_RenameLevelEvent renameLevelEvent) + { + SilentActionFileState scriptFileState = _silentActionService.CaptureFileState(scriptFilePath); + _callbacks.RenameRequestedLevelScript(renameLevelEvent.OldName, renameLevelEvent.NewName); + _silentActionService.Complete(cachedTab, true, _silentActionService.CreateCompletion(scriptFileState)); + } + } + + public void Build() + { + _callbacks.SaveAll(); + + try + { + string engineExecutable = IDE.Instance.Project.GetEngineExecutableFilePath(); + FileVersionInfo fileVersionInfo = FileVersionInfo.GetVersionInfo(engineExecutable); + var productVersion = new Version(fileVersionInfo.ProductVersion ?? "0.0"); + bool success; + + if (_workspaceProfile.GameVersion == TRVersion.Game.TR3 + && productVersion >= new Version(2, 0, 0, 0)) + { + success = ScriptCompiler.CompileTR3Version2Plus( + _scriptRootDirectoryPath, + Path.Combine(_engineDirectoryPath, "data"), + IDE.Instance.IDEConfiguration.ShowCompilerLogsAfterBuild); + } + else + { + success = ScriptCompiler.ClassicCompile( + _scriptRootDirectoryPath, + Path.Combine(_engineDirectoryPath, "data"), + _workspaceProfile.GameVersion == TRVersion.Game.TR3, + IDE.Instance.IDEConfiguration.ShowCompilerLogsAfterBuild); + } + + _callbacks.UpdateCompilerLogs(success ? "Script compiled successfully!" : "ERROR: Couldn't compile script."); + + if (IDE.Instance.IDEConfiguration.ShowCompilerLogsAfterBuild || !success) + _callbacks.ShowCompilerLogsPane(); + } + catch (Exception exception) + { + DarkMessageBox.Show(_promptOwner, exception.Message, "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + public void ShowDocumentation() + => OpenPathIfExists(GameFlowDocumentationPaths.MainManualPath); + + private static bool IsSilentAction(IIDEEvent ideEvent) + => ideEvent is IDE.ScriptEditor_AppendScriptEvent + || ideEvent is IDE.ScriptEditor_ScriptPresenceCheckEvent + || ideEvent is IDE.ScriptEditor_RenameLevelEvent; + + private static void OpenPathIfExists(string filePath) + { + if (!File.Exists(filePath)) + return; + + Process.Start(new ProcessStartInfo + { + FileName = filePath, + UseShellExecute = true + }); + } +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/GameFlowScriptStudio.cs b/TombIDE/TombIDE.ScriptingStudio/GameFlowScriptStudio.cs index 2992aa4447..c8e72faaaf 100644 --- a/TombIDE/TombIDE.ScriptingStudio/GameFlowScriptStudio.cs +++ b/TombIDE/TombIDE.ScriptingStudio/GameFlowScriptStudio.cs @@ -5,228 +5,93 @@ using System.Windows.Forms; using TombIDE.ScriptingStudio.Bases; using TombIDE.ScriptingStudio.Controls; -using TombIDE.ScriptingStudio.ToolWindows; +using TombIDE.ScriptingStudio.GameFlowScript; +using TombIDE.ScriptingStudio.TextEditing; +using TombIDE.ScriptingStudio.WorkspaceProfile; using TombIDE.ScriptingStudio.UI; -using TombIDE.Shared; using TombIDE.Shared.SharedClasses; -using TombLib.Scripting.Bases; -using TombLib.Scripting.GameFlowScript.Parsers; -using TombLib.Scripting.GameFlowScript.Utils; +using TombLib.Scripting.Editing; +using TombLib.Scripting.GameFlowScript; +using TombLib.Scripting.GameFlowScript.Compilers; +using TombLib.Scripting.GameFlowScript.Documents; using TombLib.Scripting.GameFlowScript.Writers; -using TombLib.Scripting.Interfaces; +using TombLib.Scripting.Specifications.GameFlow; +using TombLib.Scripting.UI.Bases; +using TombLib.Scripting.UI.Cleaning; +using TombLib.Scripting.UI.Editing; +using TombLib.Scripting.UI.Editors; namespace TombIDE.ScriptingStudio { - public sealed class GameFlowScriptStudio : StudioBase + public sealed class GameFlowScriptStudio : TombIDE.ScriptingStudio.Bases.ScriptingStudio { - public override StudioMode StudioMode => StudioMode.GameFlowScript; + private readonly GameFlowDocumentLookupService _documentLookupService = new(); + private readonly EditorTabControlTextEditorHost _textEditorHost; + private readonly ITextFormattingProvider _trimWhitespaceProvider = new TextDocumentFormatterProvider(TrimTrailingWhitespaceFormatter.Instance); + private readonly TextWorkspaceEditApplier _workspaceEditApplier; + private readonly TextWorkspaceCommandService _workspaceCommandService; #region Construction - public GameFlowScriptStudio() : base(IDE.Instance.Project.GetScriptRootDirectory(), IDE.Instance.Project.GetEngineRootDirectoryPath()) + public GameFlowScriptStudio(ScriptingWorkspaceProfile workspaceProfile) : base() { - DockPanelState = IDE.Instance.IDEConfiguration.GFL_DockPanelState; - - FileExplorer.Filter = "*.txt"; - FileExplorer.CommentPrefix = "//"; - - EditorTabControl.PlainTextTypeOverride = typeof(GameFlowScriptStudio); - - EditorTabControl.CheckPreviousSession(); - - string initialFilePath = PathHelper.GetScriptFilePath(IDE.Instance.Project.GetScriptRootDirectory(), TombLib.LevelData.TRVersion.Game.TR2); - - if (!string.IsNullOrWhiteSpace(initialFilePath)) - EditorTabControl.OpenFile(initialFilePath); + ArgumentNullException.ThrowIfNull(workspaceProfile); + _textEditorHost = new EditorTabControlTextEditorHost(EditorTabControl); + var silentActionService = new StudioSilentActionService(EditorTabControl); + _workspaceEditApplier = new TextWorkspaceEditApplier(_textEditorHost); + _workspaceCommandService = new TextWorkspaceCommandService(_workspaceEditApplier); + var documentCommandHandler = new GameFlowDocumentCommandHandler( + new GameFlowDocumentCommandCallbacks( + () => CurrentEditor as GameFlowEditor, + editor => _workspaceCommandService.FormatDocumentAsync(editor, _trimWhitespaceProvider), + ShowExtraCommandsDocumentation)); + var workspaceAutomationProvider = new GameFlowWorkspaceAutomationProvider( + this, + workspaceProfile, + silentActionService, + ScriptRootDirectoryPath, + EngineDirectoryPath, + new GameFlowWorkspaceAutomationCallbacks( + AppendScript, + IsLevelScriptDefined, + RenameRequestedLevelScript, + EditorTabControl.SaveAll, + () => ShowPane(UICommand.CompilerLogs), + CompilerLogs.UpdateLogs)); + InitializeHost( + workspaceProfile, + (editor, configs) => editor.UpdateSettings(configs.GameFlowScript), + () => ApplyUserSettingsToOpenEditors(), + documentCommandHandler: documentCommandHandler, + workspaceAutomationProvider: workspaceAutomationProvider); } #endregion Construction - #region IDE Events - - protected override void OnIDEEventRaised(IIDEEvent obj) - { - base.OnIDEEventRaised(obj); - - IDEEvent_HandleSilentActions(obj); - } - - private bool IsSilentAction(IIDEEvent obj) - => obj is IDE.ScriptEditor_AppendScriptEvent - || obj is IDE.ScriptEditor_ScriptPresenceCheckEvent - || obj is IDE.ScriptEditor_RenameLevelEvent; - - private void IDEEvent_HandleSilentActions(IIDEEvent obj) - { - if (IsSilentAction(obj)) - { - TabPage cachedTab = EditorTabControl.SelectedTab; - - TabPage scriptFileTab = EditorTabControl.FindTabPage(PathHelper.GetScriptFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TR2)); - bool wasScriptFileAlreadyOpened = scriptFileTab != null; - bool wasScriptFileFileChanged = wasScriptFileAlreadyOpened && EditorTabControl.GetEditorOfTab(scriptFileTab).IsContentChanged; - - if (obj is IDE.ScriptEditor_AppendScriptEvent asle && asle.Result.HasContent) - { - AppendScript(asle.Result.GameFlowScript); - EndSilentScriptAction(cachedTab, true, !wasScriptFileFileChanged, !wasScriptFileAlreadyOpened); - } - else if (obj is IDE.ScriptEditor_ScriptPresenceCheckEvent scrpce) - { - IDE.Instance.ScriptDefined = IsLevelScriptDefined(scrpce.LevelName); - EndSilentScriptAction(cachedTab, false, false, !wasScriptFileAlreadyOpened); - } - else if (obj is IDE.ScriptEditor_RenameLevelEvent rle) - { - string oldName = rle.OldName; - string newName = rle.NewName; - - RenameRequestedLevelScript(oldName, newName); - EndSilentScriptAction(cachedTab, true, !wasScriptFileFileChanged, !wasScriptFileAlreadyOpened); - } - } - else if (obj is IDE.ProgramClosingEvent) - { - IDE.Instance.IDEConfiguration.GFL_DockPanelState = DockPanel.GetDockPanelState(); - IDE.Instance.IDEConfiguration.Save(); - } - } - private void AppendScript(string scriptText) { - EditorTabControl.OpenFile(PathHelper.GetScriptFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TR2)); - - if (CurrentEditor is TextEditorBase editor) - { - editor.AppendText(Environment.NewLine +scriptText + Environment.NewLine); - editor.ScrollToLine(editor.LineCount); - } + TextEditorBase editor = _textEditorHost.OpenTextEditor(PathHelper.GetScriptFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TR2)); + editor.AppendText(Environment.NewLine + scriptText + Environment.NewLine); + editor.ScrollToLine(editor.LineCount); } private void RenameRequestedLevelScript(string oldName, string newName) { - EditorTabControl.OpenFile(PathHelper.GetScriptFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TR2)); - - if (CurrentEditor is TextEditorBase editor) - ScriptReplacer.RenameLevelScript(editor, oldName, newName); + TextEditorBase editor = _textEditorHost.OpenTextEditor(PathHelper.GetScriptFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TR2)); + ScriptReplacer.RenameLevelScript(editor, oldName, newName); } private bool IsLevelScriptDefined(string levelName) { - EditorTabControl.OpenFile(PathHelper.GetScriptFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TR2)); - - if (CurrentEditor is TextEditorBase editor) - return DocumentParser.IsLevelScriptDefined(editor.Document, levelName); - - return false; + TextEditorBase editor = _textEditorHost.OpenTextEditor(PathHelper.GetScriptFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TR2)); + return _documentLookupService.IsLevelScriptDefined(editor.Document, levelName); } - private void EndSilentScriptAction(TabPage previousTab, bool indicateChange, bool saveAffectedFile, bool closeAffectedTab) - { - if (indicateChange) - { - CurrentEditor.LastModified = DateTime.Now; - IDE.Instance.ScriptEditor_IndicateExternalChange(); - } - - if (saveAffectedFile) - EditorTabControl.SaveFile(EditorTabControl.SelectedTab); - - if (closeAffectedTab) - EditorTabControl.TabPages.Remove(EditorTabControl.SelectedTab); - - EditorTabControl.EnsureTabFileSynchronization(); - - if (previousTab != null) - EditorTabControl.SelectTab(previousTab); - } - - #endregion IDE Events - #region Other methods - protected override void ApplyUserSettings(IEditorControl editor) - => editor.UpdateSettings(Configs.GameFlowScript); - - protected override void ApplyUserSettings() - { - foreach (TabPage tab in EditorTabControl.TabPages) - ApplyUserSettings(EditorTabControl.GetEditorOfTab(tab)); - - UpdateSettings(); - } - - protected override void Build() - { - EditorTabControl.SaveAll(); - - try - { - string engineExecutable = IDE.Instance.Project.GetEngineExecutableFilePath(); - var fileVersionInfo = FileVersionInfo.GetVersionInfo(engineExecutable); - var productVersion = new Version(fileVersionInfo.ProductVersion ?? "0.0"); - - bool success; - - if (IDE.Instance.Project.GameVersion == TombLib.LevelData.TRVersion.Game.TR3 - && productVersion >= new Version(2, 0, 0, 0)) - { - success = ScriptCompiler.CompileTR3Version2Plus( - ScriptRootDirectoryPath, Path.Combine(EngineDirectoryPath, "data"), - IDE.Instance.IDEConfiguration.ShowCompilerLogsAfterBuild); - } - else - { - success = ScriptCompiler.ClassicCompile( - ScriptRootDirectoryPath, Path.Combine(EngineDirectoryPath, "data"), - IDE.Instance.Project.GameVersion == TombLib.LevelData.TRVersion.Game.TR3, - IDE.Instance.IDEConfiguration.ShowCompilerLogsAfterBuild); - } - - if (success) - CompilerLogs.UpdateLogs("Script compiled successfully!"); - else - CompilerLogs.UpdateLogs("ERROR: Couldn't compile script."); - } - catch (Exception ex) - { - DarkMessageBox.Show(this, ex.Message, "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); - } - } - - protected override void RestoreDefaultLayout() - { - DockPanelState = DefaultLayouts.GameFlowScriptLayout; - - DockPanel.RemoveContent(); - DockPanel.RestoreDockPanelState(DockPanelState, FindDockContentByKey); - } - - protected override void HandleDocumentCommands(UICommand command) - { - switch (command) - { - case UICommand.Tomb3ExtraCommands: - string pdfPath = Path.Combine(DefaultPaths.ResourcesDirectory, "GameFlow", "TRGameflow extra commands.pdf"); - - var process = new ProcessStartInfo - { - FileName = pdfPath, - UseShellExecute = true - }; - - if (File.Exists(pdfPath)) - Process.Start(process); - - break; - } - - base.HandleDocumentCommands(command); - } - - protected override void ShowDocumentation() + private static void ShowExtraCommandsDocumentation() { - string pdfPath = Path.Combine(DefaultPaths.ResourcesDirectory, "GameFlow", "TRGameflow.pdf"); + string pdfPath = GameFlowDocumentationPaths.ExtraCommandsManualPath; var process = new ProcessStartInfo { diff --git a/TombIDE/TombIDE.ScriptingStudio/GlobalUsings.cs b/TombIDE/TombIDE.ScriptingStudio/GlobalUsings.cs new file mode 100644 index 0000000000..4d58e4d987 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/GlobalUsings.cs @@ -0,0 +1 @@ +global using TombLib.Scripting.Core.Lua; \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/Helpers/EditorTypeHelper.cs b/TombIDE/TombIDE.ScriptingStudio/Helpers/EditorTypeHelper.cs deleted file mode 100644 index 03e4c64842..0000000000 --- a/TombIDE/TombIDE.ScriptingStudio/Helpers/EditorTypeHelper.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System; -using TombIDE.Shared; -using TombLib.LevelData; -using TombLib.Scripting.Bases; -using TombLib.Scripting.ClassicScript; -using TombLib.Scripting.Enums; -using TombLib.Scripting.GameFlowScript; -using TombLib.Scripting.Lua; -using TombLib.Scripting.Tomb1Main; - -namespace TombIDE.ScriptingStudio.Helpers -{ - internal static class EditorTypeHelper - { - public static Type GetEditorClassType(string filePath, EditorType editorType = EditorType.Default) - { - if (editorType == EditorType.Default) - editorType = GetDefaultEditorType(filePath); - - if (IDE.Instance.Project.GameVersion is TRVersion.Game.TR1 or TRVersion.Game.TR2X) - { - if (FileHelper.IsJson5File(filePath)) - return typeof(Tomb1MainEditor); - else - return typeof(TextEditorBase); - } - else if (IDE.Instance.Project.GameVersion == TRVersion.Game.TombEngine) - { - if (FileHelper.IsLuaFile(filePath)) - return typeof(LuaEditor); - else - return typeof(TextEditorBase); - } - else if (FileHelper.IsTextFile(filePath)) - { - if (IDE.Instance.Project.GameVersion is TRVersion.Game.TR2 or TRVersion.Game.TR3) - { - return typeof(GameFlowEditor); - } - else - { - if (FileHelper.IsClassicScriptFile(filePath)) - return typeof(ClassicScriptEditor); - else if (FileHelper.IsStringFile(filePath)) - return editorType == EditorType.Strings ? typeof(StringEditor) : typeof(ClassicScriptEditor); - else - return typeof(TextEditorBase); - } - } - else if (FileHelper.IsLuaFile(filePath)) - return typeof(LuaEditor); - else - return null; - } - - public static EditorType GetDefaultEditorType(string filePath) - { - if (FileHelper.IsStringFile(filePath)) - return EditorType.Strings; - else - return EditorType.Text; - } - } -} diff --git a/TombIDE/TombIDE.ScriptingStudio/Helpers/FileHelper.cs b/TombIDE/TombIDE.ScriptingStudio/Helpers/FileHelper.cs index d37b37acbe..b00816bc1b 100644 --- a/TombIDE/TombIDE.ScriptingStudio/Helpers/FileHelper.cs +++ b/TombIDE/TombIDE.ScriptingStudio/Helpers/FileHelper.cs @@ -1,73 +1,21 @@ using System; using System.IO; -using System.Text; -using TombIDE.ScriptingStudio.UI; -using TombLib.Scripting.ClassicScript; -using TombLib.Scripting.ClassicScript.Parsers; -using TombLib.Scripting.GameFlowScript; -using TombLib.Scripting.Helpers; -using TombLib.Scripting.Interfaces; -using TombLib.Scripting.Lua; -using TombLib.Scripting.Tomb1Main; +using TombLib.Scripting.ClassicScript.Documents; namespace TombIDE.ScriptingStudio.Helpers { internal static class FileHelper { - public static DocumentMode GetDocumentModeOfEditor(IEditorControl editor) - { - Type editorClassType = editor.GetType(); + private static readonly ClassicScriptFileClassificationService _classicScriptFileClassificationService = new(); - if (editorClassType != null) - { - if (editorClassType == typeof(ClassicScriptEditor)) - return DocumentMode.ClassicScript; - else if (editorClassType == typeof(StringEditor)) - return DocumentMode.Strings; - else if (editorClassType == typeof(LuaEditor)) - return DocumentMode.Lua; - else if (editorClassType == typeof(GameFlowEditor)) - return DocumentMode.GameFlowScript; - else if (editorClassType == typeof(Tomb1MainEditor)) - return DocumentMode.Tomb1Main; - } - - return DocumentMode.PlainText; - } + public static ClassicScriptFileKind GetClassicScriptFileKind(string filePath) + => _classicScriptFileClassificationService.GetFileKind(filePath); public static bool IsStringFile(string filePath) - { - string[] lines = File.ReadAllLines(filePath); - - foreach (string line in lines) - if (LineParser.IsSectionHeaderLine(line)) - { - string text = LineParser.GetSectionHeaderText(line); - - if (StringHelper.BulkStringComparision(text, StringComparison.OrdinalIgnoreCase, - "Strings", "PSXStrings", "PCStrings", "ExtraNG")) - return true; - } - - return false; - } + => GetClassicScriptFileKind(filePath) == ClassicScriptFileKind.Strings; public static bool IsClassicScriptFile(string filePath) - { - string[] lines = File.ReadAllLines(filePath); - - foreach (string line in lines) - if (LineParser.IsSectionHeaderLine(line)) - { - string text = LineParser.GetSectionHeaderText(line); - - if (StringHelper.BulkStringComparision(text, StringComparison.OrdinalIgnoreCase, - "PSXExtensions", "PCExtensions", "Language", "Options", "Title", "Level")) - return true; - } - - return false; - } + => GetClassicScriptFileKind(filePath) == ClassicScriptFileKind.Script; public static bool IsLuaFile(string filePath) => Path.GetExtension(filePath).Equals(SupportedFormats.Lua, StringComparison.OrdinalIgnoreCase); diff --git a/TombIDE/TombIDE.ScriptingStudio/Lua/LuaDocumentCommandHandler.cs b/TombIDE/TombIDE.ScriptingStudio/Lua/LuaDocumentCommandHandler.cs new file mode 100644 index 0000000000..f2da86aabe --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/Lua/LuaDocumentCommandHandler.cs @@ -0,0 +1,81 @@ +using System; +using System.Threading.Tasks; +using TombIDE.ScriptingStudio.CommandSurface; +using TombIDE.ScriptingStudio.UI; +using TombLib.Scripting.Lua; + +namespace TombIDE.ScriptingStudio.Lua; + +public sealed class LuaDocumentCommandHandler : IStudioDocumentCommandHandler +{ + private readonly LuaDocumentCommandCallbacks _callbacks; + + public LuaDocumentCommandHandler(LuaDocumentCommandCallbacks callbacks) + { + _callbacks = callbacks ?? throw new ArgumentNullException(nameof(callbacks)); + } + + public bool TryHandle(UICommand command) + { + if (command == UICommand.NavigateBack) + { + _callbacks.NavigateBack(); + return true; + } + + if (command == UICommand.NavigateForward) + { + _callbacks.NavigateForward(); + return true; + } + + if (command == UICommand.FindReferences) + { + _ = _callbacks.FindReferencesAsync(); + return true; + } + + if (command == UICommand.RenameSymbol) + { + _ = _callbacks.RenameSymbolAsync(); + return true; + } + + if (command == UICommand.LuaBasics) + { + _callbacks.ShowLuaBasics(); + return true; + } + + if (_callbacks.GetCurrentEditor() is not LuaEditor editor) + return false; + + switch (command) + { + case UICommand.Reindent: + _ = _callbacks.ReindentAsync(); + return true; + + case UICommand.TrimWhiteSpace: + _ = _callbacks.TrimWhitespaceAsync(); + return true; + + case UICommand.GoToDefinition: + _callbacks.GoToDefinition(editor); + return true; + } + + return false; + } +} + +public sealed record LuaDocumentCommandCallbacks( + Func GetCurrentEditor, + Func ReindentAsync, + Func TrimWhitespaceAsync, + Action NavigateBack, + Action NavigateForward, + Action GoToDefinition, + Func FindReferencesAsync, + Func RenameSymbolAsync, + Action ShowLuaBasics); \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/Lua/LuaDocumentCommandStatusProvider.cs b/TombIDE/TombIDE.ScriptingStudio/Lua/LuaDocumentCommandStatusProvider.cs new file mode 100644 index 0000000000..f932f186f6 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/Lua/LuaDocumentCommandStatusProvider.cs @@ -0,0 +1,56 @@ +using System; +using TombIDE.ScriptingStudio.CommandSurface; +using TombIDE.ScriptingStudio.TextEditing; +using TombIDE.ScriptingStudio.UI; +using TombLib.Scripting.Editing; +using TombLib.Scripting.Lua; +using TombLib.Scripting.UI.Bases; +using TombLib.Scripting.UI.Editors; + +namespace TombIDE.ScriptingStudio.Lua; + +internal sealed class LuaDocumentCommandStatusProvider : IStudioDocumentCommandStatusProvider +{ + private readonly ITextFormattingProvider _formattingProvider; + private readonly LuaReferenceSearchService _referenceSearchService; + private readonly TextWorkspaceCommandService _workspaceCommandService; + + public LuaDocumentCommandStatusProvider( + ITextFormattingProvider formattingProvider, + LuaReferenceSearchService referenceSearchService, + TextWorkspaceCommandService workspaceCommandService) + { + _formattingProvider = formattingProvider ?? throw new ArgumentNullException(nameof(formattingProvider)); + _referenceSearchService = referenceSearchService ?? throw new ArgumentNullException(nameof(referenceSearchService)); + _workspaceCommandService = workspaceCommandService ?? throw new ArgumentNullException(nameof(workspaceCommandService)); + } + + public bool TryGetEnabled(IEditorControl editor, UICommand command, out bool isEnabled) + { + bool hasTextEditor = editor is TextEditorBase; + bool hasLuaEditor = editor is LuaEditor; + + switch (command) + { + case UICommand.GoToDefinition: + isEnabled = hasLuaEditor; + return true; + + case UICommand.FindReferences: + isEnabled = hasLuaEditor && _referenceSearchService.SupportsReferences; + return true; + + case UICommand.RenameSymbol: + isEnabled = hasLuaEditor && _workspaceCommandService.SupportsRename; + return true; + + case UICommand.Reindent: + isEnabled = hasLuaEditor ? _formattingProvider.SupportsFormatting : hasTextEditor; + return true; + + default: + isEnabled = false; + return false; + } + } +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/Lua/LuaDocumentLifecycleCoordinator.cs b/TombIDE/TombIDE.ScriptingStudio/Lua/LuaDocumentLifecycleCoordinator.cs new file mode 100644 index 0000000000..ffda1166b7 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/Lua/LuaDocumentLifecycleCoordinator.cs @@ -0,0 +1,131 @@ +#nullable enable + +using System; +using System.Windows.Forms; +using TombIDE.ScriptingStudio.Controls; +using TombLib.Scripting.Lua; +using TombLib.Scripting.Navigation; + +namespace TombIDE.ScriptingStudio.Lua; + +internal sealed class LuaDocumentLifecycleCoordinator : IDisposable +{ + private readonly EditorTabControl _editorTabControl; + private readonly ILuaIntellisenseProvider _intellisenseProvider; + private readonly LuaTrackedDocumentStateService _trackedDocumentStateService; + private readonly Action _selectedIndexChanged; + private readonly EventHandler _statusChanged; + private readonly EventHandler _textChanged; + private readonly Action _definitionNavigationRequested; + private readonly Action _editorOpened; + private readonly Action _currentEditorRenamed; + + public LuaDocumentLifecycleCoordinator( + EditorTabControl editorTabControl, + ILuaIntellisenseProvider intellisenseProvider, + LuaTrackedDocumentStateService trackedDocumentStateService, + Action selectedIndexChanged, + EventHandler statusChanged, + EventHandler textChanged, + Action definitionNavigationRequested, + Action editorOpened, + Action currentEditorRenamed) + { + _editorTabControl = editorTabControl ?? throw new ArgumentNullException(nameof(editorTabControl)); + _intellisenseProvider = intellisenseProvider ?? throw new ArgumentNullException(nameof(intellisenseProvider)); + _trackedDocumentStateService = trackedDocumentStateService ?? throw new ArgumentNullException(nameof(trackedDocumentStateService)); + _selectedIndexChanged = selectedIndexChanged ?? throw new ArgumentNullException(nameof(selectedIndexChanged)); + _statusChanged = statusChanged ?? throw new ArgumentNullException(nameof(statusChanged)); + _textChanged = textChanged ?? throw new ArgumentNullException(nameof(textChanged)); + _definitionNavigationRequested = definitionNavigationRequested ?? throw new ArgumentNullException(nameof(definitionNavigationRequested)); + _editorOpened = editorOpened ?? throw new ArgumentNullException(nameof(editorOpened)); + _currentEditorRenamed = currentEditorRenamed ?? throw new ArgumentNullException(nameof(currentEditorRenamed)); + } + + public void Attach() + { + Detach(); + + _editorTabControl.FileOpened += EditorTabControl_FileOpened; + _editorTabControl.SelectedIndexChanged += EditorTabControl_SelectedIndexChanged; + _editorTabControl.DocumentRenamed += EditorTabControl_DocumentRenamed; + } + + public void Detach() + { + _editorTabControl.FileOpened -= EditorTabControl_FileOpened; + _editorTabControl.SelectedIndexChanged -= EditorTabControl_SelectedIndexChanged; + _editorTabControl.DocumentRenamed -= EditorTabControl_DocumentRenamed; + + foreach (TabPage tabPage in _editorTabControl.TabPages) + { + if (_editorTabControl.GetEditorOfTab(tabPage) is LuaEditor editor) + DetachEditor(editor); + } + } + + public void Dispose() + => Detach(); + + private void EditorTabControl_FileOpened(object? sender, EventArgs e) + { + if (sender is not LuaEditor editor) + return; + + AttachEditor(editor); + _trackedDocumentStateService.OpenDocument(editor); + _editorOpened(); + } + + private void EditorTabControl_SelectedIndexChanged(object? sender, EventArgs e) + => _selectedIndexChanged(); + + 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; + + _trackedDocumentStateService.RenameDocument(e.OldFilePath, e.NewFilePath, editor); + + if (ReferenceEquals(_editorTabControl.CurrentEditor, editor)) + _currentEditorRenamed(); + } + + private void Editor_TextChangedDelayed(object? sender, EventArgs e) + { + if (sender is LuaEditor editor) + _trackedDocumentStateService.UpdateDocument(editor); + } + + private void AttachEditor(LuaEditor editor) + { + editor.IntellisenseProvider = _intellisenseProvider; + editor.DefinitionNavigationRequested -= _definitionNavigationRequested; + editor.DefinitionNavigationRequested += _definitionNavigationRequested; + editor.StatusChanged -= _statusChanged; + editor.StatusChanged += _statusChanged; + editor.TextChanged -= _textChanged; + editor.TextChanged += _textChanged; + editor.TextChangedDelayed -= Editor_TextChangedDelayed; + editor.TextChangedDelayed += Editor_TextChangedDelayed; + } + + private void DetachEditor(LuaEditor editor) + { + editor.DefinitionNavigationRequested -= _definitionNavigationRequested; + editor.StatusChanged -= _statusChanged; + editor.TextChanged -= _textChanged; + editor.TextChangedDelayed -= Editor_TextChangedDelayed; + } +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/Lua/LuaIntellisenseEventBridge.cs b/TombIDE/TombIDE.ScriptingStudio/Lua/LuaIntellisenseEventBridge.cs new file mode 100644 index 0000000000..bf8fd13236 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/Lua/LuaIntellisenseEventBridge.cs @@ -0,0 +1,95 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Windows.Forms; +using TombLib.LanguageServer.Core; +using TombLib.LanguageServer.Lua; +using TombLib.Scripting.Diagnostics; +using TombLib.Scripting.Lua; + +namespace TombIDE.ScriptingStudio.Lua; + +internal sealed class LuaIntellisenseEventBridge : IDisposable +{ + private readonly Control _owner; + private readonly ILuaIntellisenseProvider _intellisenseProvider; + private readonly Action> _diagnosticsUpdated; + private readonly Action> _semanticTokensUpdated; + private readonly Action _startupFailed; + private readonly Action _workspaceWatcherFailed; + + public LuaIntellisenseEventBridge( + Control owner, + ILuaIntellisenseProvider intellisenseProvider, + Action> diagnosticsUpdated, + Action> semanticTokensUpdated, + Action startupFailed, + Action workspaceWatcherFailed) + { + _owner = owner ?? throw new ArgumentNullException(nameof(owner)); + _intellisenseProvider = intellisenseProvider ?? throw new ArgumentNullException(nameof(intellisenseProvider)); + _diagnosticsUpdated = diagnosticsUpdated ?? throw new ArgumentNullException(nameof(diagnosticsUpdated)); + _semanticTokensUpdated = semanticTokensUpdated ?? throw new ArgumentNullException(nameof(semanticTokensUpdated)); + _startupFailed = startupFailed ?? throw new ArgumentNullException(nameof(startupFailed)); + _workspaceWatcherFailed = workspaceWatcherFailed ?? throw new ArgumentNullException(nameof(workspaceWatcherFailed)); + } + + public void Attach() + { + Detach(); + + _intellisenseProvider.DiagnosticsUpdated += IntellisenseProvider_DiagnosticsUpdated; + _intellisenseProvider.SemanticTokensUpdated += IntellisenseProvider_SemanticTokensUpdated; + + if (_intellisenseProvider is LuaLanguageServerIntellisenseProvider languageServerProvider) + { + languageServerProvider.StartupFailed += IntellisenseProvider_StartupFailed; + languageServerProvider.WorkspaceWatcherFailed += IntellisenseProvider_WorkspaceWatcherFailed; + } + } + + public void Detach() + { + _intellisenseProvider.DiagnosticsUpdated -= IntellisenseProvider_DiagnosticsUpdated; + _intellisenseProvider.SemanticTokensUpdated -= IntellisenseProvider_SemanticTokensUpdated; + + if (_intellisenseProvider is LuaLanguageServerIntellisenseProvider languageServerProvider) + { + languageServerProvider.StartupFailed -= IntellisenseProvider_StartupFailed; + languageServerProvider.WorkspaceWatcherFailed -= IntellisenseProvider_WorkspaceWatcherFailed; + } + } + + public void Dispose() + { + Detach(); + _intellisenseProvider.Dispose(); + } + + private void IntellisenseProvider_DiagnosticsUpdated(string filePath, IReadOnlyList diagnostics) + => DispatchToUi(() => _diagnosticsUpdated(filePath, diagnostics)); + + private void IntellisenseProvider_SemanticTokensUpdated(string filePath, IReadOnlyList semanticTokens) + => DispatchToUi(() => _semanticTokensUpdated(filePath, semanticTokens)); + + private void IntellisenseProvider_StartupFailed(LanguageServerStartupFailure failure) + => DispatchToUi(() => _startupFailed(failure)); + + private void IntellisenseProvider_WorkspaceWatcherFailed(WorkspaceWatcherFailure failure) + => DispatchToUi(() => _workspaceWatcherFailed(failure)); + + private void DispatchToUi(Action action) + { + if (_owner.IsDisposed) + return; + + if (_owner.InvokeRequired) + { + _owner.BeginInvoke(action); + return; + } + + action(); + } +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/Lua/LuaLanguageServerLocator.cs b/TombIDE/TombIDE.ScriptingStudio/Lua/LuaLanguageServerLocator.cs new file mode 100644 index 0000000000..029b115ef7 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/Lua/LuaLanguageServerLocator.cs @@ -0,0 +1,20 @@ +#nullable enable + +using System.IO; + +namespace TombIDE.ScriptingStudio.Lua; + +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; + } +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/Lua/LuaReferenceSearchService.cs b/TombIDE/TombIDE.ScriptingStudio/Lua/LuaReferenceSearchService.cs new file mode 100644 index 0000000000..9935dbf65e --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/Lua/LuaReferenceSearchService.cs @@ -0,0 +1,134 @@ +#nullable enable + +using ICSharpCode.AvalonEdit.Document; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using TombLib.Scripting.Editing; +using TombLib.Scripting.Lua; +using TombLib.Scripting.Navigation; +using TombLib.Scripting.UI.Bases; +using TombLib.Scripting.UI.Editors; +using TombLib.Scripting.UI.Presentation; + +namespace TombIDE.ScriptingStudio.Lua; + +internal sealed class LuaReferenceSearchService( + ITextEditorHost textEditorHost, + ITextReferencesProvider referencesProvider, + string scriptRootDirectoryPath) +{ + private readonly ITextEditorHost _textEditorHost = textEditorHost ?? throw new ArgumentNullException(nameof(textEditorHost)); + private readonly ITextReferencesProvider _referencesProvider = referencesProvider ?? throw new ArgumentNullException(nameof(referencesProvider)); + private readonly string _scriptRootDirectoryPath = scriptRootDirectoryPath ?? string.Empty; + + public bool SupportsReferences => _referencesProvider.SupportsReferences; + + public async Task> FindReferencesAsync(LuaEditor editor, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(editor); + + IReadOnlyList references = await _referencesProvider + .GetReferencesAsync( + new TextReferenceRequest( + editor.FilePath, + editor.Text, + Math.Max(0, editor.CurrentRow - 1), + Math.Max(0, editor.CurrentColumn - 1)), + cancellationToken) + .ConfigureAwait(true); + + return BuildReferenceGroups(references); + } + + 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.StartLineNumber) + .ThenBy(reference => reference.StartColumnNumber) + .Select(reference => new TextReferenceListItem( + reference.FilePath, + new TextDocumentRange( + reference.StartLineNumber, + reference.StartColumnNumber, + reference.EndLineNumber, + reference.EndColumnNumber), + reference.StartLineNumber, + reference.StartColumnNumber, + GetPreviewText(reference.FilePath, reference.StartLineNumber, lineCache))) + .ToArray(); + + groups.Add(new TextReferenceGroup(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) + { + TextEditorBase? textEditor = _textEditorHost.GetOpenEditors(filePath) + .OfType() + .FirstOrDefault(); + + if (textEditor is null) + 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); + } +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/Lua/LuaTrackedDocumentStateService.cs b/TombIDE/TombIDE.ScriptingStudio/Lua/LuaTrackedDocumentStateService.cs new file mode 100644 index 0000000000..825d20f346 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/Lua/LuaTrackedDocumentStateService.cs @@ -0,0 +1,79 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using TombLib.Scripting.Diagnostics; +using TombLib.Scripting.Lua; +using TombLib.Scripting.UI.Editors; + +namespace TombIDE.ScriptingStudio.Lua; + +internal sealed class LuaTrackedDocumentStateService(ITextEditorHost textEditorHost, ILuaIntellisenseProvider intellisenseProvider) +{ + private readonly ITextEditorHost _textEditorHost = textEditorHost ?? throw new ArgumentNullException(nameof(textEditorHost)); + private readonly ILuaIntellisenseProvider _intellisenseProvider = intellisenseProvider ?? throw new ArgumentNullException(nameof(intellisenseProvider)); + + public IReadOnlyList GetDiagnostics(string filePath) + => _intellisenseProvider.GetDiagnostics(filePath); + + public void OpenDocument(LuaEditor editor) + { + ArgumentNullException.ThrowIfNull(editor); + + _intellisenseProvider.OpenDocument(editor.FilePath, editor.Text); + ApplyTrackedState(editor); + } + + public void UpdateDocument(LuaEditor editor) + { + ArgumentNullException.ThrowIfNull(editor); + + _intellisenseProvider.UpdateDocument(editor.FilePath, editor.Text); + } + + public void RenameDocument(string oldFilePath, string newFilePath, LuaEditor editor) + { + ArgumentNullException.ThrowIfNull(editor); + + _intellisenseProvider.RenameDocument(oldFilePath, newFilePath, editor.Text); + } + + public void ApplyTrackedState(LuaEditor editor) + { + ArgumentNullException.ThrowIfNull(editor); + + ApplyDiagnosticsToEditor(editor, _intellisenseProvider.GetDiagnostics(editor.FilePath)); + ApplySemanticTokensToEditor(editor, _intellisenseProvider.GetSemanticTokens(editor.FilePath)); + } + + public void ApplyTrackedStateToEditors(string filePath) + { + IReadOnlyList diagnostics = _intellisenseProvider.GetDiagnostics(filePath); + IReadOnlyList semanticTokens = _intellisenseProvider.GetSemanticTokens(filePath); + + ApplyDiagnosticsUpdate(filePath, diagnostics); + ApplySemanticTokensUpdate(filePath, semanticTokens); + } + + public void ApplyDiagnosticsUpdate(string filePath, IReadOnlyList diagnostics) + { + foreach (LuaEditor editor in GetOpenLuaEditors(filePath)) + ApplyDiagnosticsToEditor(editor, diagnostics); + } + + public void ApplySemanticTokensUpdate(string filePath, IReadOnlyList semanticTokens) + { + foreach (LuaEditor editor in GetOpenLuaEditors(filePath)) + ApplySemanticTokensToEditor(editor, semanticTokens); + } + + private IEnumerable GetOpenLuaEditors(string filePath) + => _textEditorHost.GetOpenEditors(filePath).OfType(); + + private static void ApplyDiagnosticsToEditor(LuaEditor editor, IReadOnlyList diagnostics) + => editor.SetDiagnostics(editor.LiveErrorUnderlining ? diagnostics : []); + + private static void ApplySemanticTokensToEditor(LuaEditor editor, IReadOnlyList semanticTokens) + => editor.SetSemanticTokens(semanticTokens ?? []); +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/Lua/LuaWorkspaceAutomationProvider.cs b/TombIDE/TombIDE.ScriptingStudio/Lua/LuaWorkspaceAutomationProvider.cs new file mode 100644 index 0000000000..2f2200ca8a --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/Lua/LuaWorkspaceAutomationProvider.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Windows.Forms; +using TombIDE.ScriptingStudio.Services; +using TombIDE.ScriptingStudio.TextEditing; +using TombIDE.Shared; +using TombIDE.Shared.SharedClasses; +using TombLib.LevelData; +using TombLib.Scripting.Lua.Documents; + +namespace TombIDE.ScriptingStudio.Lua; + +internal sealed record LuaWorkspaceAutomationCallbacks( + Func AppendScript, + Func IsLevelScriptDefined, + Func IsLevelLanguageStringDefined, + Action RenameRequestedLanguageString, + Action DisposeIntellisense); + +internal sealed class LuaWorkspaceAutomationProvider : IStudioWorkspaceAutomationProvider +{ + private readonly LuaWorkspaceAutomationCallbacks _callbacks; + private readonly string _scriptRootDirectoryPath; + private readonly StudioSilentActionService _silentActionService; + + public LuaWorkspaceAutomationProvider( + StudioSilentActionService silentActionService, + string scriptRootDirectoryPath, + LuaWorkspaceAutomationCallbacks callbacks) + { + _silentActionService = silentActionService ?? throw new ArgumentNullException(nameof(silentActionService)); + _scriptRootDirectoryPath = scriptRootDirectoryPath ?? string.Empty; + _callbacks = callbacks ?? throw new ArgumentNullException(nameof(callbacks)); + } + + public void HandleIDEEvent(IIDEEvent ideEvent) + { + if (ideEvent is null) + return; + + if (ideEvent is IDE.ProgramClosingEvent) + { + _callbacks.DisposeIntellisense(); + return; + } + + if (!IsSilentAction(ideEvent)) + return; + + TabPage cachedTab = _silentActionService.RememberSelectedTab(); + string scriptFilePath = PathHelper.GetScriptFilePath(_scriptRootDirectoryPath, TRVersion.Game.TombEngine); + string languageFilePath = PathHelper.GetLanguageFilePath(_scriptRootDirectoryPath, TRVersion.Game.TombEngine); + + if (ideEvent is IDE.ScriptEditor_AppendScriptEvent appendEvent && appendEvent.Result.HasOutput) + { + SilentActionFileState scriptFileState = _silentActionService.CaptureFileState(scriptFilePath); + SilentActionFileState languageFileState = _silentActionService.CaptureFileState(languageFilePath); + (bool scriptUpdated, bool languageUpdated) = _callbacks.AppendScript(appendEvent.Result); + var completions = new List(); + + if (scriptUpdated) + completions.Add(_silentActionService.CreateCompletion(scriptFileState)); + + if (languageUpdated) + completions.Add(_silentActionService.CreateCompletion(languageFileState)); + + _silentActionService.Complete(cachedTab, scriptUpdated || languageUpdated, completions.ToArray()); + } + else if (ideEvent is IDE.ScriptEditor_ScriptPresenceCheckEvent scriptPresenceEvent) + { + IDE.Instance.ScriptDefined = _callbacks.IsLevelScriptDefined(scriptPresenceEvent.LevelName); + } + else if (ideEvent is IDE.ScriptEditor_StringPresenceCheckEvent stringPresenceEvent) + { + SilentActionFileState languageFileState = _silentActionService.CaptureFileState(languageFilePath); + IDE.Instance.StringDefined = _callbacks.IsLevelLanguageStringDefined(stringPresenceEvent.String); + _silentActionService.Complete(cachedTab, false, _silentActionService.CreateCompletion(languageFileState, saveAffectedFile: false)); + } + else if (ideEvent is IDE.ScriptEditor_RenameLevelEvent renameLevelEvent) + { + SilentActionFileState languageFileState = _silentActionService.CaptureFileState(languageFilePath); + _callbacks.RenameRequestedLanguageString(renameLevelEvent.OldName, renameLevelEvent.NewName); + _silentActionService.Complete(cachedTab, true, _silentActionService.CreateCompletion(languageFileState)); + } + } + + public void Build() + { + } + + public void ShowDocumentation() + { + } + + private static bool IsSilentAction(IIDEEvent ideEvent) + => ideEvent is IDE.ScriptEditor_AppendScriptEvent + || ideEvent is IDE.ScriptEditor_ScriptPresenceCheckEvent + || ideEvent is IDE.ScriptEditor_StringPresenceCheckEvent + || ideEvent is IDE.ScriptEditor_RenameLevelEvent; +} \ 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..8b80f5c078 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/LuaStudio.Diagnostics.cs @@ -0,0 +1,82 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using TombIDE.ScriptingStudio.Navigation; +using TombIDE.ScriptingStudio.ToolWindows; +using TombIDE.ScriptingStudio.UI; +using TombLib.Scripting.Diagnostics; +using TombLib.Scripting.Lua; +using TombLib.Scripting.UI.Presentation; + +namespace TombIDE.ScriptingStudio; + +public sealed partial class LuaStudio +{ + private TextDiagnosticsToolWindow LuaDiagnostics + => GetPaneContent(UICommand.LuaDiagnostics); + + private TextDiagnosticsToolWindow CreateLuaDiagnosticsToolWindow() + => new TextDiagnosticsToolWindow( + Shared.Strings.Default.LuaDiagnostics, + nameof(LuaDiagnostics), + new TextDiagnosticsPresentation( + Shared.Strings.Default.LuaDiagnosticsNoDocument, + Shared.Strings.Default.LuaDiagnosticsUpdating, + Shared.Strings.Default.NoDiagnostics, + Shared.Strings.Default.Errors, + Shared.Strings.Default.Warnings, + Shared.Strings.Default.Messages, + Shared.Strings.Default.Severity, + Shared.Strings.Default.LineHeader, + Shared.Strings.Default.ColumnHeader, + Shared.Strings.Default.Message), + NavigateToDiagnostic); + + private void EditorTabControl_LuaSelectedIndexChanged(object? sender, EventArgs e) + { + RefreshLuaDiagnosticsView(); + UpdateDocumentCommandStates(); + } + + 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 ?? _trackedDocumentStateService.GetDiagnostics(editor.FilePath)); + } + + private void NavigateToDiagnostic(TextDiagnosticListItem 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..f0060735fe --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/LuaStudio.Formatting.cs @@ -0,0 +1,65 @@ +#nullable enable + +using DarkUI.Forms; +using System; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Forms; +using TombIDE.ScriptingStudio.TextEditing; +using TombIDE.Shared; +using TombLib.Scripting.Editing; +using TombLib.Scripting.Lua; +using TombLib.Scripting.UI.Editing; + +namespace TombIDE.ScriptingStudio; + +public sealed partial class LuaStudio +{ + private readonly TextWorkspaceCommandService _workspaceCommandService; + + private Task ReformatDocumentAsync() + => FormatDocumentAsync(_intellisenseProvider, Strings.Default.LuaReformatUnsupported, Strings.Default.Reindent); + + private Task TrimWhitespaceAsync() + => FormatDocumentAsync(_trimWhitespaceProvider, unsupportedMessage: null, Strings.Default.TrimWhitespace); + + private async Task FormatDocumentAsync(ITextFormattingProvider formattingProvider, string? unsupportedMessage, string commandName) + { + if (CurrentEditor is not LuaEditor editor) + return; + + if (!formattingProvider.SupportsFormatting) + { + if (!string.IsNullOrWhiteSpace(unsupportedMessage)) + { + DarkMessageBox.Show(this, + unsupportedMessage, + commandName, + MessageBoxButtons.OK, + MessageBoxIcon.Information); + } + + return; + } + + try + { + TextWorkspaceCommandResult result = await _workspaceCommandService + .FormatDocumentAsync(editor, formattingProvider) + .ConfigureAwait(true); + + if (result.Status is TextWorkspaceCommandStatus.Cancelled) + return; + + if (result.Transaction is not TextWorkspaceEditTransaction transaction || !transaction.HasChanges) + return; + + PushWorkspaceEditTransaction(transaction); + HandleWorkspaceDocumentsChanged(transaction.DocumentChanges.Select(documentChange => documentChange.FilePath)); + } + catch (Exception ex) + { + DarkMessageBox.Show(this, ex.Message, commandName, MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } +} \ 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..82529bdd60 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/LuaStudio.Intellisense.cs @@ -0,0 +1,138 @@ +#nullable enable + +using NLog; +using System; +using System.Collections.Generic; +using System.IO; +using System.Windows.Forms; +using TombIDE.ScriptingStudio.Helpers; +using TombIDE.ScriptingStudio.Lua; +using TombIDE.ScriptingStudio.Navigation; +using TombLib.LanguageServer.Core; +using TombLib.LanguageServer.Lua; +using TombLib.Scripting.Diagnostics; +using TombLib.Scripting.Lua; +using TombLib.Scripting.Navigation; +using LuaLanguageServerLocator = TombIDE.ScriptingStudio.Lua.LuaLanguageServerLocator; + +namespace TombIDE.ScriptingStudio; + +public sealed partial class LuaStudio +{ + private readonly LuaDocumentLifecycleCoordinator _documentLifecycleCoordinator; + private static readonly Logger Log = LogManager.GetCurrentClassLogger(); + + private readonly LuaIntellisenseEventBridge _intellisenseEventBridge; + private readonly ILuaIntellisenseProvider _intellisenseProvider; + private readonly LuaTrackedDocumentStateService _trackedDocumentStateService; + + private void HookLuaIntellisense() + { + _documentLifecycleCoordinator.Attach(); + _intellisenseEventBridge.Attach(); + + UpdateDocumentCommandStates(); + } + + private void DisposeLuaIntellisense() + { + CancelPendingReferenceRequest(); + + _documentLifecycleCoordinator.Dispose(); + _intellisenseEventBridge.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 LuaEditorOpened() + { + RefreshLuaDiagnosticsView(); + UpdateDocumentCommandStates(); + } + + private void CurrentLuaEditorRenamed() + => RefreshLuaDiagnosticsView(); + + private void IntellisenseProvider_DiagnosticsUpdated(string filePath, IReadOnlyList diagnostics) + { + _trackedDocumentStateService.ApplyDiagnosticsUpdate(filePath, diagnostics); + + if (CurrentEditor is LuaEditor currentEditor + && string.Equals(currentEditor.FilePath, filePath, StringComparison.OrdinalIgnoreCase)) + { + RefreshLuaDiagnosticsView(diagnostics: diagnostics); + } + + UpdateDocumentCommandStates(); + } + + private void IntellisenseProvider_SemanticTokensUpdated(string filePath, IReadOnlyList semanticTokens) + { + _trackedDocumentStateService.ApplySemanticTokensUpdate(filePath, semanticTokens); + + UpdateDocumentCommandStates(); + } + + private void IntellisenseProvider_StartupFailed(LanguageServerStartupFailure failure) + { + if (IsDisposed) + return; + + MessageBox.Show(this, + failure.Message, + failure.IsPersistent ? "Lua IntelliSense disabled" : "Lua IntelliSense unavailable", + MessageBoxButtons.OK, + MessageBoxIcon.Warning); + } + + private void IntellisenseProvider_WorkspaceWatcherFailed(WorkspaceWatcherFailure failure) + { + if (IsDisposed) + return; + + MessageBox.Show(this, + failure.Message, + "Lua workspace watching disabled", + MessageBoxButtons.OK, + MessageBoxIcon.Warning); + } + + private void NavigateToDefinition(TextDefinitionLocation 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 void ApplyTrackedDocumentStateToEditors(string filePath) + => _trackedDocumentStateService.ApplyTrackedStateToEditors(filePath); +} diff --git a/TombIDE/TombIDE.ScriptingStudio/LuaStudio.Navigation.cs b/TombIDE/TombIDE.ScriptingStudio/LuaStudio.Navigation.cs new file mode 100644 index 0000000000..9719f915a3 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/LuaStudio.Navigation.cs @@ -0,0 +1,112 @@ +#nullable enable + +using System; +using System.IO; +using TombIDE.ScriptingStudio.FindAndReplace; +using TombIDE.ScriptingStudio.Navigation; +using TombLib.Scripting.Lua; +using TombLib.Scripting.UI.Bases; + +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..8b3e3fc9fa --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/LuaStudio.References.cs @@ -0,0 +1,108 @@ +#nullable enable + +using DarkUI.Docking; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Forms; +using TombIDE.ScriptingStudio.Helpers; +using TombIDE.ScriptingStudio.Lua; +using TombIDE.ScriptingStudio.Navigation; +using TombIDE.ScriptingStudio.TextEditing; +using TombIDE.ScriptingStudio.ToolStrips; +using TombIDE.ScriptingStudio.ToolWindows; +using TombIDE.ScriptingStudio.UI; +using TombLib.Scripting.Lua; +using TombLib.Scripting.UI.Bases; +using TombLib.Scripting.UI.Presentation; + +namespace TombIDE.ScriptingStudio; + +public sealed partial class LuaStudio +{ + private TextReferencesResultsToolWindow LuaReferencesResults + => GetPaneContent(UICommand.LuaReferencesResults); + + private readonly LuaReferenceSearchService _referenceSearchService; + private CancellationTokenSource? _referencesCancellationTokenSource; + private int _referencesRequestToken; + + private TextReferencesResultsToolWindow CreateLuaReferencesResultsToolWindow() + => new TextReferencesResultsToolWindow( + Shared.Strings.Default.LuaReferencesResults, + nameof(LuaReferencesResults), + new TextReferencesPresentation( + Shared.Strings.Default.LuaReferencesNoDocument, + Shared.Strings.Default.LuaReferencesUnsupported, + Shared.Strings.Default.LuaReferencesLoading, + Shared.Strings.Default.NoReferencesFound), + NavigateToReference); + + private async Task FindReferencesAsync() + { + ShowLuaReferencesResults(); + + if (CurrentEditor is not LuaEditor editor) + { + LuaReferencesResults.ShowNoActiveDocument(); + return; + } + + if (!_referenceSearchService.SupportsReferences) + { + LuaReferencesResults.ShowUnsupported(); + return; + } + + CancellationToken cancellationToken = ResetReferenceRequestCancellation(); + int requestToken = ++_referencesRequestToken; + LuaReferencesResults.ShowLoading(); + + try + { + IReadOnlyList referenceGroups = await _referenceSearchService + .FindReferencesAsync(editor, cancellationToken) + .ConfigureAwait(true); + + if (cancellationToken.IsCancellationRequested || requestToken != _referencesRequestToken) + return; + + if (referenceGroups.Count == 0 && !_referenceSearchService.SupportsReferences) + { + LuaReferencesResults.ShowUnsupported(); + UpdateDocumentCommandStates(); + return; + } + + LuaReferencesResults.ShowReferences(referenceGroups); + } + catch (OperationCanceledException) + { + // Ignore stale reference requests. + } + } + + private void CancelPendingReferenceRequest() + { + _referencesCancellationTokenSource?.Cancel(); + _referencesCancellationTokenSource?.Dispose(); + _referencesCancellationTokenSource = null; + } + + private void ShowLuaReferencesResults() + => ShowPane(UICommand.LuaReferencesResults); + + private void NavigateToReference(TextReferenceListItem reference) + => NavigateToLocation( + reference.FilePath, + NavigationOrigin.References, + textEditor => EditorNavigationHelper.CreateRangeLocation(textEditor, reference.FilePath, reference.Range)); + + 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..ff7a6fd529 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/LuaStudio.Rename.cs @@ -0,0 +1,180 @@ +#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.TextEditing; +using TombIDE.Shared; +using TombLib.Forms.ViewModels; +using TombLib.Forms.Views; +using TombLib.Scripting.Lua; +using TombLib.Scripting.UI.Editing; + +namespace TombIDE.ScriptingStudio; + +public sealed partial class LuaStudio +{ + private readonly TextWorkspaceEditApplier _workspaceEditApplier; + + private async Task RenameSymbolAsync() + { + if (CurrentEditor is not LuaEditor editor) + { + ShowRenameInfo(Strings.Default.LuaRenameNoDocument, MessageBoxIcon.Information); + return; + } + + if (!_workspaceCommandService.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 + { + TextWorkspaceCommandResult result = await _workspaceCommandService + .RenameSymbolAsync( + editor, + Math.Max(0, location.Line - 1), + Math.Max(0, location.Column - 1), + newName) + .ConfigureAwait(true); + + if (result.Status is TextWorkspaceCommandStatus.Cancelled) + return; + + if (result.Transaction is not TextWorkspaceEditTransaction transaction || !transaction.HasChanges) + { + ShowRenameInfo(Strings.Default.LuaRenameNoChanges, MessageBoxIcon.Information); + return; + } + + PushWorkspaceEditTransaction(transaction); + HandleWorkspaceDocumentsChanged(transaction.DocumentChanges.Select(documentChange => documentChange.FilePath)); + } + 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..e54fcaa335 --- /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.Shared; +using TombLib.Scripting.Lua; +using TombLib.Scripting.UI.Editing; + +namespace TombIDE.ScriptingStudio; + +public sealed partial class LuaStudio +{ + private readonly TextWorkspaceEditHistoryService _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(TextWorkspaceEditTransaction 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) + _trackedDocumentStateService.UpdateDocument(updatedEditor); + } + + 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..fe2c4fe1a3 100644 --- a/TombIDE/TombIDE.ScriptingStudio/LuaStudio.cs +++ b/TombIDE/TombIDE.ScriptingStudio/LuaStudio.cs @@ -1,4 +1,6 @@ -using System; +using DarkUI.Docking; +using ICSharpCode.AvalonEdit.Document; +using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; @@ -7,155 +9,149 @@ using System.Windows.Forms; using TombIDE.ScriptingStudio.Bases; using TombIDE.ScriptingStudio.Controls; +using TombIDE.ScriptingStudio.Lua; +using TombIDE.ScriptingStudio.Shell; +using TombIDE.ScriptingStudio.TextEditing; +using TombIDE.ScriptingStudio.WorkspaceProfile; using TombIDE.ScriptingStudio.ToolWindows; using TombIDE.ScriptingStudio.UI; -using TombIDE.Shared; using TombIDE.Shared.SharedClasses; -using TombLib.Scripting.Bases; -using TombLib.Scripting.Enums; -using TombLib.Scripting.Interfaces; -using TombLib.Scripting.Lua.Services; +using TombLib.Scripting.Editing; +using TombLib.Scripting.Lua; +using TombLib.Scripting.Lua.Documents; +using TombLib.Scripting.UI.Cleaning; +using TombLib.Scripting.UI.Bases; +using TombLib.Scripting.UI.Editing; +using TombLib.Scripting.UI.Editors; namespace TombIDE.ScriptingStudio { - public sealed class LuaStudio : StudioBase + public sealed partial class LuaStudio : TombIDE.ScriptingStudio.Bases.ScriptingStudio { - public override StudioMode StudioMode => StudioMode.Lua; - #region Fields + private readonly TombEngineLevelScriptService _levelScriptService = new(); private readonly TombEngineLanguageScriptService _languageScriptService = new(); + private readonly EditorTabControlTextEditorHost _textEditorHost; + private readonly ITextFormattingProvider _trimWhitespaceProvider = new TextDocumentFormatterProvider(TrimTrailingWhitespaceFormatter.Instance); #endregion Fields #region Construction - public LuaStudio() : base(IDE.Instance.Project.GetScriptRootDirectory(), IDE.Instance.Project.GetEngineRootDirectoryPath()) + public LuaStudio(ScriptingWorkspaceProfile workspaceProfile) : base() { - DockPanelState = IDE.Instance.IDEConfiguration.Lua_DockPanelState; - - FileExplorer.ExcludedDirectoryFilter = "Scripts\\Engine"; - FileExplorer.Filter = "*.lua"; - FileExplorer.CommentPrefix = "--"; - - EditorTabControl.CheckPreviousSession(); - - string initialFilePath = PathHelper.GetScriptFilePath(IDE.Instance.Project.GetScriptRootDirectory(), TombLib.LevelData.TRVersion.Game.TombEngine); - - if (!string.IsNullOrWhiteSpace(initialFilePath)) - EditorTabControl.OpenFile(initialFilePath); + ArgumentNullException.ThrowIfNull(workspaceProfile); + var paneContributionProvider = new StaticStudioPaneContributionProvider( + new[] + { + new StudioPaneContribution(UICommand.LuaDiagnostics, nameof(LuaDiagnostics), CreateLuaDiagnosticsToolWindow), + new StudioPaneContribution(UICommand.LuaReferencesResults, nameof(LuaReferencesResults), CreateLuaReferencesResultsToolWindow) + }); + _textEditorHost = new EditorTabControlTextEditorHost(EditorTabControl); + var silentActionService = new StudioSilentActionService(EditorTabControl); + + _intellisenseProvider = CreateLuaIntellisenseProvider(); + _intellisenseEventBridge = new LuaIntellisenseEventBridge( + this, + _intellisenseProvider, + IntellisenseProvider_DiagnosticsUpdated, + IntellisenseProvider_SemanticTokensUpdated, + IntellisenseProvider_StartupFailed, + IntellisenseProvider_WorkspaceWatcherFailed); + _referenceSearchService = new LuaReferenceSearchService(_textEditorHost, _intellisenseProvider, ScriptRootDirectoryPath); + _workspaceEditApplier = new TextWorkspaceEditApplier(_textEditorHost); + _trackedDocumentStateService = new LuaTrackedDocumentStateService(_textEditorHost, _intellisenseProvider); + _documentLifecycleCoordinator = new LuaDocumentLifecycleCoordinator( + EditorTabControl, + _intellisenseProvider, + _trackedDocumentStateService, + () => EditorTabControl_LuaSelectedIndexChanged(this, EventArgs.Empty), + LuaEditor_StatusChanged, + LuaEditor_TextChanged, + NavigateToDefinition, + LuaEditorOpened, + CurrentLuaEditorRenamed); + _workspaceCommandService = new TextWorkspaceCommandService(_workspaceEditApplier, _intellisenseProvider); + var documentCommandHandler = new LuaDocumentCommandHandler( + new LuaDocumentCommandCallbacks( + () => CurrentEditor as LuaEditor, + ReformatDocumentAsync, + TrimWhitespaceAsync, + NavigateBack, + NavigateForward, + editor => _ = editor.NavigateToDefinitionAtCaretAsync(), + FindReferencesAsync, + RenameSymbolAsync, + ShowLuaBasicsDocumentation)); + var documentCommandStatusProvider = new LuaDocumentCommandStatusProvider(_intellisenseProvider, _referenceSearchService, _workspaceCommandService); + _workspaceEditHistory = new TextWorkspaceEditHistoryService(_workspaceEditApplier); + var workspaceAutomationProvider = new LuaWorkspaceAutomationProvider( + silentActionService, + ScriptRootDirectoryPath, + new LuaWorkspaceAutomationCallbacks( + AppendScript, + IsLevelScriptDefined, + IsLevelLanguageStringDefined, + RenameRequestedLanguageString, + DisposeLuaIntellisense)); + HookLuaIntellisense(); + InitializeHost( + workspaceProfile, + (editor, configs) => editor.UpdateSettings(configs.Lua), + () => ApplyUserSettingsToOpenEditors(afterApply: editor => + { + if (editor is LuaEditor luaEditor) + _trackedDocumentStateService.ApplyTrackedState(luaEditor); + }), + documentCommandStatusProvider: documentCommandStatusProvider, + documentCommandHandler: documentCommandHandler, + paneContributionProvider: paneContributionProvider, + workspaceAutomationProvider: workspaceAutomationProvider, + dockPanelLayoutRestored: EnsureLuaToolWindowsInDockPanel); } #endregion Construction - #region IDE Events - - protected override void OnIDEEventRaised(IIDEEvent obj) + private (bool ScriptUpdated, bool LanguageUpdated) AppendScript(ScriptGenerationResult result) { - base.OnIDEEventRaised(obj); - - IDEEvent_HandleSilentActions(obj); - - if (obj is IDE.ProgramClosingEvent) - { - IDE.Instance.IDEConfiguration.Lua_DockPanelState = DockPanel.GetDockPanelState(); - IDE.Instance.IDEConfiguration.Save(); - } - } - - private bool IsSilentAction(IIDEEvent obj) - => obj is IDE.ScriptEditor_AppendScriptEvent - || obj is IDE.ScriptEditor_ScriptPresenceCheckEvent - || obj is IDE.ScriptEditor_StringPresenceCheckEvent - || obj is IDE.ScriptEditor_RenameLevelEvent; - - private void IDEEvent_HandleSilentActions(IIDEEvent obj) - { - if (IsSilentAction(obj)) - { - TabPage cachedTab = EditorTabControl.SelectedTab; - - TabPage scriptFileTab = EditorTabControl.FindTabPage(PathHelper.GetScriptFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TombEngine)); - bool wasScriptFileAlreadyOpened = scriptFileTab is not null; - bool wasScriptFileFileChanged = wasScriptFileAlreadyOpened && EditorTabControl.GetEditorOfTab(scriptFileTab).IsContentChanged; - - TabPage languageFileTab = EditorTabControl.FindTabPage(PathHelper.GetLanguageFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TombEngine)); - bool wasLanguageFileAlreadyOpened = languageFileTab is not null; - bool wasLanguageFileFileChanged = wasLanguageFileAlreadyOpened && EditorTabControl.GetEditorOfTab(languageFileTab).IsContentChanged; + bool scriptUpdated = false; + bool languageUpdated = false; - if (obj is IDE.ScriptEditor_AppendScriptEvent asle && asle.Result.HasOutput) - { - AppendScript(asle.Result, - wasScriptFileAlreadyOpened, wasScriptFileFileChanged, - wasLanguageFileAlreadyOpened, wasLanguageFileFileChanged); - - EndSilentScriptAction(cachedTab, true, false, false); - } - else if (obj is IDE.ScriptEditor_ScriptPresenceCheckEvent scrpce) - { - IDE.Instance.ScriptDefined = true; // TEMP !!! - } - else if (obj is IDE.ScriptEditor_StringPresenceCheckEvent strpce) - { - IDE.Instance.StringDefined = IsLevelLanguageStringDefined(strpce.String); - EndSilentScriptAction(cachedTab, false, false, !wasLanguageFileAlreadyOpened); - } - else if (obj is IDE.ScriptEditor_RenameLevelEvent rle) - { - string oldName = rle.OldName; - string newName = rle.NewName; - - RenameRequestedLanguageString(oldName, newName); - - EndSilentScriptAction(cachedTab, true, !wasLanguageFileFileChanged, !wasLanguageFileAlreadyOpened); - } - } - } - - private void AppendScript(ScriptGenerationResult result, - bool wasScriptFileAlreadyOpened, bool wasScriptFileFileChanged, - bool wasLanguageFileAlreadyOpened, bool wasLanguageFileFileChanged) - { try { if (result.GameFlowScript.Length > 0) - AppendGameFlowScript(result.GameFlowScript, wasScriptFileAlreadyOpened, wasScriptFileFileChanged); + { + AppendGameFlowScript(result.GameFlowScript); + scriptUpdated = true; + } if (result.LanguageScript.Length > 0) - AppendLanguageScript(result.LanguageScript, wasLanguageFileAlreadyOpened, wasLanguageFileFileChanged); + { + AppendLanguageScript(result.LanguageScript); + languageUpdated = true; + } CreateGeneratedFiles(result.FilesToCreate); } - catch + catch (Exception exception) { - // Oh well... + Debug.WriteLine($"[LuaStudio] Failed to append generated Lua script output: {exception}"); } + + return (scriptUpdated, languageUpdated); } - private void AppendGameFlowScript(string scriptText, bool wasScriptFileAlreadyOpened, bool wasScriptFileFileChanged) + private void AppendGameFlowScript(string scriptText) { - EditorTabControl.OpenFile(PathHelper.GetScriptFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TombEngine)); - TabPage affectedTab = EditorTabControl.SelectedTab; - - if (CurrentEditor is not TextEditorBase editor) - return; - + TextEditorBase editor = _textEditorHost.OpenTextEditor(PathHelper.GetScriptFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TombEngine)); editor.AppendText(Environment.NewLine + scriptText + Environment.NewLine); editor.ScrollToLine(editor.LineCount); - - if (!wasScriptFileFileChanged && affectedTab is not null) - EditorTabControl.SaveFile(affectedTab); - - if (!wasScriptFileAlreadyOpened && affectedTab is not null) - EditorTabControl.TabPages.Remove(affectedTab); } - private void AppendLanguageScript(string languageScript, bool wasLanguageFileAlreadyOpened, bool wasLanguageFileFileChanged) + private void AppendLanguageScript(string languageScript) { - EditorTabControl.OpenFile(PathHelper.GetLanguageFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TombEngine), EditorType.Text); - TabPage affectedTab = EditorTabControl.SelectedTab; - - if (CurrentEditor is TextEditorBase stringsEditor) + if (_textEditorHost.OpenTextEditor(PathHelper.GetLanguageFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TombEngine)) is TextEditorBase stringsEditor) { int? insertedLineNumber = _languageScriptService.TryInsertLanguageScript(stringsEditor.Document, languageScript); @@ -163,14 +159,8 @@ private void AppendLanguageScript(string languageScript, bool wasLanguageFileAlr { stringsEditor.ResetSelectionAt(insertedLineNumber.Value); stringsEditor.ScrollToLine(insertedLineNumber.Value); - - if (!wasLanguageFileFileChanged && affectedTab is not null) - EditorTabControl.SaveFile(affectedTab); } } - - if (!wasLanguageFileAlreadyOpened && affectedTab is not null) - EditorTabControl.TabPages.Remove(affectedTab); } private void CreateGeneratedFiles(IReadOnlyList files) @@ -198,24 +188,27 @@ private void CreateGeneratedFiles(IReadOnlyList files) private bool IsLevelLanguageStringDefined(string levelName) { - EditorTabControl.OpenFile(PathHelper.GetLanguageFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TombEngine), EditorType.Text); + TextEditorBase editor = _textEditorHost.OpenTextEditor(PathHelper.GetLanguageFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TombEngine)); + var regex = new Regex($"\"{Regex.Escape(levelName)}\""); + var stringLine = editor.Document.Lines.FirstOrDefault(line => regex.IsMatch(editor.Document.GetText(line))); - if (CurrentEditor is TextEditorBase editor) - { - var regex = new Regex($"\"{Regex.Escape(levelName)}\""); - var stringLine = editor.Document.Lines.FirstOrDefault(line => regex.IsMatch(editor.Document.GetText(line))); + return stringLine is not null; + } - return stringLine is not null; - } + private bool IsLevelScriptDefined(string levelName) + { + TextDocument scriptDocument = _textEditorHost.TryGetTextDocument(PathHelper.GetScriptFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TombEngine)); + TextDocument languageDocument = _textEditorHost.TryGetTextDocument(PathHelper.GetLanguageFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TombEngine)); + + if (scriptDocument is null || languageDocument is null) + return false; - return false; + return _levelScriptService.IsLevelScriptDefined(scriptDocument, languageDocument, levelName); } private void RenameRequestedLanguageString(string oldName, string newName) { - EditorTabControl.OpenFile(PathHelper.GetLanguageFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TombEngine), EditorType.Text); - - if (CurrentEditor is TextEditorBase editor) + if (_textEditorHost.OpenTextEditor(PathHelper.GetLanguageFilePath(ScriptRootDirectoryPath, TombLib.LevelData.TRVersion.Game.TombEngine)) is TextEditorBase editor) { var regex = new Regex($"\"{Regex.Escape(oldName)}\""); var stringLine = editor.Document.Lines.FirstOrDefault(line => regex.IsMatch(editor.Document.GetText(line))); @@ -229,76 +222,47 @@ private void RenameRequestedLanguageString(string oldName, string newName) } } - protected override void RestoreDefaultLayout() - { - DockPanelState = DefaultLayouts.LuaLayout; - - DockPanel.RemoveContent(); - DockPanel.RestoreDockPanelState(DockPanelState, FindDockContentByKey); - } + #region Other methods - private void EndSilentScriptAction(TabPage previousTab, bool indicateChange, bool saveAffectedFile, bool closeAffectedTab) + private void EnsureLuaToolWindowsInDockPanel() { - if (indicateChange) - { - CurrentEditor.LastModified = DateTime.Now; - IDE.Instance.ScriptEditor_IndicateExternalChange(); - } - - if (saveAffectedFile) - EditorTabControl.SaveFile(EditorTabControl.SelectedTab); - - if (closeAffectedTab) - EditorTabControl.TabPages.Remove(EditorTabControl.SelectedTab); + if (DockPanel is null) + return; - EditorTabControl.EnsureTabFileSynchronization(); + DarkDockGroup bottomGroup = SearchResults?.DockGroup ?? CompilerLogs?.DockGroup; - if (previousTab is not null) - EditorTabControl.SelectTab(previousTab); + bottomGroup = EnsureLuaToolWindowInDockPanel(LuaDiagnostics, bottomGroup); + EnsureLuaToolWindowInDockPanel(LuaReferencesResults, bottomGroup); } - #endregion IDE Events - - #region Other methods + private DarkDockGroup EnsureLuaToolWindowInDockPanel(DarkToolWindow toolWindow, DarkDockGroup bottomGroup) + { + if (DockPanel.ContainsContent(toolWindow)) + return bottomGroup ?? toolWindow.DockGroup; - protected override void ApplyUserSettings(IEditorControl editor) - => editor.UpdateSettings(Configs.Lua); + toolWindow.DockArea = DarkDockArea.Bottom; - protected override void ApplyUserSettings() - { - foreach (TabPage tab in EditorTabControl.TabPages) - ApplyUserSettings(EditorTabControl.GetEditorOfTab(tab)); + if (bottomGroup is not null) + DockPanel.AddContent(toolWindow, bottomGroup); + else + DockPanel.AddContent(toolWindow); - UpdateSettings(); + return bottomGroup ?? toolWindow.DockGroup; } - protected override void Build() + private static void ShowLuaBasicsDocumentation() { - // Nothing. - } + const string url = "https://github.com/MontyTRC89/TombEngine/wiki/Basics-of-Lua-Programming"; - protected override void HandleDocumentCommands(UICommand command) - { - switch (command) + var process = new ProcessStartInfo { - case UICommand.LuaBasics: - string url = "https://github.com/MontyTRC89/TombEngine/wiki/Basics-of-Lua-Programming"; + FileName = url, + UseShellExecute = true + }; - var process = new ProcessStartInfo - { - FileName = url, - UseShellExecute = true - }; - - Process.Start(process); - break; - } - - base.HandleDocumentCommands(command); + Process.Start(process); } - protected override void ShowDocumentation() => throw new NotImplementedException(); - #endregion Other methods } } diff --git a/TombIDE/TombIDE.ScriptingStudio/Navigation/EditorNavigationHelper.cs b/TombIDE/TombIDE.ScriptingStudio/Navigation/EditorNavigationHelper.cs new file mode 100644 index 0000000000..cfb33147de --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/Navigation/EditorNavigationHelper.cs @@ -0,0 +1,109 @@ +#nullable enable + +using ICSharpCode.AvalonEdit.Document; +using System; +using System.Text.RegularExpressions; +using TombIDE.ScriptingStudio.FindAndReplace; +using TombLib.Scripting.Editing; +using TombLib.Scripting.UI.Bases; + +namespace TombIDE.ScriptingStudio.Navigation; + +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, + TextDocumentRange 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/Navigation/EditorNavigationHistoryService.cs b/TombIDE/TombIDE.ScriptingStudio/Navigation/EditorNavigationHistoryService.cs new file mode 100644 index 0000000000..9c213d4f92 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/Navigation/EditorNavigationHistoryService.cs @@ -0,0 +1,130 @@ +#nullable enable + +using System; +using System.Collections.Generic; + +namespace TombIDE.ScriptingStudio.Navigation; + +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/Navigation/EditorNavigationLocation.cs b/TombIDE/TombIDE.ScriptingStudio/Navigation/EditorNavigationLocation.cs new file mode 100644 index 0000000000..94f5f9d571 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/Navigation/EditorNavigationLocation.cs @@ -0,0 +1,19 @@ +#nullable enable + +using System; + +namespace TombIDE.ScriptingStudio.Navigation; + +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/FileOpenedEventArgs.cs b/TombIDE/TombIDE.ScriptingStudio/Objects/FileOpenedEventArgs.cs deleted file mode 100644 index 3f805aa6cd..0000000000 --- a/TombIDE/TombIDE.ScriptingStudio/Objects/FileOpenedEventArgs.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System; -using TombLib.Scripting.Enums; - -namespace TombIDE.ScriptingStudio.Objects -{ - public class FileOpenedEventArgs : EventArgs - { - public string FilePath { get; } - public EditorType EditorType { get; } - - public FileOpenedEventArgs(string filePath, EditorType editorType = EditorType.Default) - { - FilePath = filePath; - EditorType = editorType; - } - } -} diff --git a/TombIDE/TombIDE.ScriptingStudio/Objects/ReferenceDefinitionEventArgs.cs b/TombIDE/TombIDE.ScriptingStudio/Objects/ReferenceDefinitionEventArgs.cs deleted file mode 100644 index 871fd15ade..0000000000 --- a/TombIDE/TombIDE.ScriptingStudio/Objects/ReferenceDefinitionEventArgs.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System; -using TombLib.Scripting.ClassicScript.Enums; - -namespace TombIDE.ScriptingStudio.Objects -{ - public class ReferenceDefinitionEventArgs : EventArgs - { - public string Keyword { get; } - public ReferenceType Type { get; } - - public ReferenceDefinitionEventArgs(string keyword, ReferenceType type) - { - Keyword = keyword; - Type = type; - } - } -} diff --git a/TombIDE/TombIDE.ScriptingStudio/Objects/ReferenceItemType.cs b/TombIDE/TombIDE.ScriptingStudio/Objects/ReferenceItemType.cs deleted file mode 100644 index c44375b695..0000000000 --- a/TombIDE/TombIDE.ScriptingStudio/Objects/ReferenceItemType.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace TombIDE.ScriptingStudio.Objects -{ - internal enum ReferenceItemType - { - MnemonicConstants, - EnemyDamageValues, - KeyboardScancodes, - OCBList, - OldCommandsList, - NewCommandsList, - SoundIndices, - MoveableSlotIndices, - StaticObjectIndices, - VariablePlaceholders - } -} diff --git a/TombIDE/TombIDE.ScriptingStudio/Properties/InternalsVisibleTo.cs b/TombIDE/TombIDE.ScriptingStudio/Properties/InternalsVisibleTo.cs new file mode 100644 index 0000000000..5cc9957591 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/Properties/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("TombLib.Tests")] diff --git a/TombIDE/TombIDE.ScriptingStudio/Services/StudioWorkspaceAutomationProvider.cs b/TombIDE/TombIDE.ScriptingStudio/Services/StudioWorkspaceAutomationProvider.cs new file mode 100644 index 0000000000..7d8a677498 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/Services/StudioWorkspaceAutomationProvider.cs @@ -0,0 +1,13 @@ +using TombIDE.Shared; + +namespace TombIDE.ScriptingStudio.Services +{ + public interface IStudioWorkspaceAutomationProvider + { + void HandleIDEEvent(IIDEEvent ideEvent); + + void Build(); + + void ShowDocumentation(); + } +} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/Settings/ClassicScriptSettingsControl.Designer.cs b/TombIDE/TombIDE.ScriptingStudio/Settings/ClassicScriptSettingsControl.Designer.cs deleted file mode 100644 index b69980b8a3..0000000000 --- a/TombIDE/TombIDE.ScriptingStudio/Settings/ClassicScriptSettingsControl.Designer.cs +++ /dev/null @@ -1,861 +0,0 @@ -namespace TombIDE.ScriptingStudio.Settings -{ - partial class ClassicScriptSettingsControl - { - private System.ComponentModel.IContainer components = null; - - protected override void Dispose(bool disposing) - { - if (disposing && (components != null)) - { - components.Dispose(); - } - base.Dispose(disposing); - } - - #region Component Designer generated code - - private void InitializeComponent() - { - this.components = new System.ComponentModel.Container(); - this.button_ImportScheme = new DarkUI.Controls.DarkButton(); - this.buttonContextMenu = new DarkUI.Controls.DarkContextMenu(); - this.menuItem_Bold = new System.Windows.Forms.ToolStripMenuItem(); - this.menuItem_Italic = new System.Windows.Forms.ToolStripMenuItem(); - this.checkBox_Autocomplete = new DarkUI.Controls.DarkCheckBox(); - this.checkBox_CloseBrackets = new DarkUI.Controls.DarkCheckBox(); - this.checkBox_CloseQuotes = new DarkUI.Controls.DarkCheckBox(); - this.checkBox_HighlightCurrentLine = new DarkUI.Controls.DarkCheckBox(); - this.checkBox_LineNumbers = new DarkUI.Controls.DarkCheckBox(); - this.checkBox_LiveErrors = new DarkUI.Controls.DarkCheckBox(); - this.checkBox_PostCommaSpace = new DarkUI.Controls.DarkCheckBox(); - this.checkBox_PostEqualSpace = new DarkUI.Controls.DarkCheckBox(); - this.checkBox_PreCommaSpace = new DarkUI.Controls.DarkCheckBox(); - this.checkBox_PreEqualSpace = new DarkUI.Controls.DarkCheckBox(); - this.checkBox_ReduceSpaces = new DarkUI.Controls.DarkCheckBox(); - this.checkBox_SectionSeparators = new DarkUI.Controls.DarkCheckBox(); - this.checkBox_VisibleSpaces = new DarkUI.Controls.DarkCheckBox(); - this.checkBox_VisibleTabs = new DarkUI.Controls.DarkCheckBox(); - this.checkBox_WordWrapping = new DarkUI.Controls.DarkCheckBox(); - this.colorButton_Background = new DarkUI.Controls.DarkButton(); - this.colorButton_Comments = new DarkUI.Controls.DarkButton(); - this.colorButton_Foreground = new DarkUI.Controls.DarkButton(); - this.colorButton_NewCommands = new DarkUI.Controls.DarkButton(); - this.colorButton_References = new DarkUI.Controls.DarkButton(); - this.colorButton_Sections = new DarkUI.Controls.DarkButton(); - this.colorButton_StandardCommands = new DarkUI.Controls.DarkButton(); - this.colorButton_Values = new DarkUI.Controls.DarkButton(); - this.colorDialog = new System.Windows.Forms.ColorDialog(); - this.darkLabel1 = new DarkUI.Controls.DarkLabel(); - this.darkLabel10 = new DarkUI.Controls.DarkLabel(); - this.darkLabel11 = new DarkUI.Controls.DarkLabel(); - this.darkLabel12 = new DarkUI.Controls.DarkLabel(); - this.darkLabel2 = new DarkUI.Controls.DarkLabel(); - this.darkLabel3 = new DarkUI.Controls.DarkLabel(); - this.darkLabel4 = new DarkUI.Controls.DarkLabel(); - this.darkLabel5 = new DarkUI.Controls.DarkLabel(); - this.darkLabel6 = new DarkUI.Controls.DarkLabel(); - this.darkLabel7 = new DarkUI.Controls.DarkLabel(); - this.darkLabel8 = new DarkUI.Controls.DarkLabel(); - this.darkLabel9 = new DarkUI.Controls.DarkLabel(); - this.elementHost = new System.Windows.Forms.Integration.ElementHost(); - this.groupBox_AddSpaces = new DarkUI.Controls.DarkGroupBox(); - this.label_Comma = new DarkUI.Controls.DarkLabel(); - this.label_Equal = new DarkUI.Controls.DarkLabel(); - this.groupBox_Colors = new DarkUI.Controls.DarkGroupBox(); - this.button_SaveScheme = new DarkUI.Controls.DarkButton(); - this.button_DeleteScheme = new DarkUI.Controls.DarkButton(); - this.button_OpenSchemesFolder = new DarkUI.Controls.DarkButton(); - this.comboBox_ColorSchemes = new DarkUI.Controls.DarkComboBox(); - this.groupBox_Identation = new DarkUI.Controls.DarkGroupBox(); - this.groupBox_Preview = new DarkUI.Controls.DarkGroupBox(); - this.numeric_FontSize = new DarkUI.Controls.DarkNumericUpDown(); - this.numeric_UndoStackSize = new DarkUI.Controls.DarkNumericUpDown(); - this.sectionPanel = new DarkUI.Controls.DarkSectionPanel(); - this.comboBox_FontFamily = new DarkUI.Controls.DarkComboBox(); - this.toolTip = new System.Windows.Forms.ToolTip(this.components); - this.buttonContextMenu.SuspendLayout(); - this.groupBox_AddSpaces.SuspendLayout(); - this.groupBox_Colors.SuspendLayout(); - this.groupBox_Identation.SuspendLayout(); - this.groupBox_Preview.SuspendLayout(); - ((System.ComponentModel.ISupportInitialize)(this.numeric_FontSize)).BeginInit(); - ((System.ComponentModel.ISupportInitialize)(this.numeric_UndoStackSize)).BeginInit(); - this.sectionPanel.SuspendLayout(); - this.SuspendLayout(); - // - // button_ImportScheme - // - this.button_ImportScheme.Checked = false; - this.button_ImportScheme.Image = global::TombIDE.ScriptingStudio.Properties.Resources.Import_16; - this.button_ImportScheme.Location = new System.Drawing.Point(483, 16); - this.button_ImportScheme.Margin = new System.Windows.Forms.Padding(3, 0, 3, 3); - this.button_ImportScheme.Name = "button_ImportScheme"; - this.button_ImportScheme.Size = new System.Drawing.Size(25, 25); - this.button_ImportScheme.TabIndex = 21; - this.toolTip.SetToolTip(this.button_ImportScheme, "Import Scheme..."); - this.button_ImportScheme.Click += new System.EventHandler(this.button_ImportScheme_Click); - // - // buttonContextMenu - // - this.buttonContextMenu.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(60)))), ((int)(((byte)(63)))), ((int)(((byte)(65))))); - this.buttonContextMenu.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.buttonContextMenu.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { - this.menuItem_Bold, - this.menuItem_Italic}); - this.buttonContextMenu.Name = "buttonContextMenu"; - this.buttonContextMenu.Size = new System.Drawing.Size(100, 48); - this.buttonContextMenu.Opening += new System.ComponentModel.CancelEventHandler(this.buttonContextMenu_Opening); - // - // menuItem_Bold - // - this.menuItem_Bold.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(60)))), ((int)(((byte)(63)))), ((int)(((byte)(65))))); - this.menuItem_Bold.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.menuItem_Bold.Name = "menuItem_Bold"; - this.menuItem_Bold.Size = new System.Drawing.Size(99, 22); - this.menuItem_Bold.Text = "Bold"; - this.menuItem_Bold.Click += new System.EventHandler(this.menuItem_Bold_Click); - // - // menuItem_Italic - // - this.menuItem_Italic.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(60)))), ((int)(((byte)(63)))), ((int)(((byte)(65))))); - this.menuItem_Italic.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.menuItem_Italic.Name = "menuItem_Italic"; - this.menuItem_Italic.Size = new System.Drawing.Size(99, 22); - this.menuItem_Italic.Text = "Italic"; - this.menuItem_Italic.Click += new System.EventHandler(this.menuItem_Italic_Click); - // - // checkBox_Autocomplete - // - this.checkBox_Autocomplete.AutoSize = true; - this.checkBox_Autocomplete.Location = new System.Drawing.Point(6, 164); - this.checkBox_Autocomplete.Margin = new System.Windows.Forms.Padding(6, 6, 3, 0); - this.checkBox_Autocomplete.Name = "checkBox_Autocomplete"; - this.checkBox_Autocomplete.Size = new System.Drawing.Size(135, 17); - this.checkBox_Autocomplete.TabIndex = 6; - this.checkBox_Autocomplete.Text = "Enable autocomplete"; - // - // checkBox_CloseBrackets - // - this.checkBox_CloseBrackets.AutoSize = true; - this.checkBox_CloseBrackets.Location = new System.Drawing.Point(6, 207); - this.checkBox_CloseBrackets.Margin = new System.Windows.Forms.Padding(3, 9, 3, 0); - this.checkBox_CloseBrackets.Name = "checkBox_CloseBrackets"; - this.checkBox_CloseBrackets.Size = new System.Drawing.Size(138, 17); - this.checkBox_CloseBrackets.TabIndex = 8; - this.checkBox_CloseBrackets.Text = "Auto close brackets [ ]"; - // - // checkBox_CloseQuotes - // - this.checkBox_CloseQuotes.AutoSize = true; - this.checkBox_CloseQuotes.Location = new System.Drawing.Point(6, 224); - this.checkBox_CloseQuotes.Margin = new System.Windows.Forms.Padding(3, 0, 3, 0); - this.checkBox_CloseQuotes.Name = "checkBox_CloseQuotes"; - this.checkBox_CloseQuotes.Size = new System.Drawing.Size(133, 17); - this.checkBox_CloseQuotes.TabIndex = 9; - this.checkBox_CloseQuotes.Text = "Auto close quotes \" \""; - // - // checkBox_HighlightCurrentLine - // - this.checkBox_HighlightCurrentLine.AutoSize = true; - this.checkBox_HighlightCurrentLine.Location = new System.Drawing.Point(6, 276); - this.checkBox_HighlightCurrentLine.Margin = new System.Windows.Forms.Padding(3, 9, 3, 0); - this.checkBox_HighlightCurrentLine.Name = "checkBox_HighlightCurrentLine"; - this.checkBox_HighlightCurrentLine.Size = new System.Drawing.Size(137, 17); - this.checkBox_HighlightCurrentLine.TabIndex = 11; - this.checkBox_HighlightCurrentLine.Text = "Highlight current line"; - this.checkBox_HighlightCurrentLine.CheckedChanged += new System.EventHandler(this.VisiblePreviewSetting_Changed); - // - // checkBox_LineNumbers - // - this.checkBox_LineNumbers.AutoSize = true; - this.checkBox_LineNumbers.Location = new System.Drawing.Point(6, 302); - this.checkBox_LineNumbers.Margin = new System.Windows.Forms.Padding(3, 9, 3, 0); - this.checkBox_LineNumbers.Name = "checkBox_LineNumbers"; - this.checkBox_LineNumbers.Size = new System.Drawing.Size(125, 17); - this.checkBox_LineNumbers.TabIndex = 12; - this.checkBox_LineNumbers.Text = "Show line numbers"; - this.checkBox_LineNumbers.CheckedChanged += new System.EventHandler(this.VisiblePreviewSetting_Changed); - // - // checkBox_LiveErrors - // - this.checkBox_LiveErrors.AutoSize = true; - this.checkBox_LiveErrors.Location = new System.Drawing.Point(6, 181); - this.checkBox_LiveErrors.Margin = new System.Windows.Forms.Padding(3, 0, 3, 0); - this.checkBox_LiveErrors.Name = "checkBox_LiveErrors"; - this.checkBox_LiveErrors.Size = new System.Drawing.Size(137, 17); - this.checkBox_LiveErrors.TabIndex = 7; - this.checkBox_LiveErrors.Text = "Live error underlining"; - this.checkBox_LiveErrors.CheckedChanged += new System.EventHandler(this.VisiblePreviewSetting_Changed); - // - // checkBox_PostCommaSpace - // - this.checkBox_PostCommaSpace.AutoSize = true; - this.checkBox_PostCommaSpace.Location = new System.Drawing.Point(118, 77); - this.checkBox_PostCommaSpace.Margin = new System.Windows.Forms.Padding(0, 0, 40, 26); - this.checkBox_PostCommaSpace.Name = "checkBox_PostCommaSpace"; - this.checkBox_PostCommaSpace.Size = new System.Drawing.Size(15, 14); - this.checkBox_PostCommaSpace.TabIndex = 3; - this.checkBox_PostCommaSpace.CheckedChanged += new System.EventHandler(this.checkBox_PostCommaSpace_CheckedChanged); - // - // checkBox_PostEqualSpace - // - this.checkBox_PostEqualSpace.AutoSize = true; - this.checkBox_PostEqualSpace.Location = new System.Drawing.Point(118, 36); - this.checkBox_PostEqualSpace.Margin = new System.Windows.Forms.Padding(0, 20, 40, 0); - this.checkBox_PostEqualSpace.Name = "checkBox_PostEqualSpace"; - this.checkBox_PostEqualSpace.Size = new System.Drawing.Size(15, 14); - this.checkBox_PostEqualSpace.TabIndex = 1; - this.checkBox_PostEqualSpace.CheckedChanged += new System.EventHandler(this.checkBox_PostEqualSpace_CheckedChanged); - // - // checkBox_PreCommaSpace - // - this.checkBox_PreCommaSpace.AutoSize = true; - this.checkBox_PreCommaSpace.Location = new System.Drawing.Point(43, 77); - this.checkBox_PreCommaSpace.Margin = new System.Windows.Forms.Padding(40, 0, 0, 26); - this.checkBox_PreCommaSpace.Name = "checkBox_PreCommaSpace"; - this.checkBox_PreCommaSpace.Size = new System.Drawing.Size(15, 14); - this.checkBox_PreCommaSpace.TabIndex = 2; - this.checkBox_PreCommaSpace.CheckedChanged += new System.EventHandler(this.checkBox_PreCommaSpace_CheckedChanged); - // - // checkBox_PreEqualSpace - // - this.checkBox_PreEqualSpace.AutoSize = true; - this.checkBox_PreEqualSpace.Location = new System.Drawing.Point(43, 36); - this.checkBox_PreEqualSpace.Margin = new System.Windows.Forms.Padding(40, 20, 0, 0); - this.checkBox_PreEqualSpace.Name = "checkBox_PreEqualSpace"; - this.checkBox_PreEqualSpace.Size = new System.Drawing.Size(15, 14); - this.checkBox_PreEqualSpace.TabIndex = 0; - this.checkBox_PreEqualSpace.CheckedChanged += new System.EventHandler(this.checkBox_PreEqualSpace_CheckedChanged); - // - // checkBox_ReduceSpaces - // - this.checkBox_ReduceSpaces.AutoSize = true; - this.checkBox_ReduceSpaces.Location = new System.Drawing.Point(12, 151); - this.checkBox_ReduceSpaces.Margin = new System.Windows.Forms.Padding(9, 6, 3, 6); - this.checkBox_ReduceSpaces.Name = "checkBox_ReduceSpaces"; - this.checkBox_ReduceSpaces.Size = new System.Drawing.Size(178, 17); - this.checkBox_ReduceSpaces.TabIndex = 1; - this.checkBox_ReduceSpaces.Text = "Reduce the amount of spaces"; - this.checkBox_ReduceSpaces.CheckedChanged += new System.EventHandler(this.checkBox_ReduceSpaces_CheckedChanged); - // - // checkBox_SectionSeparators - // - this.checkBox_SectionSeparators.AutoSize = true; - this.checkBox_SectionSeparators.Location = new System.Drawing.Point(6, 319); - this.checkBox_SectionSeparators.Margin = new System.Windows.Forms.Padding(3, 0, 3, 0); - this.checkBox_SectionSeparators.Name = "checkBox_SectionSeparators"; - this.checkBox_SectionSeparators.Size = new System.Drawing.Size(152, 17); - this.checkBox_SectionSeparators.TabIndex = 13; - this.checkBox_SectionSeparators.Text = "Show section separators"; - this.checkBox_SectionSeparators.CheckedChanged += new System.EventHandler(this.VisiblePreviewSetting_Changed); - // - // checkBox_VisibleSpaces - // - this.checkBox_VisibleSpaces.AutoSize = true; - this.checkBox_VisibleSpaces.Location = new System.Drawing.Point(6, 345); - this.checkBox_VisibleSpaces.Margin = new System.Windows.Forms.Padding(3, 9, 3, 0); - this.checkBox_VisibleSpaces.Name = "checkBox_VisibleSpaces"; - this.checkBox_VisibleSpaces.Size = new System.Drawing.Size(127, 17); - this.checkBox_VisibleSpaces.TabIndex = 14; - this.checkBox_VisibleSpaces.Text = "Show visible spaces"; - this.checkBox_VisibleSpaces.CheckedChanged += new System.EventHandler(this.VisiblePreviewSetting_Changed); - // - // checkBox_VisibleTabs - // - this.checkBox_VisibleTabs.AutoSize = true; - this.checkBox_VisibleTabs.Location = new System.Drawing.Point(6, 362); - this.checkBox_VisibleTabs.Margin = new System.Windows.Forms.Padding(3, 0, 3, 0); - this.checkBox_VisibleTabs.Name = "checkBox_VisibleTabs"; - this.checkBox_VisibleTabs.Size = new System.Drawing.Size(115, 17); - this.checkBox_VisibleTabs.TabIndex = 15; - this.checkBox_VisibleTabs.Text = "Show visible tabs"; - this.checkBox_VisibleTabs.CheckedChanged += new System.EventHandler(this.VisiblePreviewSetting_Changed); - // - // checkBox_WordWrapping - // - this.checkBox_WordWrapping.AutoSize = true; - this.checkBox_WordWrapping.Location = new System.Drawing.Point(6, 250); - this.checkBox_WordWrapping.Margin = new System.Windows.Forms.Padding(3, 9, 3, 0); - this.checkBox_WordWrapping.Name = "checkBox_WordWrapping"; - this.checkBox_WordWrapping.Size = new System.Drawing.Size(108, 17); - this.checkBox_WordWrapping.TabIndex = 10; - this.checkBox_WordWrapping.Text = "Word wrapping"; - this.checkBox_WordWrapping.CheckedChanged += new System.EventHandler(this.VisiblePreviewSetting_Changed); - // - // colorButton_Background - // - this.colorButton_Background.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(32)))), ((int)(((byte)(32)))), ((int)(((byte)(32))))); - this.colorButton_Background.BackColorUseGeneric = false; - this.colorButton_Background.Checked = false; - this.colorButton_Background.Location = new System.Drawing.Point(370, 59); - this.colorButton_Background.Margin = new System.Windows.Forms.Padding(6, 0, 9, 3); - this.colorButton_Background.Name = "colorButton_Background"; - this.colorButton_Background.Size = new System.Drawing.Size(169, 25); - this.colorButton_Background.TabIndex = 14; - this.colorButton_Background.Click += new System.EventHandler(this.button_Color_Click); - // - // colorButton_Comments - // - this.colorButton_Comments.BackColor = System.Drawing.Color.Green; - this.colorButton_Comments.BackColorUseGeneric = false; - this.colorButton_Comments.Checked = false; - this.colorButton_Comments.ContextMenuStrip = this.buttonContextMenu; - this.colorButton_Comments.Font = new System.Drawing.Font("Segoe UI", 9.75F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(238))); - this.colorButton_Comments.Location = new System.Drawing.Point(191, 141); - this.colorButton_Comments.Margin = new System.Windows.Forms.Padding(3, 0, 3, 3); - this.colorButton_Comments.Name = "colorButton_Comments"; - this.colorButton_Comments.Size = new System.Drawing.Size(170, 25); - this.colorButton_Comments.TabIndex = 11; - this.colorButton_Comments.UseForeColor = true; - this.colorButton_Comments.Click += new System.EventHandler(this.button_Color_Click); - // - // colorButton_Foreground - // - this.colorButton_Foreground.BackColor = System.Drawing.Color.Gainsboro; - this.colorButton_Foreground.BackColorUseGeneric = false; - this.colorButton_Foreground.Checked = false; - this.colorButton_Foreground.Location = new System.Drawing.Point(370, 100); - this.colorButton_Foreground.Margin = new System.Windows.Forms.Padding(6, 0, 9, 3); - this.colorButton_Foreground.Name = "colorButton_Foreground"; - this.colorButton_Foreground.Size = new System.Drawing.Size(169, 25); - this.colorButton_Foreground.TabIndex = 16; - this.colorButton_Foreground.Click += new System.EventHandler(this.button_Color_Click); - // - // colorButton_NewCommands - // - this.colorButton_NewCommands.BackColor = System.Drawing.Color.SpringGreen; - this.colorButton_NewCommands.BackColorUseGeneric = false; - this.colorButton_NewCommands.Checked = false; - this.colorButton_NewCommands.ContextMenuStrip = this.buttonContextMenu; - this.colorButton_NewCommands.Font = new System.Drawing.Font("Segoe UI", 9.75F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(238))); - this.colorButton_NewCommands.Location = new System.Drawing.Point(191, 100); - this.colorButton_NewCommands.Margin = new System.Windows.Forms.Padding(3, 0, 3, 3); - this.colorButton_NewCommands.Name = "colorButton_NewCommands"; - this.colorButton_NewCommands.Size = new System.Drawing.Size(170, 25); - this.colorButton_NewCommands.TabIndex = 9; - this.colorButton_NewCommands.UseForeColor = true; - this.colorButton_NewCommands.Click += new System.EventHandler(this.button_Color_Click); - // - // colorButton_References - // - this.colorButton_References.BackColor = System.Drawing.Color.Orchid; - this.colorButton_References.BackColorUseGeneric = false; - this.colorButton_References.Checked = false; - this.colorButton_References.ContextMenuStrip = this.buttonContextMenu; - this.colorButton_References.Font = new System.Drawing.Font("Segoe UI", 9.75F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(238))); - this.colorButton_References.Location = new System.Drawing.Point(12, 141); - this.colorButton_References.Margin = new System.Windows.Forms.Padding(9, 0, 3, 8); - this.colorButton_References.Name = "colorButton_References"; - this.colorButton_References.Size = new System.Drawing.Size(170, 25); - this.colorButton_References.TabIndex = 5; - this.colorButton_References.UseForeColor = true; - this.colorButton_References.Click += new System.EventHandler(this.button_Color_Click); - // - // colorButton_Sections - // - this.colorButton_Sections.BackColor = System.Drawing.Color.SteelBlue; - this.colorButton_Sections.BackColorUseGeneric = false; - this.colorButton_Sections.Checked = false; - this.colorButton_Sections.ContextMenuStrip = this.buttonContextMenu; - this.colorButton_Sections.Font = new System.Drawing.Font("Segoe UI", 9.75F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(238))); - this.colorButton_Sections.Location = new System.Drawing.Point(12, 59); - this.colorButton_Sections.Margin = new System.Windows.Forms.Padding(9, 0, 3, 3); - this.colorButton_Sections.Name = "colorButton_Sections"; - this.colorButton_Sections.Size = new System.Drawing.Size(170, 25); - this.colorButton_Sections.TabIndex = 1; - this.colorButton_Sections.UseForeColor = true; - this.colorButton_Sections.Click += new System.EventHandler(this.button_Color_Click); - // - // colorButton_StandardCommands - // - this.colorButton_StandardCommands.BackColor = System.Drawing.Color.MediumAquamarine; - this.colorButton_StandardCommands.BackColorUseGeneric = false; - this.colorButton_StandardCommands.Checked = false; - this.colorButton_StandardCommands.ContextMenuStrip = this.buttonContextMenu; - this.colorButton_StandardCommands.Font = new System.Drawing.Font("Segoe UI", 9.75F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(238))); - this.colorButton_StandardCommands.Location = new System.Drawing.Point(191, 59); - this.colorButton_StandardCommands.Margin = new System.Windows.Forms.Padding(6, 0, 3, 3); - this.colorButton_StandardCommands.Name = "colorButton_StandardCommands"; - this.colorButton_StandardCommands.Size = new System.Drawing.Size(170, 25); - this.colorButton_StandardCommands.TabIndex = 7; - this.colorButton_StandardCommands.UseForeColor = true; - this.colorButton_StandardCommands.Click += new System.EventHandler(this.button_Color_Click); - // - // colorButton_Values - // - this.colorButton_Values.BackColor = System.Drawing.Color.LightSalmon; - this.colorButton_Values.BackColorUseGeneric = false; - this.colorButton_Values.Checked = false; - this.colorButton_Values.ContextMenuStrip = this.buttonContextMenu; - this.colorButton_Values.Font = new System.Drawing.Font("Segoe UI", 9.75F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(238))); - this.colorButton_Values.Location = new System.Drawing.Point(12, 100); - this.colorButton_Values.Margin = new System.Windows.Forms.Padding(3, 0, 3, 3); - this.colorButton_Values.Name = "colorButton_Values"; - this.colorButton_Values.Size = new System.Drawing.Size(170, 25); - this.colorButton_Values.TabIndex = 3; - this.colorButton_Values.UseForeColor = true; - this.colorButton_Values.Click += new System.EventHandler(this.button_Color_Click); - // - // colorDialog - // - this.colorDialog.AnyColor = true; - this.colorDialog.FullOpen = true; - // - // darkLabel1 - // - this.darkLabel1.AutoSize = true; - this.darkLabel1.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.darkLabel1.Location = new System.Drawing.Point(7, 34); - this.darkLabel1.Margin = new System.Windows.Forms.Padding(6, 9, 3, 0); - this.darkLabel1.Name = "darkLabel1"; - this.darkLabel1.Size = new System.Drawing.Size(56, 13); - this.darkLabel1.TabIndex = 0; - this.darkLabel1.Text = "Font size:"; - // - // darkLabel10 - // - this.darkLabel10.AutoSize = true; - this.darkLabel10.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.darkLabel10.Location = new System.Drawing.Point(370, 46); - this.darkLabel10.Margin = new System.Windows.Forms.Padding(3, 3, 3, 0); - this.darkLabel10.Name = "darkLabel10"; - this.darkLabel10.Size = new System.Drawing.Size(72, 13); - this.darkLabel10.TabIndex = 13; - this.darkLabel10.Text = "Background:"; - // - // darkLabel11 - // - this.darkLabel11.AutoSize = true; - this.darkLabel11.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.darkLabel11.Location = new System.Drawing.Point(370, 87); - this.darkLabel11.Name = "darkLabel11"; - this.darkLabel11.Size = new System.Drawing.Size(98, 13); - this.darkLabel11.TabIndex = 15; - this.darkLabel11.Text = "Normal text color:"; - // - // darkLabel12 - // - this.darkLabel12.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle; - this.darkLabel12.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.darkLabel12.Location = new System.Drawing.Point(475, 17); - this.darkLabel12.Name = "darkLabel12"; - this.darkLabel12.Size = new System.Drawing.Size(2, 23); - this.darkLabel12.TabIndex = 20; - // - // darkLabel2 - // - this.darkLabel2.AutoSize = true; - this.darkLabel2.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.darkLabel2.Location = new System.Drawing.Point(7, 76); - this.darkLabel2.Margin = new System.Windows.Forms.Padding(6, 3, 3, 0); - this.darkLabel2.Name = "darkLabel2"; - this.darkLabel2.Size = new System.Drawing.Size(67, 13); - this.darkLabel2.TabIndex = 2; - this.darkLabel2.Text = "Font family:"; - // - // darkLabel3 - // - this.darkLabel3.AutoSize = true; - this.darkLabel3.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.darkLabel3.Location = new System.Drawing.Point(7, 119); - this.darkLabel3.Margin = new System.Windows.Forms.Padding(6, 3, 3, 0); - this.darkLabel3.Name = "darkLabel3"; - this.darkLabel3.Size = new System.Drawing.Size(90, 13); - this.darkLabel3.TabIndex = 4; - this.darkLabel3.Text = "Undo stack size:"; - // - // darkLabel4 - // - this.darkLabel4.AutoSize = true; - this.darkLabel4.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.darkLabel4.Location = new System.Drawing.Point(12, 46); - this.darkLabel4.Margin = new System.Windows.Forms.Padding(9, 3, 3, 0); - this.darkLabel4.Name = "darkLabel4"; - this.darkLabel4.Size = new System.Drawing.Size(53, 13); - this.darkLabel4.TabIndex = 0; - this.darkLabel4.Text = "Sections:"; - // - // darkLabel5 - // - this.darkLabel5.AutoSize = true; - this.darkLabel5.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.darkLabel5.Location = new System.Drawing.Point(12, 87); - this.darkLabel5.Name = "darkLabel5"; - this.darkLabel5.Size = new System.Drawing.Size(43, 13); - this.darkLabel5.TabIndex = 2; - this.darkLabel5.Text = "Values:"; - // - // darkLabel6 - // - this.darkLabel6.AutoSize = true; - this.darkLabel6.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.darkLabel6.Location = new System.Drawing.Point(12, 128); - this.darkLabel6.Name = "darkLabel6"; - this.darkLabel6.Size = new System.Drawing.Size(66, 13); - this.darkLabel6.TabIndex = 4; - this.darkLabel6.Text = "References:"; - // - // darkLabel7 - // - this.darkLabel7.AutoSize = true; - this.darkLabel7.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.darkLabel7.Location = new System.Drawing.Point(190, 46); - this.darkLabel7.Margin = new System.Windows.Forms.Padding(3, 3, 3, 0); - this.darkLabel7.Name = "darkLabel7"; - this.darkLabel7.Size = new System.Drawing.Size(115, 13); - this.darkLabel7.TabIndex = 6; - this.darkLabel7.Text = "Standard commands:"; - // - // darkLabel8 - // - this.darkLabel8.AutoSize = true; - this.darkLabel8.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.darkLabel8.Location = new System.Drawing.Point(190, 87); - this.darkLabel8.Name = "darkLabel8"; - this.darkLabel8.Size = new System.Drawing.Size(91, 13); - this.darkLabel8.TabIndex = 8; - this.darkLabel8.Text = "New commands:"; - // - // darkLabel9 - // - this.darkLabel9.AutoSize = true; - this.darkLabel9.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.darkLabel9.Location = new System.Drawing.Point(190, 128); - this.darkLabel9.Name = "darkLabel9"; - this.darkLabel9.Size = new System.Drawing.Size(64, 13); - this.darkLabel9.TabIndex = 10; - this.darkLabel9.Text = "Comments:"; - // - // elementHost - // - this.elementHost.Dock = System.Windows.Forms.DockStyle.Fill; - this.elementHost.Location = new System.Drawing.Point(3, 18); - this.elementHost.Name = "elementHost"; - this.elementHost.Size = new System.Drawing.Size(345, 131); - this.elementHost.TabIndex = 0; - this.elementHost.Child = null; - // - // groupBox_AddSpaces - // - this.groupBox_AddSpaces.Controls.Add(this.label_Comma); - this.groupBox_AddSpaces.Controls.Add(this.label_Equal); - this.groupBox_AddSpaces.Controls.Add(this.checkBox_PostCommaSpace); - this.groupBox_AddSpaces.Controls.Add(this.checkBox_PreCommaSpace); - this.groupBox_AddSpaces.Controls.Add(this.checkBox_PostEqualSpace); - this.groupBox_AddSpaces.Controls.Add(this.checkBox_PreEqualSpace); - this.groupBox_AddSpaces.Location = new System.Drawing.Point(9, 22); - this.groupBox_AddSpaces.Margin = new System.Windows.Forms.Padding(6, 6, 3, 3); - this.groupBox_AddSpaces.Name = "groupBox_AddSpaces"; - this.groupBox_AddSpaces.Size = new System.Drawing.Size(176, 120); - this.groupBox_AddSpaces.TabIndex = 0; - this.groupBox_AddSpaces.TabStop = false; - this.groupBox_AddSpaces.Text = "Insert spaces"; - // - // label_Comma - // - this.label_Comma.Font = new System.Drawing.Font("Consolas", 15.75F, System.Drawing.FontStyle.Bold, System.Drawing.GraphicsUnit.Point, ((byte)(238))); - this.label_Comma.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.label_Comma.Location = new System.Drawing.Point(76, 70); - this.label_Comma.Margin = new System.Windows.Forms.Padding(18, 0, 18, 0); - this.label_Comma.Name = "label_Comma"; - this.label_Comma.Size = new System.Drawing.Size(24, 24); - this.label_Comma.TabIndex = 5; - this.label_Comma.Text = ","; - this.label_Comma.TextAlign = System.Drawing.ContentAlignment.BottomRight; - // - // label_Equal - // - this.label_Equal.Font = new System.Drawing.Font("Consolas", 15.75F, System.Drawing.FontStyle.Bold, System.Drawing.GraphicsUnit.Point, ((byte)(238))); - this.label_Equal.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.label_Equal.Location = new System.Drawing.Point(76, 29); - this.label_Equal.Margin = new System.Windows.Forms.Padding(18, 0, 18, 0); - this.label_Equal.Name = "label_Equal"; - this.label_Equal.Size = new System.Drawing.Size(24, 24); - this.label_Equal.TabIndex = 4; - this.label_Equal.Text = "="; - this.label_Equal.TextAlign = System.Drawing.ContentAlignment.BottomRight; - // - // groupBox_Colors - // - this.groupBox_Colors.Controls.Add(this.button_ImportScheme); - this.groupBox_Colors.Controls.Add(this.darkLabel12); - this.groupBox_Colors.Controls.Add(this.button_SaveScheme); - this.groupBox_Colors.Controls.Add(this.button_DeleteScheme); - this.groupBox_Colors.Controls.Add(this.button_OpenSchemesFolder); - this.groupBox_Colors.Controls.Add(this.darkLabel11); - this.groupBox_Colors.Controls.Add(this.colorButton_Foreground); - this.groupBox_Colors.Controls.Add(this.darkLabel10); - this.groupBox_Colors.Controls.Add(this.colorButton_Background); - this.groupBox_Colors.Controls.Add(this.comboBox_ColorSchemes); - this.groupBox_Colors.Controls.Add(this.darkLabel9); - this.groupBox_Colors.Controls.Add(this.darkLabel8); - this.groupBox_Colors.Controls.Add(this.darkLabel7); - this.groupBox_Colors.Controls.Add(this.darkLabel6); - this.groupBox_Colors.Controls.Add(this.darkLabel5); - this.groupBox_Colors.Controls.Add(this.darkLabel4); - this.groupBox_Colors.Controls.Add(this.colorButton_StandardCommands); - this.groupBox_Colors.Controls.Add(this.colorButton_NewCommands); - this.groupBox_Colors.Controls.Add(this.colorButton_Sections); - this.groupBox_Colors.Controls.Add(this.colorButton_Values); - this.groupBox_Colors.Controls.Add(this.colorButton_References); - this.groupBox_Colors.Controls.Add(this.colorButton_Comments); - this.groupBox_Colors.Location = new System.Drawing.Point(162, 28); - this.groupBox_Colors.Margin = new System.Windows.Forms.Padding(3, 3, 6, 3); - this.groupBox_Colors.Name = "groupBox_Colors"; - this.groupBox_Colors.Size = new System.Drawing.Size(551, 177); - this.groupBox_Colors.TabIndex = 17; - this.groupBox_Colors.TabStop = false; - this.groupBox_Colors.Text = "Color schemes"; - // - // button_SaveScheme - // - this.button_SaveScheme.Checked = false; - this.button_SaveScheme.Image = global::TombIDE.ScriptingStudio.Properties.Resources.Save_16; - this.button_SaveScheme.Location = new System.Drawing.Point(413, 16); - this.button_SaveScheme.Name = "button_SaveScheme"; - this.button_SaveScheme.Size = new System.Drawing.Size(25, 25); - this.button_SaveScheme.TabIndex = 19; - this.toolTip.SetToolTip(this.button_SaveScheme, "Save Scheme As..."); - this.button_SaveScheme.Click += new System.EventHandler(this.button_SaveScheme_Click); - // - // button_DeleteScheme - // - this.button_DeleteScheme.Checked = false; - this.button_DeleteScheme.Image = global::TombIDE.ScriptingStudio.Properties.Resources.Trash_16; - this.button_DeleteScheme.Location = new System.Drawing.Point(444, 16); - this.button_DeleteScheme.Name = "button_DeleteScheme"; - this.button_DeleteScheme.Size = new System.Drawing.Size(25, 25); - this.button_DeleteScheme.TabIndex = 18; - this.toolTip.SetToolTip(this.button_DeleteScheme, "Delete Scheme"); - this.button_DeleteScheme.Click += new System.EventHandler(this.button_DeleteScheme_Click); - // - // button_OpenSchemesFolder - // - this.button_OpenSchemesFolder.Checked = false; - this.button_OpenSchemesFolder.Image = global::TombIDE.ScriptingStudio.Properties.Resources.ForwardArrow_16; - this.button_OpenSchemesFolder.Location = new System.Drawing.Point(514, 16); - this.button_OpenSchemesFolder.Margin = new System.Windows.Forms.Padding(3, 0, 3, 3); - this.button_OpenSchemesFolder.Name = "button_OpenSchemesFolder"; - this.button_OpenSchemesFolder.Size = new System.Drawing.Size(25, 25); - this.button_OpenSchemesFolder.TabIndex = 17; - this.toolTip.SetToolTip(this.button_OpenSchemesFolder, "Open Schemes Folder"); - this.button_OpenSchemesFolder.Click += new System.EventHandler(this.button_OpenSchemesFolder_Click); - // - // comboBox_ColorSchemes - // - this.comboBox_ColorSchemes.FormattingEnabled = true; - this.comboBox_ColorSchemes.Location = new System.Drawing.Point(12, 19); - this.comboBox_ColorSchemes.Margin = new System.Windows.Forms.Padding(9, 3, 3, 3); - this.comboBox_ColorSchemes.Name = "comboBox_ColorSchemes"; - this.comboBox_ColorSchemes.Size = new System.Drawing.Size(395, 23); - this.comboBox_ColorSchemes.TabIndex = 12; - this.comboBox_ColorSchemes.SelectedIndexChanged += new System.EventHandler(this.comboBox_ColorSchemes_SelectedIndexChanged); - // - // groupBox_Identation - // - this.groupBox_Identation.Controls.Add(this.groupBox_Preview); - this.groupBox_Identation.Controls.Add(this.groupBox_AddSpaces); - this.groupBox_Identation.Controls.Add(this.checkBox_ReduceSpaces); - this.groupBox_Identation.Location = new System.Drawing.Point(162, 228); - this.groupBox_Identation.Margin = new System.Windows.Forms.Padding(3, 3, 6, 6); - this.groupBox_Identation.Name = "groupBox_Identation"; - this.groupBox_Identation.Size = new System.Drawing.Size(551, 177); - this.groupBox_Identation.TabIndex = 18; - this.groupBox_Identation.TabStop = false; - this.groupBox_Identation.Text = "Indentation rules"; - // - // groupBox_Preview - // - this.groupBox_Preview.Controls.Add(this.elementHost); - this.groupBox_Preview.Location = new System.Drawing.Point(191, 16); - this.groupBox_Preview.Margin = new System.Windows.Forms.Padding(3, 0, 6, 6); - this.groupBox_Preview.Name = "groupBox_Preview"; - this.groupBox_Preview.Size = new System.Drawing.Size(351, 152); - this.groupBox_Preview.TabIndex = 2; - this.groupBox_Preview.TabStop = false; - this.groupBox_Preview.Text = "Preview"; - // - // numeric_FontSize - // - this.numeric_FontSize.IncrementAlternate = new decimal(new int[] { - 10, - 0, - 0, - 65536}); - this.numeric_FontSize.Location = new System.Drawing.Point(6, 50); - this.numeric_FontSize.LoopValues = false; - this.numeric_FontSize.Margin = new System.Windows.Forms.Padding(6, 3, 3, 3); - this.numeric_FontSize.Maximum = new decimal(new int[] { - 32, - 0, - 0, - 0}); - this.numeric_FontSize.Minimum = new decimal(new int[] { - 4, - 0, - 0, - 0}); - this.numeric_FontSize.Name = "numeric_FontSize"; - this.numeric_FontSize.Size = new System.Drawing.Size(150, 22); - this.numeric_FontSize.TabIndex = 1; - this.numeric_FontSize.Value = new decimal(new int[] { - 12, - 0, - 0, - 0}); - this.numeric_FontSize.ValueChanged += new System.EventHandler(this.VisiblePreviewSetting_Changed); - // - // numeric_UndoStackSize - // - this.numeric_UndoStackSize.IncrementAlternate = new decimal(new int[] { - 10, - 0, - 0, - 65536}); - this.numeric_UndoStackSize.Location = new System.Drawing.Point(6, 135); - this.numeric_UndoStackSize.LoopValues = false; - this.numeric_UndoStackSize.Margin = new System.Windows.Forms.Padding(6, 3, 3, 3); - this.numeric_UndoStackSize.Maximum = new decimal(new int[] { - 1024, - 0, - 0, - 0}); - this.numeric_UndoStackSize.Minimum = new decimal(new int[] { - 16, - 0, - 0, - 0}); - this.numeric_UndoStackSize.Name = "numeric_UndoStackSize"; - this.numeric_UndoStackSize.Size = new System.Drawing.Size(150, 22); - this.numeric_UndoStackSize.TabIndex = 5; - this.numeric_UndoStackSize.Value = new decimal(new int[] { - 256, - 0, - 0, - 0}); - // - // sectionPanel - // - this.sectionPanel.Controls.Add(this.checkBox_HighlightCurrentLine); - this.sectionPanel.Controls.Add(this.checkBox_SectionSeparators); - this.sectionPanel.Controls.Add(this.checkBox_VisibleTabs); - this.sectionPanel.Controls.Add(this.checkBox_VisibleSpaces); - this.sectionPanel.Controls.Add(this.checkBox_LineNumbers); - this.sectionPanel.Controls.Add(this.groupBox_Identation); - this.sectionPanel.Controls.Add(this.darkLabel3); - this.sectionPanel.Controls.Add(this.darkLabel2); - this.sectionPanel.Controls.Add(this.darkLabel1); - this.sectionPanel.Controls.Add(this.checkBox_CloseQuotes); - this.sectionPanel.Controls.Add(this.checkBox_LiveErrors); - this.sectionPanel.Controls.Add(this.numeric_UndoStackSize); - this.sectionPanel.Controls.Add(this.checkBox_Autocomplete); - this.sectionPanel.Controls.Add(this.checkBox_WordWrapping); - this.sectionPanel.Controls.Add(this.groupBox_Colors); - this.sectionPanel.Controls.Add(this.checkBox_CloseBrackets); - this.sectionPanel.Controls.Add(this.comboBox_FontFamily); - this.sectionPanel.Controls.Add(this.numeric_FontSize); - this.sectionPanel.Dock = System.Windows.Forms.DockStyle.Fill; - this.sectionPanel.Location = new System.Drawing.Point(0, 0); - this.sectionPanel.Name = "sectionPanel"; - this.sectionPanel.SectionHeader = "TR4 / TRNG Script"; - this.sectionPanel.Size = new System.Drawing.Size(720, 412); - this.sectionPanel.TabIndex = 0; - // - // comboBox_FontFamily - // - this.comboBox_FontFamily.FormattingEnabled = true; - this.comboBox_FontFamily.Location = new System.Drawing.Point(6, 92); - this.comboBox_FontFamily.Margin = new System.Windows.Forms.Padding(6, 3, 3, 3); - this.comboBox_FontFamily.Name = "comboBox_FontFamily"; - this.comboBox_FontFamily.Size = new System.Drawing.Size(150, 23); - this.comboBox_FontFamily.TabIndex = 3; - this.comboBox_FontFamily.SelectedIndexChanged += new System.EventHandler(this.comboBox_FontFamily_SelectedIndexChanged); - // - // ClassicScriptSettingsControl - // - this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); - this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; - this.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(63)))), ((int)(((byte)(65)))), ((int)(((byte)(69))))); - this.Controls.Add(this.sectionPanel); - this.Font = new System.Drawing.Font("Segoe UI", 8.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0))); - this.MaximumSize = new System.Drawing.Size(720, 412); - this.MinimumSize = new System.Drawing.Size(720, 412); - this.Name = "ClassicScriptSettingsControl"; - this.Size = new System.Drawing.Size(720, 412); - this.buttonContextMenu.ResumeLayout(false); - this.groupBox_AddSpaces.ResumeLayout(false); - this.groupBox_AddSpaces.PerformLayout(); - this.groupBox_Colors.ResumeLayout(false); - this.groupBox_Colors.PerformLayout(); - this.groupBox_Identation.ResumeLayout(false); - this.groupBox_Identation.PerformLayout(); - this.groupBox_Preview.ResumeLayout(false); - ((System.ComponentModel.ISupportInitialize)(this.numeric_FontSize)).EndInit(); - ((System.ComponentModel.ISupportInitialize)(this.numeric_UndoStackSize)).EndInit(); - this.sectionPanel.ResumeLayout(false); - this.sectionPanel.PerformLayout(); - this.ResumeLayout(false); - - } - - #endregion - - private DarkUI.Controls.DarkButton button_DeleteScheme; - private DarkUI.Controls.DarkButton button_ImportScheme; - private DarkUI.Controls.DarkButton button_OpenSchemesFolder; - private DarkUI.Controls.DarkButton button_SaveScheme; - private DarkUI.Controls.DarkButton colorButton_Background; - private DarkUI.Controls.DarkButton colorButton_Comments; - private DarkUI.Controls.DarkButton colorButton_Foreground; - private DarkUI.Controls.DarkButton colorButton_NewCommands; - private DarkUI.Controls.DarkButton colorButton_References; - private DarkUI.Controls.DarkButton colorButton_Sections; - private DarkUI.Controls.DarkButton colorButton_StandardCommands; - private DarkUI.Controls.DarkButton colorButton_Values; - private DarkUI.Controls.DarkCheckBox checkBox_Autocomplete; - private DarkUI.Controls.DarkCheckBox checkBox_CloseBrackets; - private DarkUI.Controls.DarkCheckBox checkBox_CloseQuotes; - private DarkUI.Controls.DarkCheckBox checkBox_HighlightCurrentLine; - private DarkUI.Controls.DarkCheckBox checkBox_LineNumbers; - private DarkUI.Controls.DarkCheckBox checkBox_LiveErrors; - private DarkUI.Controls.DarkCheckBox checkBox_PostCommaSpace; - private DarkUI.Controls.DarkCheckBox checkBox_PostEqualSpace; - private DarkUI.Controls.DarkCheckBox checkBox_PreCommaSpace; - private DarkUI.Controls.DarkCheckBox checkBox_PreEqualSpace; - private DarkUI.Controls.DarkCheckBox checkBox_ReduceSpaces; - private DarkUI.Controls.DarkCheckBox checkBox_SectionSeparators; - private DarkUI.Controls.DarkCheckBox checkBox_VisibleSpaces; - private DarkUI.Controls.DarkCheckBox checkBox_VisibleTabs; - private DarkUI.Controls.DarkCheckBox checkBox_WordWrapping; - private DarkUI.Controls.DarkComboBox comboBox_ColorSchemes; - private DarkUI.Controls.DarkComboBox comboBox_FontFamily; - private DarkUI.Controls.DarkContextMenu buttonContextMenu; - private DarkUI.Controls.DarkGroupBox groupBox_AddSpaces; - private DarkUI.Controls.DarkGroupBox groupBox_Colors; - private DarkUI.Controls.DarkGroupBox groupBox_Identation; - private DarkUI.Controls.DarkGroupBox groupBox_Preview; - private DarkUI.Controls.DarkLabel darkLabel1; - private DarkUI.Controls.DarkLabel darkLabel10; - private DarkUI.Controls.DarkLabel darkLabel11; - private DarkUI.Controls.DarkLabel darkLabel12; - private DarkUI.Controls.DarkLabel darkLabel2; - private DarkUI.Controls.DarkLabel darkLabel3; - private DarkUI.Controls.DarkLabel darkLabel4; - private DarkUI.Controls.DarkLabel darkLabel5; - private DarkUI.Controls.DarkLabel darkLabel6; - private DarkUI.Controls.DarkLabel darkLabel7; - private DarkUI.Controls.DarkLabel darkLabel8; - private DarkUI.Controls.DarkLabel darkLabel9; - private DarkUI.Controls.DarkLabel label_Comma; - private DarkUI.Controls.DarkLabel label_Equal; - private DarkUI.Controls.DarkNumericUpDown numeric_FontSize; - private DarkUI.Controls.DarkNumericUpDown numeric_UndoStackSize; - private DarkUI.Controls.DarkSectionPanel sectionPanel; - private System.Windows.Forms.ColorDialog colorDialog; - private System.Windows.Forms.Integration.ElementHost elementHost; - private System.Windows.Forms.ToolStripMenuItem menuItem_Bold; - private System.Windows.Forms.ToolStripMenuItem menuItem_Italic; - private System.Windows.Forms.ToolTip toolTip; - } -} diff --git a/TombIDE/TombIDE.ScriptingStudio/Settings/ClassicScriptSettingsControl.cs b/TombIDE/TombIDE.ScriptingStudio/Settings/ClassicScriptSettingsControl.cs deleted file mode 100644 index cd90cb033a..0000000000 --- a/TombIDE/TombIDE.ScriptingStudio/Settings/ClassicScriptSettingsControl.cs +++ /dev/null @@ -1,566 +0,0 @@ -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.ClassicScript; -using TombLib.Scripting.ClassicScript.Objects; -using TombLib.Scripting.ClassicScript.Resources; -using TombLib.Scripting.Objects; -using TombLib.Scripting.Resources; -using TombLib.Utils; - -namespace TombIDE.ScriptingStudio.Settings -{ - internal partial class ClassicScriptSettingsControl : UserControl - { - // TODO: Refactor !!! - - private ClassicScriptEditor editorPreview; - - #region Construction - - public ClassicScriptSettingsControl() - { - InitializeComponent(); - } - - public void Initialize(ClassicScriptEditorConfiguration config) - { - InitializePreview(); - - FillFontList(); - UpdateSchemeList(); - UpdateControlsWithSettings(config); - } - - private void InitializePreview() - { - editorPreview = new ClassicScriptEditor(new Version(0, 0)) - { - Text = "[Level]\nRain=ENABLED,12 ; Has error\nLayer1=128,128,>\n\t\t128,-8\nMirror=69,$2137\n[Level]", - 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 FillFontList() - { - var fontList = new List(); - - foreach (FontFamily font in new InstalledFontCollection().Families) - fontList.Add(font.Name); - - comboBox_FontFamily.Items.AddRange(fontList.ToArray()); - } - - private void UpdateSchemeList() - { - string cachedSelectedItem = null; - - if (comboBox_ColorSchemes.SelectedItem != null) - cachedSelectedItem = comboBox_ColorSchemes.SelectedItem.ToString(); - - comboBox_ColorSchemes.Items.Clear(); - - foreach (string file in Directory.GetFiles(DefaultPaths.ClassicScriptColorConfigsDirectory, "*.cssch", SearchOption.TopDirectoryOnly)) - comboBox_ColorSchemes.Items.Add(Path.GetFileNameWithoutExtension(file)); - - if (cachedSelectedItem != null) - comboBox_ColorSchemes.SelectedItem = cachedSelectedItem; - } - - #endregion Construction - - #region Events - - private void VisiblePreviewSetting_Changed(object sender, EventArgs e) => - UpdatePreviewTemp(); - - private void comboBox_FontFamily_SelectedIndexChanged(object sender, EventArgs e) => - UpdatePreviewTemp(false); - - private void checkBox_PreEqualSpace_CheckedChanged(object sender, EventArgs e) - { - editorPreview.Text = checkBox_PreEqualSpace.Checked ? editorPreview.Text.Replace("=", " =") : editorPreview.Text.Replace(" =", "="); - ForcePreviewUpdate(); - } - - private void checkBox_PostEqualSpace_CheckedChanged(object sender, EventArgs e) - { - editorPreview.Text = checkBox_PostEqualSpace.Checked ? editorPreview.Text.Replace("=", "= ") : editorPreview.Text.Replace("= ", "="); - ForcePreviewUpdate(); - } - - private void checkBox_PreCommaSpace_CheckedChanged(object sender, EventArgs e) - { - editorPreview.Text = checkBox_PreCommaSpace.Checked ? editorPreview.Text.Replace(",", " ,") : editorPreview.Text.Replace(" ,", ","); - ForcePreviewUpdate(); - } - - private void checkBox_PostCommaSpace_CheckedChanged(object sender, EventArgs e) - { - editorPreview.Text = checkBox_PostCommaSpace.Checked ? editorPreview.Text.Replace(",", ", ") : editorPreview.Text.Replace(", ", ","); - ForcePreviewUpdate(); - } - - private void checkBox_ReduceSpaces_CheckedChanged(object sender, EventArgs e) - { - editorPreview.Text = checkBox_ReduceSpaces.Checked ? editorPreview.Text.Replace(" ;", ";") : editorPreview.Text.Replace(";", " ;"); - ForcePreviewUpdate(); - } - - 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(); - - string fullSchemePath = Path.Combine(DefaultPaths.ClassicScriptColorConfigsDirectory, comboBox_ColorSchemes.SelectedItem.ToString() + ".cssch"); - ColorScheme selectedScheme = XmlUtils.ReadXmlFile(fullSchemePath); - - UpdateColorButtons(selectedScheme); - UpdatePreviewColors(selectedScheme); - } - - 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.ClassicScript)) - if (form.ShowDialog(this) == DialogResult.OK) - { - var currentScheme = new ColorScheme - { - Sections = (HighlightingObject)colorButton_Sections.Tag, - Values = (HighlightingObject)colorButton_Values.Tag, - References = (HighlightingObject)colorButton_References.Tag, - StandardCommands = (HighlightingObject)colorButton_StandardCommands.Tag, - NewCommands = (HighlightingObject)colorButton_NewCommands.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.ClassicScriptColorConfigsDirectory, comboBox_ColorSchemes.SelectedItem + ".cssch"); - - 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 = "Classic Script Scheme|*.cssch"; - - if (dialog.ShowDialog(this) == DialogResult.OK) - { - File.Copy(dialog.FileName, Path.Combine(DefaultPaths.ClassicScriptColorConfigsDirectory, 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.ClassicScriptColorConfigsDirectory, - UseShellExecute = true - }; - - Process.Start(startInfo); - } - - #endregion Events - - #region Loading - - private void UpdateControlsWithSettings(ClassicScriptEditorConfiguration config) - { - numeric_FontSize.Value = (decimal)config.FontSize - 4; // -4 because AvalonEdit has a different font size scale - comboBox_FontFamily.SelectedItem = config.FontFamily; - numeric_UndoStackSize.Value = config.UndoStackSize; - - LoadSettingsForCheckBoxes(config); - LoadSettingsForIdentationRules(config); - - comboBox_ColorSchemes.SelectedItem = config.SelectedColorSchemeName; - } - - private void LoadSettingsForCheckBoxes(ClassicScriptEditorConfiguration config) - { - checkBox_Autocomplete.Checked = config.AutocompleteEnabled; - checkBox_LiveErrors.Checked = config.LiveErrorUnderlining; - - checkBox_CloseBrackets.Checked = config.AutoCloseBrackets; - checkBox_CloseQuotes.Checked = config.AutoCloseQuotes; - - checkBox_WordWrapping.Checked = config.WordWrapping; - - checkBox_HighlightCurrentLine.Checked = config.HighlightCurrentLine; - - checkBox_LineNumbers.Checked = config.ShowLineNumbers; - checkBox_SectionSeparators.Checked = config.ShowSectionSeparators; - - checkBox_VisibleSpaces.Checked = config.ShowVisualSpaces; - checkBox_VisibleTabs.Checked = config.ShowVisualTabs; - } - - private void LoadSettingsForIdentationRules(ClassicScriptEditorConfiguration config) - { - checkBox_PreEqualSpace.Checked = config.Tidy_PreEqualSpace; - checkBox_PostEqualSpace.Checked = config.Tidy_PostEqualSpace; - - checkBox_PreCommaSpace.Checked = config.Tidy_PreCommaSpace; - checkBox_PostCommaSpace.Checked = config.Tidy_PostCommaSpace; - - checkBox_ReduceSpaces.Checked = config.Tidy_ReduceSpaces; - } - - #endregion Loading - - #region Applying - - public void ApplySettings(ClassicScriptEditorConfiguration config) - { - config.FontSize = (double)(numeric_FontSize.Value + 4); // +4 because AvalonEdit has a different font size scale - config.FontFamily = comboBox_FontFamily.SelectedItem.ToString(); - config.UndoStackSize = (int)numeric_UndoStackSize.Value; - - ApplySettingsFromCheckBoxes(config); - ApplyIdentationRulesSettings(config); - - config.SelectedColorSchemeName = comboBox_ColorSchemes.SelectedItem.ToString(); - - config.Save(); - } - - private void ApplySettingsFromCheckBoxes(ClassicScriptEditorConfiguration config) - { - config.AutocompleteEnabled = checkBox_Autocomplete.Checked; - config.LiveErrorUnderlining = checkBox_LiveErrors.Checked; - - config.AutoCloseBrackets = checkBox_CloseBrackets.Checked; - config.AutoCloseQuotes = checkBox_CloseQuotes.Checked; - - config.WordWrapping = checkBox_WordWrapping.Checked; - - config.HighlightCurrentLine = checkBox_HighlightCurrentLine.Checked; - - config.ShowLineNumbers = checkBox_LineNumbers.Checked; - config.ShowSectionSeparators = checkBox_SectionSeparators.Checked; - - config.ShowVisualSpaces = checkBox_VisibleSpaces.Checked; - config.ShowVisualTabs = checkBox_VisibleTabs.Checked; - } - - private void ApplyIdentationRulesSettings(ClassicScriptEditorConfiguration config) - { - config.Tidy_PreEqualSpace = checkBox_PreEqualSpace.Checked; - config.Tidy_PostEqualSpace = checkBox_PostEqualSpace.Checked; - - config.Tidy_PreCommaSpace = checkBox_PreCommaSpace.Checked; - config.Tidy_PostCommaSpace = checkBox_PostCommaSpace.Checked; - - config.Tidy_ReduceSpaces = checkBox_ReduceSpaces.Checked; - } - - #endregion Applying - - #region Resetting - - public void ResetToDefault() - { - numeric_FontSize.Value = (decimal)(TextEditorBaseDefaults.FontSize - 4); // -4 because AvalonEdit has a different font size scale - comboBox_FontFamily.SelectedItem = TextEditorBaseDefaults.FontFamily; - numeric_UndoStackSize.Value = TextEditorBaseDefaults.UndoStackSize; - - ResetCheckBoxSettings(); - ResetIdentationRules(); - } - - private void ResetCheckBoxSettings() - { - checkBox_Autocomplete.Checked = TextEditorBaseDefaults.AutocompleteEnabled; - checkBox_LiveErrors.Checked = TextEditorBaseDefaults.LiveErrorUnderlining; - - checkBox_CloseBrackets.Checked = TextEditorBaseDefaults.AutoCloseBrackets; - checkBox_CloseQuotes.Checked = TextEditorBaseDefaults.AutoCloseQuotes; - - checkBox_HighlightCurrentLine.Checked = TextEditorBaseDefaults.HighlightCurrentLine; - - checkBox_WordWrapping.Checked = TextEditorBaseDefaults.WordWrapping; - - checkBox_LineNumbers.Checked = TextEditorBaseDefaults.ShowLineNumbers; - checkBox_SectionSeparators.Checked = ConfigurationDefaults.ShowSectionSeparators; - - checkBox_VisibleSpaces.Checked = TextEditorBaseDefaults.ShowVisualSpaces; - checkBox_VisibleTabs.Checked = TextEditorBaseDefaults.ShowVisualTabs; - } - - private void ResetIdentationRules() - { - checkBox_PreEqualSpace.Checked = ConfigurationDefaults.Tidy_PreEqualSpace; - checkBox_PostEqualSpace.Checked = ConfigurationDefaults.Tidy_PostEqualSpace; - - checkBox_PreCommaSpace.Checked = ConfigurationDefaults.Tidy_PreCommaSpace; - checkBox_PostCommaSpace.Checked = ConfigurationDefaults.Tidy_PostCommaSpace; - - checkBox_ReduceSpaces.Checked = ConfigurationDefaults.Tidy_ReduceSpaces; - } - - #endregion Resetting - - public void ForcePreviewUpdate() => - editorPreview.Focus(); - - private void ChangeColor(DarkButton targetButton) - { - 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(); - - UpdateColorButtonStyleText(targetButton); - } - } - - private void UpdatePreview() - { - var currentScheme = new ColorScheme - { - Sections = (HighlightingObject)colorButton_Sections.Tag, - Values = (HighlightingObject)colorButton_Values.Tag, - References = (HighlightingObject)colorButton_References.Tag, - StandardCommands = (HighlightingObject)colorButton_StandardCommands.Tag, - NewCommands = (HighlightingObject)colorButton_NewCommands.Tag, - Comments = (HighlightingObject)colorButton_Comments.Tag, - Background = ColorTranslator.ToHtml(colorButton_Background.BackColor), - Foreground = ColorTranslator.ToHtml(colorButton_Foreground.BackColor) - }; - - bool itemFound = false; - - foreach (string item in comboBox_ColorSchemes.Items) - { - if (item == "~UNTITLED") - continue; - - ColorScheme itemScheme = XmlUtils.ReadXmlFile(Path.Combine(DefaultPaths.ClassicScriptColorConfigsDirectory, item + ".cssch")); - - 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.ClassicScriptColorConfigsDirectory, "~UNTITLED.cssch"), currentScheme); - - comboBox_ColorSchemes.SelectedItem = "~UNTITLED"; - } - - UpdatePreviewColors(currentScheme); - } - - private void UpdateColorButtons(ColorScheme scheme) - { - colorButton_Sections.BackColor = ColorTranslator.FromHtml(scheme.Sections.HtmlColor); - colorButton_Sections.Tag = scheme.Sections; - - colorButton_Values.BackColor = ColorTranslator.FromHtml(scheme.Values.HtmlColor); - colorButton_Values.Tag = scheme.Values; - - colorButton_References.BackColor = ColorTranslator.FromHtml(scheme.References.HtmlColor); - colorButton_References.Tag = scheme.References; - - colorButton_StandardCommands.BackColor = ColorTranslator.FromHtml(scheme.StandardCommands.HtmlColor); - colorButton_StandardCommands.Tag = scheme.StandardCommands; - - colorButton_NewCommands.BackColor = ColorTranslator.FromHtml(scheme.NewCommands.HtmlColor); - colorButton_NewCommands.Tag = scheme.NewCommands; - - colorButton_Comments.BackColor = ColorTranslator.FromHtml(scheme.Comments.HtmlColor); - colorButton_Comments.Tag = scheme.Comments; - - UpdateColorButtonStyleText(colorButton_Sections); - UpdateColorButtonStyleText(colorButton_Values); - UpdateColorButtonStyleText(colorButton_References); - UpdateColorButtonStyleText(colorButton_StandardCommands); - UpdateColorButtonStyleText(colorButton_NewCommands); - UpdateColorButtonStyleText(colorButton_Comments); - - colorButton_Background.BackColor = ColorTranslator.FromHtml(scheme.Background); - colorButton_Foreground.BackColor = ColorTranslator.FromHtml(scheme.Foreground); - } - - 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(); - } - - private void UpdateColorButtonStyleText(DarkButton colorButton) - { - 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; - } - - private void UpdatePreviewColors(ColorScheme scheme) - { - 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); - } - - private void ToggleSaveSchemeButton() - { - bool isUntitled = comboBox_ColorSchemes.SelectedItem.ToString().Equals("~UNTITLED", StringComparison.OrdinalIgnoreCase); - - button_SaveScheme.Enabled = isUntitled; - button_SaveScheme.Visible = isUntitled; - - comboBox_ColorSchemes.Width = isUntitled ? 395 : 426; - } - - private void UpdatePreviewTemp(bool forceUpdate = true) - { - 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()); - - editorPreview.LiveErrorUnderlining = checkBox_LiveErrors.Checked; - - if (editorPreview.LiveErrorUnderlining) - editorPreview.CheckForErrors(); - else - editorPreview.ResetAllErrors(); - - editorPreview.WordWrap = checkBox_WordWrapping.Checked; - - editorPreview.Options.HighlightCurrentLine = checkBox_HighlightCurrentLine.Checked; - - editorPreview.ShowLineNumbers = checkBox_LineNumbers.Checked; - editorPreview.ShowSectionSeparators = checkBox_SectionSeparators.Checked; - - editorPreview.Options.ShowSpaces = checkBox_VisibleSpaces.Checked; - editorPreview.Options.ShowTabs = checkBox_VisibleTabs.Checked; - - if (forceUpdate) - ForcePreviewUpdate(); - } - } -} diff --git a/TombIDE/TombIDE.ScriptingStudio/Settings/ClassicScriptSettingsControl.resx b/TombIDE/TombIDE.ScriptingStudio/Settings/ClassicScriptSettingsControl.resx deleted file mode 100644 index 203aec3b78..0000000000 --- a/TombIDE/TombIDE.ScriptingStudio/Settings/ClassicScriptSettingsControl.resx +++ /dev/null @@ -1,129 +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 - - - 131, 17 - - - 221, 17 - - - 17, 17 - - \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/Settings/FormSaveSchemeAs.Designer.cs b/TombIDE/TombIDE.ScriptingStudio/Settings/FormSaveSchemeAs.Designer.cs deleted file mode 100644 index 68e932b065..0000000000 --- a/TombIDE/TombIDE.ScriptingStudio/Settings/FormSaveSchemeAs.Designer.cs +++ /dev/null @@ -1,130 +0,0 @@ -namespace TombIDE.ScriptingStudio.Settings -{ - partial class FormSaveSchemeAs - { - private System.ComponentModel.IContainer components = null; - - protected override void Dispose(bool disposing) - { - if (disposing && (components != null)) - { - components.Dispose(); - } - base.Dispose(disposing); - } - - #region Windows Form Designer generated code - - private void InitializeComponent() - { - this.button_Save = new DarkUI.Controls.DarkButton(); - this.button_Cancel = new DarkUI.Controls.DarkButton(); - this.label = new DarkUI.Controls.DarkLabel(); - this.panel_01 = new System.Windows.Forms.Panel(); - this.textBox_Name = new DarkUI.Controls.DarkTextBox(); - this.panel_02 = new System.Windows.Forms.Panel(); - this.panel_01.SuspendLayout(); - this.panel_02.SuspendLayout(); - this.SuspendLayout(); - // - // button_Save - // - this.button_Save.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); - this.button_Save.Checked = false; - this.button_Save.DialogResult = System.Windows.Forms.DialogResult.OK; - this.button_Save.Location = new System.Drawing.Point(300, 11); - this.button_Save.Margin = new System.Windows.Forms.Padding(3, 9, 0, 0); - this.button_Save.Name = "button_Save"; - this.button_Save.Size = new System.Drawing.Size(75, 23); - this.button_Save.TabIndex = 0; - this.button_Save.Text = "Save"; - this.button_Save.Click += new System.EventHandler(this.button_Save_Click); - // - // button_Cancel - // - this.button_Cancel.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); - this.button_Cancel.Checked = false; - this.button_Cancel.DialogResult = System.Windows.Forms.DialogResult.Cancel; - this.button_Cancel.Location = new System.Drawing.Point(381, 11); - this.button_Cancel.Margin = new System.Windows.Forms.Padding(3, 9, 0, 0); - this.button_Cancel.Name = "button_Cancel"; - this.button_Cancel.Size = new System.Drawing.Size(75, 23); - this.button_Cancel.TabIndex = 1; - this.button_Cancel.Text = "Cancel"; - // - // label - // - this.label.AutoSize = true; - this.label.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.label.Location = new System.Drawing.Point(3, 3); - this.label.Margin = new System.Windows.Forms.Padding(3, 3, 3, 0); - this.label.Name = "label"; - this.label.Size = new System.Drawing.Size(39, 13); - this.label.TabIndex = 0; - this.label.Text = "Name:"; - // - // panel_01 - // - this.panel_01.Controls.Add(this.textBox_Name); - this.panel_01.Controls.Add(this.label); - this.panel_01.Dock = System.Windows.Forms.DockStyle.Fill; - this.panel_01.Location = new System.Drawing.Point(0, 0); - this.panel_01.Margin = new System.Windows.Forms.Padding(0); - this.panel_01.Name = "panel_01"; - this.panel_01.Size = new System.Drawing.Size(464, 55); - this.panel_01.TabIndex = 0; - // - // textBox_Name - // - this.textBox_Name.Font = new System.Drawing.Font("Segoe UI", 12F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(238))); - this.textBox_Name.Location = new System.Drawing.Point(6, 19); - this.textBox_Name.Margin = new System.Windows.Forms.Padding(6, 3, 6, 6); - this.textBox_Name.Name = "textBox_Name"; - this.textBox_Name.Size = new System.Drawing.Size(450, 29); - this.textBox_Name.TabIndex = 1; - // - // panel_02 - // - this.panel_02.Controls.Add(this.button_Cancel); - this.panel_02.Controls.Add(this.button_Save); - this.panel_02.Dock = System.Windows.Forms.DockStyle.Bottom; - this.panel_02.Location = new System.Drawing.Point(0, 55); - this.panel_02.Name = "panel_02"; - this.panel_02.Size = new System.Drawing.Size(464, 42); - this.panel_02.TabIndex = 2; - // - // FormSaveSchemeAs - // - this.AcceptButton = this.button_Save; - this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); - this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; - this.CancelButton = this.button_Cancel; - this.ClientSize = new System.Drawing.Size(464, 97); - this.Controls.Add(this.panel_01); - this.Controls.Add(this.panel_02); - this.FlatBorder = true; - this.Font = new System.Drawing.Font("Segoe UI", 8.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(238))); - this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedSingle; - this.MaximizeBox = false; - this.MinimizeBox = false; - this.Name = "FormSaveSchemeAs"; - this.ShowIcon = false; - this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen; - this.Text = "Save Scheme As..."; - this.panel_01.ResumeLayout(false); - this.panel_01.PerformLayout(); - this.panel_02.ResumeLayout(false); - this.ResumeLayout(false); - - } - - #endregion - - private DarkUI.Controls.DarkButton button_Save; - private DarkUI.Controls.DarkButton button_Cancel; - private DarkUI.Controls.DarkLabel label; - private DarkUI.Controls.DarkTextBox textBox_Name; - private System.Windows.Forms.Panel panel_01; - private System.Windows.Forms.Panel panel_02; - } -} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/Settings/FormSaveSchemeAs.cs b/TombIDE/TombIDE.ScriptingStudio/Settings/FormSaveSchemeAs.cs deleted file mode 100644 index 5df6860f2a..0000000000 --- a/TombIDE/TombIDE.ScriptingStudio/Settings/FormSaveSchemeAs.cs +++ /dev/null @@ -1,115 +0,0 @@ -using DarkUI.Forms; -using System; -using System.IO; -using System.Linq; -using System.Windows.Forms; - -namespace TombIDE.ScriptingStudio.Settings -{ - internal enum ColorSchemeType - { - ClassicScript, - GameFlowScript, - Lua - } - - internal partial class FormSaveSchemeAs : DarkForm - { - // TODO: Refactor !!! - - public string SchemeFilePath { get; set; } - - private ColorSchemeType _schemeType; - - #region Construction - - public FormSaveSchemeAs(ColorSchemeType type) - { - InitializeComponent(); - - _schemeType = type; - } - - #endregion Construction - - #region Events - - protected override void OnShown(EventArgs e) - { - base.OnShown(e); - - textBox_Name.Text = "New Scheme"; - textBox_Name.SelectAll(); - } - - private void button_Save_Click(object sender, EventArgs e) - { - try - { - string newName = RemoveIllegalPathSymbols(textBox_Name.Text).Trim(); - - if (string.IsNullOrWhiteSpace(newName)) - throw new ArgumentException("Invalid name."); - - string schemeFilePath = null; - - switch (_schemeType) - { - case ColorSchemeType.ClassicScript: - { - string schemeFolderPath = DefaultPaths.ClassicScriptColorConfigsDirectory; - - foreach (string file in Directory.GetFiles(schemeFolderPath, "*.cssch", 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 + ".cssch"); - break; - } - case ColorSchemeType.GameFlowScript: - { - string schemeFolderPath = DefaultPaths.GameFlowColorConfigsDirectory; - - foreach (string file in Directory.GetFiles(schemeFolderPath, "*.gflsch", 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 + ".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; - } - } - - // // // // - SchemeFilePath = schemeFilePath; - // // // // - } - catch (Exception ex) - { - DarkMessageBox.Show(this, ex.Message, "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); - DialogResult = DialogResult.None; - } - } - - #endregion Events - - #region Methods - - public string RemoveIllegalPathSymbols(string fileName) - { - return Path.GetInvalidFileNameChars().Aggregate(fileName, (current, c) => current.Replace(c.ToString(), string.Empty)); - } - - #endregion Methods - } -} diff --git a/TombIDE/TombIDE.ScriptingStudio/Settings/FormSaveSchemeAs.resx b/TombIDE/TombIDE.ScriptingStudio/Settings/FormSaveSchemeAs.resx deleted file mode 100644 index 1af7de150c..0000000000 --- a/TombIDE/TombIDE.ScriptingStudio/Settings/FormSaveSchemeAs.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/TombIDE/TombIDE.ScriptingStudio/Settings/FormTextEditorSettings.Designer.cs b/TombIDE/TombIDE.ScriptingStudio/Settings/FormTextEditorSettings.Designer.cs deleted file mode 100644 index 8b445a5f22..0000000000 --- a/TombIDE/TombIDE.ScriptingStudio/Settings/FormTextEditorSettings.Designer.cs +++ /dev/null @@ -1,281 +0,0 @@ -namespace TombIDE.ScriptingStudio.Settings -{ - partial class FormTextEditorSettings - { - private System.ComponentModel.IContainer components = null; - - protected override void Dispose(bool disposing) - { - if (disposing && (components != null)) - { - components.Dispose(); - } - base.Dispose(disposing); - } - - #region Windows Form Designer generated code - - private void InitializeComponent() - { - this.button_Apply = new DarkUI.Controls.DarkButton(); - this.button_Cancel = new DarkUI.Controls.DarkButton(); - this.button_ResetDefault = new DarkUI.Controls.DarkButton(); - this.panel_Buttons = new System.Windows.Forms.Panel(); - this.panel_Main = new System.Windows.Forms.Panel(); - this.tablessTabControl = new TombLib.Controls.DarkTabbedContainer(); - this.tabPage_Global = new System.Windows.Forms.TabPage(); - this.tabPage_ClassicScript = new System.Windows.Forms.TabPage(); - this.settingsClassicScript = new TombIDE.ScriptingStudio.Settings.ClassicScriptSettingsControl(); - this.tabPage_GameFlow = new System.Windows.Forms.TabPage(); - this.settingsGameFlow = new TombIDE.ScriptingStudio.Settings.GameFlowSettingsControl(); - this.tabPage_Tomb1Main = new System.Windows.Forms.TabPage(); - this.settingsTomb1Main = new TombIDE.ScriptingStudio.Settings.Tomb1MainSettingsControl(); - this.treeView = new DarkUI.Controls.DarkTreeView(); - this.tabPage_Lua = new System.Windows.Forms.TabPage(); - this.settingsLua = new TombIDE.ScriptingStudio.Settings.LuaSettingsControl(); - this.panel_Buttons.SuspendLayout(); - this.panel_Main.SuspendLayout(); - this.tablessTabControl.SuspendLayout(); - this.tabPage_ClassicScript.SuspendLayout(); - this.tabPage_GameFlow.SuspendLayout(); - this.tabPage_Tomb1Main.SuspendLayout(); - this.tabPage_Lua.SuspendLayout(); - this.SuspendLayout(); - // - // button_Apply - // - this.button_Apply.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); - this.button_Apply.Checked = false; - this.button_Apply.DialogResult = System.Windows.Forms.DialogResult.OK; - this.button_Apply.Location = new System.Drawing.Point(726, 8); - this.button_Apply.Margin = new System.Windows.Forms.Padding(3, 6, 3, 6); - this.button_Apply.Name = "button_Apply"; - this.button_Apply.Size = new System.Drawing.Size(75, 24); - this.button_Apply.TabIndex = 1; - this.button_Apply.Text = "Apply"; - this.button_Apply.Click += new System.EventHandler(this.button_Apply_Click); - // - // button_Cancel - // - this.button_Cancel.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); - this.button_Cancel.Checked = false; - this.button_Cancel.DialogResult = System.Windows.Forms.DialogResult.Cancel; - this.button_Cancel.Location = new System.Drawing.Point(807, 8); - this.button_Cancel.Margin = new System.Windows.Forms.Padding(3, 6, 6, 6); - this.button_Cancel.Name = "button_Cancel"; - this.button_Cancel.Size = new System.Drawing.Size(75, 24); - this.button_Cancel.TabIndex = 0; - this.button_Cancel.Text = "Cancel"; - // - // button_ResetDefault - // - this.button_ResetDefault.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); - this.button_ResetDefault.Checked = false; - this.button_ResetDefault.Location = new System.Drawing.Point(6, 8); - this.button_ResetDefault.Margin = new System.Windows.Forms.Padding(6, 6, 0, 6); - this.button_ResetDefault.Name = "button_ResetDefault"; - this.button_ResetDefault.Size = new System.Drawing.Size(150, 24); - this.button_ResetDefault.TabIndex = 2; - this.button_ResetDefault.Text = "Reset settings to default"; - this.button_ResetDefault.Click += new System.EventHandler(this.button_ResetDefault_Click); - // - // panel_Buttons - // - this.panel_Buttons.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle; - this.panel_Buttons.Controls.Add(this.button_ResetDefault); - this.panel_Buttons.Controls.Add(this.button_Apply); - this.panel_Buttons.Controls.Add(this.button_Cancel); - this.panel_Buttons.Dock = System.Windows.Forms.DockStyle.Bottom; - this.panel_Buttons.Location = new System.Drawing.Point(0, 414); - this.panel_Buttons.Name = "panel_Buttons"; - this.panel_Buttons.Size = new System.Drawing.Size(890, 40); - this.panel_Buttons.TabIndex = 3; - // - // panel_Main - // - this.panel_Main.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle; - this.panel_Main.Controls.Add(this.tablessTabControl); - this.panel_Main.Controls.Add(this.treeView); - this.panel_Main.Dock = System.Windows.Forms.DockStyle.Fill; - this.panel_Main.Location = new System.Drawing.Point(0, 0); - this.panel_Main.Name = "panel_Main"; - this.panel_Main.Size = new System.Drawing.Size(890, 414); - this.panel_Main.TabIndex = 4; - // - // tablessTabControl - // - this.tablessTabControl.Controls.Add(this.tabPage_Global); - this.tablessTabControl.Controls.Add(this.tabPage_ClassicScript); - this.tablessTabControl.Controls.Add(this.tabPage_GameFlow); - this.tablessTabControl.Controls.Add(this.tabPage_Tomb1Main); - this.tablessTabControl.Controls.Add(this.tabPage_Lua); - this.tablessTabControl.Dock = System.Windows.Forms.DockStyle.Fill; - this.tablessTabControl.Location = new System.Drawing.Point(168, 0); - this.tablessTabControl.Name = "tablessTabControl"; - this.tablessTabControl.SelectedIndex = 0; - this.tablessTabControl.Size = new System.Drawing.Size(720, 412); - this.tablessTabControl.TabIndex = 4; - // - // tabPage_Global - // - this.tabPage_Global.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(60)))), ((int)(((byte)(63)))), ((int)(((byte)(65))))); - this.tabPage_Global.Location = new System.Drawing.Point(4, 22); - this.tabPage_Global.Name = "tabPage_Global"; - this.tabPage_Global.Size = new System.Drawing.Size(712, 386); - this.tabPage_Global.TabIndex = 0; - this.tabPage_Global.Text = "Global"; - // - // tabPage_ClassicScript - // - this.tabPage_ClassicScript.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(60)))), ((int)(((byte)(63)))), ((int)(((byte)(65))))); - this.tabPage_ClassicScript.Controls.Add(this.settingsClassicScript); - this.tabPage_ClassicScript.Location = new System.Drawing.Point(4, 22); - this.tabPage_ClassicScript.Name = "tabPage_ClassicScript"; - this.tabPage_ClassicScript.Size = new System.Drawing.Size(712, 386); - this.tabPage_ClassicScript.TabIndex = 1; - this.tabPage_ClassicScript.Text = "Classic Script"; - // - // settingsClassicScript - // - this.settingsClassicScript.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(63)))), ((int)(((byte)(65)))), ((int)(((byte)(69))))); - this.settingsClassicScript.Dock = System.Windows.Forms.DockStyle.Fill; - this.settingsClassicScript.Font = new System.Drawing.Font("Segoe UI", 8.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0))); - this.settingsClassicScript.Location = new System.Drawing.Point(0, 0); - this.settingsClassicScript.MaximumSize = new System.Drawing.Size(720, 412); - this.settingsClassicScript.MinimumSize = new System.Drawing.Size(720, 412); - this.settingsClassicScript.Name = "settingsClassicScript"; - this.settingsClassicScript.Size = new System.Drawing.Size(720, 412); - this.settingsClassicScript.TabIndex = 0; - // - // tabPage_GameFlow - // - this.tabPage_GameFlow.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(60)))), ((int)(((byte)(63)))), ((int)(((byte)(65))))); - this.tabPage_GameFlow.Controls.Add(this.settingsGameFlow); - this.tabPage_GameFlow.Location = new System.Drawing.Point(4, 22); - this.tabPage_GameFlow.Name = "tabPage_GameFlow"; - this.tabPage_GameFlow.Size = new System.Drawing.Size(712, 386); - this.tabPage_GameFlow.TabIndex = 2; - this.tabPage_GameFlow.Text = "Game Flow"; - // - // settingsGameFlow - // - this.settingsGameFlow.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(63)))), ((int)(((byte)(65)))), ((int)(((byte)(69))))); - this.settingsGameFlow.Dock = System.Windows.Forms.DockStyle.Fill; - this.settingsGameFlow.Font = new System.Drawing.Font("Segoe UI", 8.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0))); - this.settingsGameFlow.Location = new System.Drawing.Point(0, 0); - this.settingsGameFlow.MaximumSize = new System.Drawing.Size(720, 412); - this.settingsGameFlow.MinimumSize = new System.Drawing.Size(720, 412); - this.settingsGameFlow.Name = "settingsGameFlow"; - this.settingsGameFlow.Size = new System.Drawing.Size(720, 412); - this.settingsGameFlow.TabIndex = 0; - // - // tabPage_Tomb1Main - // - this.tabPage_Tomb1Main.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(60)))), ((int)(((byte)(63)))), ((int)(((byte)(65))))); - this.tabPage_Tomb1Main.Controls.Add(this.settingsTomb1Main); - this.tabPage_Tomb1Main.Location = new System.Drawing.Point(4, 22); - this.tabPage_Tomb1Main.Margin = new System.Windows.Forms.Padding(0); - this.tabPage_Tomb1Main.Name = "tabPage_Tomb1Main"; - this.tabPage_Tomb1Main.Size = new System.Drawing.Size(712, 386); - this.tabPage_Tomb1Main.TabIndex = 3; - this.tabPage_Tomb1Main.Text = "TR1X / TR2X"; - // - // settingsTomb1Main - // - this.settingsTomb1Main.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(63)))), ((int)(((byte)(65)))), ((int)(((byte)(69))))); - this.settingsTomb1Main.Dock = System.Windows.Forms.DockStyle.Fill; - this.settingsTomb1Main.Font = new System.Drawing.Font("Segoe UI", 8.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0))); - this.settingsTomb1Main.Location = new System.Drawing.Point(0, 0); - this.settingsTomb1Main.Margin = new System.Windows.Forms.Padding(0); - this.settingsTomb1Main.MaximumSize = new System.Drawing.Size(720, 412); - this.settingsTomb1Main.MinimumSize = new System.Drawing.Size(720, 412); - this.settingsTomb1Main.Name = "settingsTomb1Main"; - this.settingsTomb1Main.Size = new System.Drawing.Size(720, 412); - this.settingsTomb1Main.TabIndex = 0; - // - // treeView - // - this.treeView.Dock = System.Windows.Forms.DockStyle.Left; - this.treeView.ExpandOnDoubleClick = false; - this.treeView.Font = new System.Drawing.Font("Segoe UI", 9.75F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(238))); - this.treeView.ItemHeight = 48; - this.treeView.Location = new System.Drawing.Point(0, 0); - this.treeView.MaxDragChange = 48; - this.treeView.Name = "treeView"; - this.treeView.Size = new System.Drawing.Size(168, 412); - this.treeView.TabIndex = 3; - this.treeView.SelectedNodesChanged += new System.EventHandler(this.treeView_SelectedNodesChanged); - // - // tabPage_Lua - // - this.tabPage_Lua.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(60)))), ((int)(((byte)(63)))), ((int)(((byte)(65))))); - this.tabPage_Lua.Controls.Add(this.settingsLua); - this.tabPage_Lua.Location = new System.Drawing.Point(4, 22); - this.tabPage_Lua.Margin = new System.Windows.Forms.Padding(0); - this.tabPage_Lua.Name = "tabPage_Lua"; - this.tabPage_Lua.Size = new System.Drawing.Size(712, 386); - this.tabPage_Lua.TabIndex = 4; - this.tabPage_Lua.Text = "Lua"; - // - // settingsLua - // - this.settingsLua.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(63)))), ((int)(((byte)(65)))), ((int)(((byte)(69))))); - this.settingsLua.Dock = System.Windows.Forms.DockStyle.Fill; - this.settingsLua.Font = new System.Drawing.Font("Segoe UI", 8.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0))); - this.settingsLua.Location = new System.Drawing.Point(0, 0); - this.settingsLua.Margin = new System.Windows.Forms.Padding(0); - this.settingsLua.MaximumSize = new System.Drawing.Size(720, 412); - this.settingsLua.MinimumSize = new System.Drawing.Size(720, 412); - this.settingsLua.Name = "settingsLua"; - this.settingsLua.Size = new System.Drawing.Size(720, 412); - this.settingsLua.TabIndex = 1; - // - // FormTextEditorSettings - // - this.AcceptButton = this.button_Apply; - this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); - this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; - this.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(60)))), ((int)(((byte)(63)))), ((int)(((byte)(65))))); - this.CancelButton = this.button_Cancel; - this.ClientSize = new System.Drawing.Size(890, 454); - this.Controls.Add(this.panel_Main); - this.Controls.Add(this.panel_Buttons); - this.FlatBorder = true; - this.Font = new System.Drawing.Font("Segoe UI", 8.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(238))); - this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedSingle; - this.MaximizeBox = false; - this.Name = "FormTextEditorSettings"; - this.ShowIcon = false; - this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen; - this.Text = "Text Editor Settings"; - this.panel_Buttons.ResumeLayout(false); - this.panel_Main.ResumeLayout(false); - this.tablessTabControl.ResumeLayout(false); - this.tabPage_ClassicScript.ResumeLayout(false); - this.tabPage_GameFlow.ResumeLayout(false); - this.tabPage_Tomb1Main.ResumeLayout(false); - this.tabPage_Lua.ResumeLayout(false); - this.ResumeLayout(false); - - } - - #endregion - - private ClassicScriptSettingsControl settingsClassicScript; - private DarkUI.Controls.DarkButton button_Apply; - private DarkUI.Controls.DarkButton button_Cancel; - private DarkUI.Controls.DarkButton button_ResetDefault; - private DarkUI.Controls.DarkTreeView treeView; - private System.Windows.Forms.Panel panel_Buttons; - private System.Windows.Forms.Panel panel_Main; - private System.Windows.Forms.TabPage tabPage_ClassicScript; - private System.Windows.Forms.TabPage tabPage_Global; - private System.Windows.Forms.TabPage tabPage_GameFlow; - private TombLib.Controls.DarkTabbedContainer tablessTabControl; - private GameFlowSettingsControl settingsGameFlow; - private System.Windows.Forms.TabPage tabPage_Tomb1Main; - private Tomb1MainSettingsControl settingsTomb1Main; - private System.Windows.Forms.TabPage tabPage_Lua; - private LuaSettingsControl settingsLua; - } -} \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/Settings/FormTextEditorSettings.cs b/TombIDE/TombIDE.ScriptingStudio/Settings/FormTextEditorSettings.cs deleted file mode 100644 index 365f3fc85b..0000000000 --- a/TombIDE/TombIDE.ScriptingStudio/Settings/FormTextEditorSettings.cs +++ /dev/null @@ -1,115 +0,0 @@ -using DarkUI.Controls; -using DarkUI.Forms; -using System; -using System.Windows.Forms; -using TombIDE.ScriptingStudio.UI; - -namespace TombIDE.ScriptingStudio.Settings -{ - // TODO: Refactor - - public partial class FormTextEditorSettings : DarkForm - { - private ConfigurationCollection configs = new ConfigurationCollection(); - - public FormTextEditorSettings(StudioMode studioMode) - { - InitializeComponent(); - - settingsClassicScript.Initialize(configs.ClassicScript); - settingsGameFlow.Initialize(configs.GameFlowScript); - settingsTomb1Main.Initialize(configs.Tomb1Main); - settingsLua.Initialize(configs.Lua); - - var classicScriptNode = new DarkTreeNode("TR4 / TRNG Script"); - var gameFlowNode = new DarkTreeNode("TR2 / TR3 Script"); - var tomb1MainNode = new DarkTreeNode("TR1X / TR2X Script"); - var luaNode = new DarkTreeNode("Lua"); - - treeView.Nodes.Add(classicScriptNode); - treeView.Nodes.Add(gameFlowNode); - treeView.Nodes.Add(tomb1MainNode); - treeView.Nodes.Add(luaNode); - - if (studioMode == StudioMode.ClassicScript) - treeView.SelectNode(classicScriptNode); - else if (studioMode == StudioMode.GameFlowScript) - treeView.SelectNode(gameFlowNode); - else if (studioMode == StudioMode.Tomb1Main) - treeView.SelectNode(tomb1MainNode); - else if (studioMode == StudioMode.Lua) - treeView.SelectNode(luaNode); - } - - protected override void OnClosed(EventArgs e) - { - base.OnClosed(e); - - if (DialogResult == DialogResult.OK) - { - settingsClassicScript.ApplySettings(configs.ClassicScript); - settingsGameFlow.ApplySettings(configs.GameFlowScript); - settingsTomb1Main.ApplySettings(configs.Tomb1Main); - settingsLua.ApplySettings(configs.Lua); - } - else - { - configs.SaveAllConfigs(); - } - } - - private void button_Apply_Click(object sender, EventArgs e) - { - configs.SaveAllConfigs(); - } - - private void button_ResetDefault_Click(object sender, EventArgs e) - { - if (treeView.SelectedNodes.Count == 0) - return; - - DialogResult result = DarkMessageBox.Show(this, - "Are you sure you want to reset all settings for the selected language to default?", "Reset?", - MessageBoxButtons.YesNo, MessageBoxIcon.Warning); - - if (result == DialogResult.Yes) - { - if (treeView.SelectedNodes[0] == treeView.Nodes[0]) - settingsClassicScript.ResetToDefault(); - else if (treeView.SelectedNodes[0] == treeView.Nodes[1]) - settingsGameFlow.ResetToDefault(); - else if (treeView.SelectedNodes[0] == treeView.Nodes[2]) - settingsTomb1Main.ResetToDefault(); - else if (treeView.SelectedNodes[0] == treeView.Nodes[3]) - settingsLua.ResetToDefault(); - } - } - - private void treeView_SelectedNodesChanged(object sender, EventArgs e) - { - if (treeView.SelectedNodes.Count == 0) - return; - - if (treeView.SelectedNodes[0] == treeView.Nodes[0]) - { - tablessTabControl.SelectTab(1); - settingsClassicScript.ForcePreviewUpdate(); - } - else if (treeView.SelectedNodes[0] == treeView.Nodes[1]) - { - tablessTabControl.SelectTab(2); - settingsGameFlow.ForcePreviewUpdate(); - } - else if (treeView.SelectedNodes[0] == treeView.Nodes[2]) - { - tablessTabControl.SelectTab(3); - settingsTomb1Main.ForcePreviewUpdate(); - } - else if (treeView.SelectedNodes[0] == treeView.Nodes[3]) - { - tablessTabControl.SelectTab(4); - settingsLua.ForcePreviewUpdate(); - } - } - } -} diff --git a/TombIDE/TombIDE.ScriptingStudio/Settings/FormTextEditorSettings.resx b/TombIDE/TombIDE.ScriptingStudio/Settings/FormTextEditorSettings.resx deleted file mode 100644 index 1af7de150c..0000000000 --- a/TombIDE/TombIDE.ScriptingStudio/Settings/FormTextEditorSettings.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/TombIDE/TombIDE.ScriptingStudio/Settings/GameFlowSettingsControl.Designer.cs b/TombIDE/TombIDE.ScriptingStudio/Settings/GameFlowSettingsControl.Designer.cs deleted file mode 100644 index a150a9d265..0000000000 --- a/TombIDE/TombIDE.ScriptingStudio/Settings/GameFlowSettingsControl.Designer.cs +++ /dev/null @@ -1,679 +0,0 @@ -namespace TombIDE.ScriptingStudio.Settings -{ - partial class GameFlowSettingsControl - { - private System.ComponentModel.IContainer components = null; - - protected override void Dispose(bool disposing) - { - if (disposing && (components != null)) - { - components.Dispose(); - } - base.Dispose(disposing); - } - - #region Component Designer generated code - - private void InitializeComponent() - { - this.components = new System.ComponentModel.Container(); - this.button_ImportScheme = new DarkUI.Controls.DarkButton(); - this.buttonContextMenu = new DarkUI.Controls.DarkContextMenu(); - this.menuItem_Bold = new System.Windows.Forms.ToolStripMenuItem(); - this.menuItem_Italic = new System.Windows.Forms.ToolStripMenuItem(); - this.checkBox_Autocomplete = new DarkUI.Controls.DarkCheckBox(); - this.checkBox_HighlightCurrentLine = new DarkUI.Controls.DarkCheckBox(); - this.checkBox_LineNumbers = new DarkUI.Controls.DarkCheckBox(); - this.checkBox_VisibleSpaces = new DarkUI.Controls.DarkCheckBox(); - this.checkBox_VisibleTabs = new DarkUI.Controls.DarkCheckBox(); - this.checkBox_WordWrapping = new DarkUI.Controls.DarkCheckBox(); - this.colorButton_Background = new DarkUI.Controls.DarkButton(); - this.colorButton_Comments = new DarkUI.Controls.DarkButton(); - this.colorButton_Foreground = new DarkUI.Controls.DarkButton(); - this.colorButton_SpecialProperties = new DarkUI.Controls.DarkButton(); - this.colorButton_Constants = new DarkUI.Controls.DarkButton(); - this.colorButton_Sections = new DarkUI.Controls.DarkButton(); - this.colorButton_Properties = new DarkUI.Controls.DarkButton(); - this.colorButton_Values = new DarkUI.Controls.DarkButton(); - this.colorDialog = new System.Windows.Forms.ColorDialog(); - this.darkLabel1 = new DarkUI.Controls.DarkLabel(); - this.darkLabel10 = new DarkUI.Controls.DarkLabel(); - this.darkLabel11 = new DarkUI.Controls.DarkLabel(); - this.darkLabel12 = new DarkUI.Controls.DarkLabel(); - this.darkLabel2 = new DarkUI.Controls.DarkLabel(); - this.darkLabel3 = new DarkUI.Controls.DarkLabel(); - this.darkLabel4 = new DarkUI.Controls.DarkLabel(); - this.darkLabel5 = new DarkUI.Controls.DarkLabel(); - this.darkLabel6 = new DarkUI.Controls.DarkLabel(); - this.darkLabel7 = new DarkUI.Controls.DarkLabel(); - this.darkLabel8 = new DarkUI.Controls.DarkLabel(); - this.darkLabel9 = new DarkUI.Controls.DarkLabel(); - this.elementHost = new System.Windows.Forms.Integration.ElementHost(); - this.groupBox_Colors = new DarkUI.Controls.DarkGroupBox(); - this.button_SaveScheme = new DarkUI.Controls.DarkButton(); - this.button_DeleteScheme = new DarkUI.Controls.DarkButton(); - this.button_OpenSchemesFolder = new DarkUI.Controls.DarkButton(); - this.comboBox_ColorSchemes = new DarkUI.Controls.DarkComboBox(); - this.groupBox_Preview = new DarkUI.Controls.DarkGroupBox(); - this.numeric_FontSize = new DarkUI.Controls.DarkNumericUpDown(); - this.numeric_UndoStackSize = new DarkUI.Controls.DarkNumericUpDown(); - this.sectionPanel = new DarkUI.Controls.DarkSectionPanel(); - this.comboBox_FontFamily = new DarkUI.Controls.DarkComboBox(); - this.toolTip = new System.Windows.Forms.ToolTip(this.components); - this.buttonContextMenu.SuspendLayout(); - this.groupBox_Colors.SuspendLayout(); - this.groupBox_Preview.SuspendLayout(); - ((System.ComponentModel.ISupportInitialize)(this.numeric_FontSize)).BeginInit(); - ((System.ComponentModel.ISupportInitialize)(this.numeric_UndoStackSize)).BeginInit(); - this.sectionPanel.SuspendLayout(); - this.SuspendLayout(); - // - // button_ImportScheme - // - this.button_ImportScheme.Checked = false; - this.button_ImportScheme.Image = global::TombIDE.ScriptingStudio.Properties.Resources.Import_16; - this.button_ImportScheme.Location = new System.Drawing.Point(483, 16); - this.button_ImportScheme.Margin = new System.Windows.Forms.Padding(3, 0, 3, 3); - this.button_ImportScheme.Name = "button_ImportScheme"; - this.button_ImportScheme.Size = new System.Drawing.Size(25, 25); - this.button_ImportScheme.TabIndex = 21; - this.toolTip.SetToolTip(this.button_ImportScheme, "Import Scheme..."); - this.button_ImportScheme.Click += new System.EventHandler(this.button_ImportScheme_Click); - // - // buttonContextMenu - // - this.buttonContextMenu.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(60)))), ((int)(((byte)(63)))), ((int)(((byte)(65))))); - this.buttonContextMenu.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.buttonContextMenu.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { - this.menuItem_Bold, - this.menuItem_Italic}); - this.buttonContextMenu.Name = "buttonContextMenu"; - this.buttonContextMenu.Size = new System.Drawing.Size(100, 48); - this.buttonContextMenu.Opening += new System.ComponentModel.CancelEventHandler(this.buttonContextMenu_Opening); - // - // menuItem_Bold - // - this.menuItem_Bold.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(60)))), ((int)(((byte)(63)))), ((int)(((byte)(65))))); - this.menuItem_Bold.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.menuItem_Bold.Name = "menuItem_Bold"; - this.menuItem_Bold.Size = new System.Drawing.Size(99, 22); - this.menuItem_Bold.Text = "Bold"; - this.menuItem_Bold.Click += new System.EventHandler(this.menuItem_Bold_Click); - // - // menuItem_Italic - // - this.menuItem_Italic.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(60)))), ((int)(((byte)(63)))), ((int)(((byte)(65))))); - this.menuItem_Italic.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.menuItem_Italic.Name = "menuItem_Italic"; - this.menuItem_Italic.Size = new System.Drawing.Size(99, 22); - this.menuItem_Italic.Text = "Italic"; - this.menuItem_Italic.Click += new System.EventHandler(this.menuItem_Italic_Click); - // - // checkBox_Autocomplete - // - this.checkBox_Autocomplete.AutoSize = true; - this.checkBox_Autocomplete.Location = new System.Drawing.Point(6, 164); - this.checkBox_Autocomplete.Margin = new System.Windows.Forms.Padding(6, 6, 3, 0); - this.checkBox_Autocomplete.Name = "checkBox_Autocomplete"; - this.checkBox_Autocomplete.Size = new System.Drawing.Size(135, 17); - this.checkBox_Autocomplete.TabIndex = 6; - this.checkBox_Autocomplete.Text = "Enable autocomplete"; - // - // checkBox_HighlightCurrentLine - // - this.checkBox_HighlightCurrentLine.AutoSize = true; - this.checkBox_HighlightCurrentLine.Location = new System.Drawing.Point(6, 242); - this.checkBox_HighlightCurrentLine.Margin = new System.Windows.Forms.Padding(3, 9, 3, 0); - this.checkBox_HighlightCurrentLine.Name = "checkBox_HighlightCurrentLine"; - this.checkBox_HighlightCurrentLine.Size = new System.Drawing.Size(137, 17); - this.checkBox_HighlightCurrentLine.TabIndex = 11; - this.checkBox_HighlightCurrentLine.Text = "Highlight current line"; - this.checkBox_HighlightCurrentLine.CheckedChanged += new System.EventHandler(this.VisiblePreviewSetting_Changed); - // - // checkBox_LineNumbers - // - this.checkBox_LineNumbers.AutoSize = true; - this.checkBox_LineNumbers.Location = new System.Drawing.Point(6, 281); - this.checkBox_LineNumbers.Margin = new System.Windows.Forms.Padding(3, 9, 3, 0); - this.checkBox_LineNumbers.Name = "checkBox_LineNumbers"; - this.checkBox_LineNumbers.Size = new System.Drawing.Size(125, 17); - this.checkBox_LineNumbers.TabIndex = 12; - this.checkBox_LineNumbers.Text = "Show line numbers"; - this.checkBox_LineNumbers.CheckedChanged += new System.EventHandler(this.VisiblePreviewSetting_Changed); - // - // checkBox_VisibleSpaces - // - this.checkBox_VisibleSpaces.AutoSize = true; - this.checkBox_VisibleSpaces.Location = new System.Drawing.Point(6, 320); - this.checkBox_VisibleSpaces.Margin = new System.Windows.Forms.Padding(3, 9, 3, 0); - this.checkBox_VisibleSpaces.Name = "checkBox_VisibleSpaces"; - this.checkBox_VisibleSpaces.Size = new System.Drawing.Size(127, 17); - this.checkBox_VisibleSpaces.TabIndex = 14; - this.checkBox_VisibleSpaces.Text = "Show visible spaces"; - this.checkBox_VisibleSpaces.CheckedChanged += new System.EventHandler(this.VisiblePreviewSetting_Changed); - // - // checkBox_VisibleTabs - // - this.checkBox_VisibleTabs.AutoSize = true; - this.checkBox_VisibleTabs.Location = new System.Drawing.Point(6, 359); - this.checkBox_VisibleTabs.Margin = new System.Windows.Forms.Padding(3, 0, 3, 0); - this.checkBox_VisibleTabs.Name = "checkBox_VisibleTabs"; - this.checkBox_VisibleTabs.Size = new System.Drawing.Size(115, 17); - this.checkBox_VisibleTabs.TabIndex = 15; - this.checkBox_VisibleTabs.Text = "Show visible tabs"; - this.checkBox_VisibleTabs.CheckedChanged += new System.EventHandler(this.VisiblePreviewSetting_Changed); - // - // checkBox_WordWrapping - // - this.checkBox_WordWrapping.AutoSize = true; - this.checkBox_WordWrapping.Location = new System.Drawing.Point(6, 203); - this.checkBox_WordWrapping.Margin = new System.Windows.Forms.Padding(3, 9, 3, 0); - this.checkBox_WordWrapping.Name = "checkBox_WordWrapping"; - this.checkBox_WordWrapping.Size = new System.Drawing.Size(108, 17); - this.checkBox_WordWrapping.TabIndex = 10; - this.checkBox_WordWrapping.Text = "Word wrapping"; - this.checkBox_WordWrapping.CheckedChanged += new System.EventHandler(this.VisiblePreviewSetting_Changed); - // - // colorButton_Background - // - this.colorButton_Background.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(32)))), ((int)(((byte)(32)))), ((int)(((byte)(32))))); - this.colorButton_Background.BackColorUseGeneric = false; - this.colorButton_Background.Checked = false; - this.colorButton_Background.Location = new System.Drawing.Point(370, 59); - this.colorButton_Background.Margin = new System.Windows.Forms.Padding(6, 0, 9, 3); - this.colorButton_Background.Name = "colorButton_Background"; - this.colorButton_Background.Size = new System.Drawing.Size(169, 25); - this.colorButton_Background.TabIndex = 14; - this.colorButton_Background.Click += new System.EventHandler(this.button_Color_Click); - // - // colorButton_Comments - // - this.colorButton_Comments.BackColor = System.Drawing.Color.Green; - this.colorButton_Comments.BackColorUseGeneric = false; - this.colorButton_Comments.Checked = false; - this.colorButton_Comments.ContextMenuStrip = this.buttonContextMenu; - this.colorButton_Comments.Font = new System.Drawing.Font("Segoe UI", 9.75F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(238))); - this.colorButton_Comments.Location = new System.Drawing.Point(191, 141); - this.colorButton_Comments.Margin = new System.Windows.Forms.Padding(3, 0, 3, 3); - this.colorButton_Comments.Name = "colorButton_Comments"; - this.colorButton_Comments.Size = new System.Drawing.Size(170, 25); - this.colorButton_Comments.TabIndex = 11; - this.colorButton_Comments.UseForeColor = true; - this.colorButton_Comments.Click += new System.EventHandler(this.button_Color_Click); - // - // colorButton_Foreground - // - this.colorButton_Foreground.BackColor = System.Drawing.Color.Gainsboro; - this.colorButton_Foreground.BackColorUseGeneric = false; - this.colorButton_Foreground.Checked = false; - this.colorButton_Foreground.Location = new System.Drawing.Point(370, 100); - this.colorButton_Foreground.Margin = new System.Windows.Forms.Padding(6, 0, 9, 3); - this.colorButton_Foreground.Name = "colorButton_Foreground"; - this.colorButton_Foreground.Size = new System.Drawing.Size(169, 25); - this.colorButton_Foreground.TabIndex = 16; - this.colorButton_Foreground.Click += new System.EventHandler(this.button_Color_Click); - // - // colorButton_SpecialProperties - // - this.colorButton_SpecialProperties.BackColor = System.Drawing.Color.SpringGreen; - this.colorButton_SpecialProperties.BackColorUseGeneric = false; - this.colorButton_SpecialProperties.Checked = false; - this.colorButton_SpecialProperties.ContextMenuStrip = this.buttonContextMenu; - this.colorButton_SpecialProperties.Font = new System.Drawing.Font("Segoe UI", 9.75F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(238))); - this.colorButton_SpecialProperties.Location = new System.Drawing.Point(191, 100); - this.colorButton_SpecialProperties.Margin = new System.Windows.Forms.Padding(3, 0, 3, 3); - this.colorButton_SpecialProperties.Name = "colorButton_SpecialProperties"; - this.colorButton_SpecialProperties.Size = new System.Drawing.Size(170, 25); - this.colorButton_SpecialProperties.TabIndex = 9; - this.colorButton_SpecialProperties.UseForeColor = true; - this.colorButton_SpecialProperties.Click += new System.EventHandler(this.button_Color_Click); - // - // colorButton_Constants - // - this.colorButton_Constants.BackColor = System.Drawing.Color.Orchid; - this.colorButton_Constants.BackColorUseGeneric = false; - this.colorButton_Constants.Checked = false; - this.colorButton_Constants.ContextMenuStrip = this.buttonContextMenu; - this.colorButton_Constants.Font = new System.Drawing.Font("Segoe UI", 9.75F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(238))); - this.colorButton_Constants.Location = new System.Drawing.Point(12, 141); - this.colorButton_Constants.Margin = new System.Windows.Forms.Padding(9, 0, 3, 8); - this.colorButton_Constants.Name = "colorButton_Constants"; - this.colorButton_Constants.Size = new System.Drawing.Size(170, 25); - this.colorButton_Constants.TabIndex = 5; - this.colorButton_Constants.UseForeColor = true; - this.colorButton_Constants.Click += new System.EventHandler(this.button_Color_Click); - // - // colorButton_Sections - // - this.colorButton_Sections.BackColor = System.Drawing.Color.SteelBlue; - this.colorButton_Sections.BackColorUseGeneric = false; - this.colorButton_Sections.Checked = false; - this.colorButton_Sections.ContextMenuStrip = this.buttonContextMenu; - this.colorButton_Sections.Font = new System.Drawing.Font("Segoe UI", 9.75F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(238))); - this.colorButton_Sections.Location = new System.Drawing.Point(12, 59); - this.colorButton_Sections.Margin = new System.Windows.Forms.Padding(9, 0, 3, 3); - this.colorButton_Sections.Name = "colorButton_Sections"; - this.colorButton_Sections.Size = new System.Drawing.Size(170, 25); - this.colorButton_Sections.TabIndex = 1; - this.colorButton_Sections.UseForeColor = true; - this.colorButton_Sections.Click += new System.EventHandler(this.button_Color_Click); - // - // colorButton_Properties - // - this.colorButton_Properties.BackColor = System.Drawing.Color.MediumAquamarine; - this.colorButton_Properties.BackColorUseGeneric = false; - this.colorButton_Properties.Checked = false; - this.colorButton_Properties.ContextMenuStrip = this.buttonContextMenu; - this.colorButton_Properties.Font = new System.Drawing.Font("Segoe UI", 9.75F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(238))); - this.colorButton_Properties.Location = new System.Drawing.Point(191, 59); - this.colorButton_Properties.Margin = new System.Windows.Forms.Padding(6, 0, 3, 3); - this.colorButton_Properties.Name = "colorButton_Properties"; - this.colorButton_Properties.Size = new System.Drawing.Size(170, 25); - this.colorButton_Properties.TabIndex = 7; - this.colorButton_Properties.UseForeColor = true; - this.colorButton_Properties.Click += new System.EventHandler(this.button_Color_Click); - // - // colorButton_Values - // - this.colorButton_Values.BackColor = System.Drawing.Color.LightSalmon; - this.colorButton_Values.BackColorUseGeneric = false; - this.colorButton_Values.Checked = false; - this.colorButton_Values.ContextMenuStrip = this.buttonContextMenu; - this.colorButton_Values.Font = new System.Drawing.Font("Segoe UI", 9.75F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(238))); - this.colorButton_Values.Location = new System.Drawing.Point(12, 100); - this.colorButton_Values.Margin = new System.Windows.Forms.Padding(3, 0, 3, 3); - this.colorButton_Values.Name = "colorButton_Values"; - this.colorButton_Values.Size = new System.Drawing.Size(170, 25); - this.colorButton_Values.TabIndex = 3; - this.colorButton_Values.UseForeColor = true; - this.colorButton_Values.Click += new System.EventHandler(this.button_Color_Click); - // - // colorDialog - // - this.colorDialog.AnyColor = true; - this.colorDialog.FullOpen = true; - // - // darkLabel1 - // - this.darkLabel1.AutoSize = true; - this.darkLabel1.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.darkLabel1.Location = new System.Drawing.Point(7, 34); - this.darkLabel1.Margin = new System.Windows.Forms.Padding(6, 9, 3, 0); - this.darkLabel1.Name = "darkLabel1"; - this.darkLabel1.Size = new System.Drawing.Size(56, 13); - this.darkLabel1.TabIndex = 0; - this.darkLabel1.Text = "Font size:"; - // - // darkLabel10 - // - this.darkLabel10.AutoSize = true; - this.darkLabel10.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.darkLabel10.Location = new System.Drawing.Point(370, 46); - this.darkLabel10.Margin = new System.Windows.Forms.Padding(3, 3, 3, 0); - this.darkLabel10.Name = "darkLabel10"; - this.darkLabel10.Size = new System.Drawing.Size(72, 13); - this.darkLabel10.TabIndex = 13; - this.darkLabel10.Text = "Background:"; - // - // darkLabel11 - // - this.darkLabel11.AutoSize = true; - this.darkLabel11.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.darkLabel11.Location = new System.Drawing.Point(370, 87); - this.darkLabel11.Name = "darkLabel11"; - this.darkLabel11.Size = new System.Drawing.Size(98, 13); - this.darkLabel11.TabIndex = 15; - this.darkLabel11.Text = "Normal text color:"; - // - // darkLabel12 - // - this.darkLabel12.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle; - this.darkLabel12.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.darkLabel12.Location = new System.Drawing.Point(475, 17); - this.darkLabel12.Name = "darkLabel12"; - this.darkLabel12.Size = new System.Drawing.Size(2, 23); - this.darkLabel12.TabIndex = 20; - // - // darkLabel2 - // - this.darkLabel2.AutoSize = true; - this.darkLabel2.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.darkLabel2.Location = new System.Drawing.Point(7, 76); - this.darkLabel2.Margin = new System.Windows.Forms.Padding(6, 3, 3, 0); - this.darkLabel2.Name = "darkLabel2"; - this.darkLabel2.Size = new System.Drawing.Size(67, 13); - this.darkLabel2.TabIndex = 2; - this.darkLabel2.Text = "Font family:"; - // - // darkLabel3 - // - this.darkLabel3.AutoSize = true; - this.darkLabel3.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.darkLabel3.Location = new System.Drawing.Point(7, 119); - this.darkLabel3.Margin = new System.Windows.Forms.Padding(6, 3, 3, 0); - this.darkLabel3.Name = "darkLabel3"; - this.darkLabel3.Size = new System.Drawing.Size(90, 13); - this.darkLabel3.TabIndex = 4; - this.darkLabel3.Text = "Undo stack size:"; - // - // darkLabel4 - // - this.darkLabel4.AutoSize = true; - this.darkLabel4.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.darkLabel4.Location = new System.Drawing.Point(12, 46); - this.darkLabel4.Margin = new System.Windows.Forms.Padding(9, 3, 3, 0); - this.darkLabel4.Name = "darkLabel4"; - this.darkLabel4.Size = new System.Drawing.Size(53, 13); - this.darkLabel4.TabIndex = 0; - this.darkLabel4.Text = "Sections:"; - // - // darkLabel5 - // - this.darkLabel5.AutoSize = true; - this.darkLabel5.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.darkLabel5.Location = new System.Drawing.Point(12, 87); - this.darkLabel5.Name = "darkLabel5"; - this.darkLabel5.Size = new System.Drawing.Size(43, 13); - this.darkLabel5.TabIndex = 2; - this.darkLabel5.Text = "Values:"; - // - // darkLabel6 - // - this.darkLabel6.AutoSize = true; - this.darkLabel6.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.darkLabel6.Location = new System.Drawing.Point(12, 128); - this.darkLabel6.Name = "darkLabel6"; - this.darkLabel6.Size = new System.Drawing.Size(62, 13); - this.darkLabel6.TabIndex = 4; - this.darkLabel6.Text = "Constants:"; - // - // darkLabel7 - // - this.darkLabel7.AutoSize = true; - this.darkLabel7.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.darkLabel7.Location = new System.Drawing.Point(190, 46); - this.darkLabel7.Margin = new System.Windows.Forms.Padding(3, 3, 3, 0); - this.darkLabel7.Name = "darkLabel7"; - this.darkLabel7.Size = new System.Drawing.Size(62, 13); - this.darkLabel7.TabIndex = 6; - this.darkLabel7.Text = "Properties:"; - // - // darkLabel8 - // - this.darkLabel8.AutoSize = true; - this.darkLabel8.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.darkLabel8.Location = new System.Drawing.Point(190, 87); - this.darkLabel8.Name = "darkLabel8"; - this.darkLabel8.Size = new System.Drawing.Size(101, 13); - this.darkLabel8.TabIndex = 8; - this.darkLabel8.Text = "Special Properties:"; - // - // darkLabel9 - // - this.darkLabel9.AutoSize = true; - this.darkLabel9.ForeColor = System.Drawing.Color.FromArgb(((int)(((byte)(220)))), ((int)(((byte)(220)))), ((int)(((byte)(220))))); - this.darkLabel9.Location = new System.Drawing.Point(190, 128); - this.darkLabel9.Name = "darkLabel9"; - this.darkLabel9.Size = new System.Drawing.Size(64, 13); - this.darkLabel9.TabIndex = 10; - this.darkLabel9.Text = "Comments:"; - // - // elementHost - // - this.elementHost.Dock = System.Windows.Forms.DockStyle.Fill; - this.elementHost.Location = new System.Drawing.Point(3, 18); - this.elementHost.Name = "elementHost"; - this.elementHost.Size = new System.Drawing.Size(545, 170); - this.elementHost.TabIndex = 0; - this.elementHost.Child = null; - // - // groupBox_Colors - // - this.groupBox_Colors.Controls.Add(this.button_ImportScheme); - this.groupBox_Colors.Controls.Add(this.darkLabel12); - this.groupBox_Colors.Controls.Add(this.button_SaveScheme); - this.groupBox_Colors.Controls.Add(this.button_DeleteScheme); - this.groupBox_Colors.Controls.Add(this.button_OpenSchemesFolder); - this.groupBox_Colors.Controls.Add(this.darkLabel11); - this.groupBox_Colors.Controls.Add(this.colorButton_Foreground); - this.groupBox_Colors.Controls.Add(this.darkLabel10); - this.groupBox_Colors.Controls.Add(this.colorButton_Background); - this.groupBox_Colors.Controls.Add(this.comboBox_ColorSchemes); - this.groupBox_Colors.Controls.Add(this.darkLabel9); - this.groupBox_Colors.Controls.Add(this.darkLabel8); - this.groupBox_Colors.Controls.Add(this.darkLabel7); - this.groupBox_Colors.Controls.Add(this.darkLabel6); - this.groupBox_Colors.Controls.Add(this.darkLabel5); - this.groupBox_Colors.Controls.Add(this.darkLabel4); - this.groupBox_Colors.Controls.Add(this.colorButton_Properties); - this.groupBox_Colors.Controls.Add(this.colorButton_SpecialProperties); - this.groupBox_Colors.Controls.Add(this.colorButton_Sections); - this.groupBox_Colors.Controls.Add(this.colorButton_Values); - this.groupBox_Colors.Controls.Add(this.colorButton_Constants); - this.groupBox_Colors.Controls.Add(this.colorButton_Comments); - this.groupBox_Colors.Location = new System.Drawing.Point(162, 28); - this.groupBox_Colors.Margin = new System.Windows.Forms.Padding(3, 3, 6, 3); - this.groupBox_Colors.Name = "groupBox_Colors"; - this.groupBox_Colors.Size = new System.Drawing.Size(551, 177); - this.groupBox_Colors.TabIndex = 17; - this.groupBox_Colors.TabStop = false; - this.groupBox_Colors.Text = "Color schemes"; - // - // button_SaveScheme - // - this.button_SaveScheme.Checked = false; - this.button_SaveScheme.Image = global::TombIDE.ScriptingStudio.Properties.Resources.Save_16; - this.button_SaveScheme.Location = new System.Drawing.Point(413, 16); - this.button_SaveScheme.Name = "button_SaveScheme"; - this.button_SaveScheme.Size = new System.Drawing.Size(25, 25); - this.button_SaveScheme.TabIndex = 19; - this.toolTip.SetToolTip(this.button_SaveScheme, "Save Scheme As..."); - this.button_SaveScheme.Click += new System.EventHandler(this.button_SaveScheme_Click); - // - // button_DeleteScheme - // - this.button_DeleteScheme.Checked = false; - this.button_DeleteScheme.Image = global::TombIDE.ScriptingStudio.Properties.Resources.Trash_16; - this.button_DeleteScheme.Location = new System.Drawing.Point(444, 16); - this.button_DeleteScheme.Name = "button_DeleteScheme"; - this.button_DeleteScheme.Size = new System.Drawing.Size(25, 25); - this.button_DeleteScheme.TabIndex = 18; - this.toolTip.SetToolTip(this.button_DeleteScheme, "Delete Scheme"); - this.button_DeleteScheme.Click += new System.EventHandler(this.button_DeleteScheme_Click); - // - // button_OpenSchemesFolder - // - this.button_OpenSchemesFolder.Checked = false; - this.button_OpenSchemesFolder.Image = global::TombIDE.ScriptingStudio.Properties.Resources.ForwardArrow_16; - this.button_OpenSchemesFolder.Location = new System.Drawing.Point(514, 16); - this.button_OpenSchemesFolder.Margin = new System.Windows.Forms.Padding(3, 0, 3, 3); - this.button_OpenSchemesFolder.Name = "button_OpenSchemesFolder"; - this.button_OpenSchemesFolder.Size = new System.Drawing.Size(25, 25); - this.button_OpenSchemesFolder.TabIndex = 17; - this.toolTip.SetToolTip(this.button_OpenSchemesFolder, "Open Schemes Folder"); - this.button_OpenSchemesFolder.Click += new System.EventHandler(this.button_OpenSchemesFolder_Click); - // - // comboBox_ColorSchemes - // - this.comboBox_ColorSchemes.FormattingEnabled = true; - this.comboBox_ColorSchemes.Location = new System.Drawing.Point(12, 19); - this.comboBox_ColorSchemes.Margin = new System.Windows.Forms.Padding(9, 3, 3, 3); - this.comboBox_ColorSchemes.Name = "comboBox_ColorSchemes"; - this.comboBox_ColorSchemes.Size = new System.Drawing.Size(395, 23); - this.comboBox_ColorSchemes.TabIndex = 12; - this.comboBox_ColorSchemes.SelectedIndexChanged += new System.EventHandler(this.comboBox_ColorSchemes_SelectedIndexChanged); - // - // groupBox_Preview - // - this.groupBox_Preview.Controls.Add(this.elementHost); - this.groupBox_Preview.Location = new System.Drawing.Point(162, 214); - this.groupBox_Preview.Margin = new System.Windows.Forms.Padding(3, 6, 6, 6); - this.groupBox_Preview.Name = "groupBox_Preview"; - this.groupBox_Preview.Size = new System.Drawing.Size(551, 191); - this.groupBox_Preview.TabIndex = 2; - this.groupBox_Preview.TabStop = false; - this.groupBox_Preview.Text = "Preview"; - // - // numeric_FontSize - // - this.numeric_FontSize.IncrementAlternate = new decimal(new int[] { - 10, - 0, - 0, - 65536}); - this.numeric_FontSize.Location = new System.Drawing.Point(6, 50); - this.numeric_FontSize.LoopValues = false; - this.numeric_FontSize.Margin = new System.Windows.Forms.Padding(6, 3, 3, 3); - this.numeric_FontSize.Maximum = new decimal(new int[] { - 32, - 0, - 0, - 0}); - this.numeric_FontSize.Minimum = new decimal(new int[] { - 4, - 0, - 0, - 0}); - this.numeric_FontSize.Name = "numeric_FontSize"; - this.numeric_FontSize.Size = new System.Drawing.Size(150, 22); - this.numeric_FontSize.TabIndex = 1; - this.numeric_FontSize.Value = new decimal(new int[] { - 12, - 0, - 0, - 0}); - this.numeric_FontSize.ValueChanged += new System.EventHandler(this.VisiblePreviewSetting_Changed); - // - // numeric_UndoStackSize - // - this.numeric_UndoStackSize.IncrementAlternate = new decimal(new int[] { - 10, - 0, - 0, - 65536}); - this.numeric_UndoStackSize.Location = new System.Drawing.Point(6, 135); - this.numeric_UndoStackSize.LoopValues = false; - this.numeric_UndoStackSize.Margin = new System.Windows.Forms.Padding(6, 3, 3, 3); - this.numeric_UndoStackSize.Maximum = new decimal(new int[] { - 1024, - 0, - 0, - 0}); - this.numeric_UndoStackSize.Minimum = new decimal(new int[] { - 16, - 0, - 0, - 0}); - this.numeric_UndoStackSize.Name = "numeric_UndoStackSize"; - this.numeric_UndoStackSize.Size = new System.Drawing.Size(150, 22); - this.numeric_UndoStackSize.TabIndex = 5; - this.numeric_UndoStackSize.Value = new decimal(new int[] { - 256, - 0, - 0, - 0}); - // - // sectionPanel - // - this.sectionPanel.Controls.Add(this.groupBox_Preview); - this.sectionPanel.Controls.Add(this.checkBox_HighlightCurrentLine); - this.sectionPanel.Controls.Add(this.checkBox_VisibleTabs); - this.sectionPanel.Controls.Add(this.checkBox_VisibleSpaces); - this.sectionPanel.Controls.Add(this.checkBox_LineNumbers); - this.sectionPanel.Controls.Add(this.darkLabel3); - this.sectionPanel.Controls.Add(this.darkLabel2); - this.sectionPanel.Controls.Add(this.darkLabel1); - this.sectionPanel.Controls.Add(this.numeric_UndoStackSize); - this.sectionPanel.Controls.Add(this.checkBox_Autocomplete); - this.sectionPanel.Controls.Add(this.checkBox_WordWrapping); - this.sectionPanel.Controls.Add(this.groupBox_Colors); - this.sectionPanel.Controls.Add(this.comboBox_FontFamily); - this.sectionPanel.Controls.Add(this.numeric_FontSize); - this.sectionPanel.Dock = System.Windows.Forms.DockStyle.Fill; - this.sectionPanel.Location = new System.Drawing.Point(0, 0); - this.sectionPanel.Name = "sectionPanel"; - this.sectionPanel.SectionHeader = "TR2 / TR3 Script"; - this.sectionPanel.Size = new System.Drawing.Size(720, 412); - this.sectionPanel.TabIndex = 0; - // - // comboBox_FontFamily - // - this.comboBox_FontFamily.FormattingEnabled = true; - this.comboBox_FontFamily.Location = new System.Drawing.Point(6, 92); - this.comboBox_FontFamily.Margin = new System.Windows.Forms.Padding(6, 3, 3, 3); - this.comboBox_FontFamily.Name = "comboBox_FontFamily"; - this.comboBox_FontFamily.Size = new System.Drawing.Size(150, 23); - this.comboBox_FontFamily.TabIndex = 3; - this.comboBox_FontFamily.SelectedIndexChanged += new System.EventHandler(this.comboBox_FontFamily_SelectedIndexChanged); - // - // GameFlowSettingsControl - // - this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); - this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; - this.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(63)))), ((int)(((byte)(65)))), ((int)(((byte)(69))))); - this.Controls.Add(this.sectionPanel); - this.Font = new System.Drawing.Font("Segoe UI", 8.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0))); - this.MaximumSize = new System.Drawing.Size(720, 412); - this.MinimumSize = new System.Drawing.Size(720, 412); - this.Name = "GameFlowSettingsControl"; - this.Size = new System.Drawing.Size(720, 412); - this.buttonContextMenu.ResumeLayout(false); - this.groupBox_Colors.ResumeLayout(false); - this.groupBox_Colors.PerformLayout(); - this.groupBox_Preview.ResumeLayout(false); - ((System.ComponentModel.ISupportInitialize)(this.numeric_FontSize)).EndInit(); - ((System.ComponentModel.ISupportInitialize)(this.numeric_UndoStackSize)).EndInit(); - this.sectionPanel.ResumeLayout(false); - this.sectionPanel.PerformLayout(); - this.ResumeLayout(false); - - } - - #endregion - - private DarkUI.Controls.DarkButton button_DeleteScheme; - private DarkUI.Controls.DarkButton button_ImportScheme; - private DarkUI.Controls.DarkButton button_OpenSchemesFolder; - private DarkUI.Controls.DarkButton button_SaveScheme; - private DarkUI.Controls.DarkButton colorButton_Background; - private DarkUI.Controls.DarkButton colorButton_Comments; - private DarkUI.Controls.DarkButton colorButton_Foreground; - private DarkUI.Controls.DarkButton colorButton_SpecialProperties; - private DarkUI.Controls.DarkButton colorButton_Constants; - private DarkUI.Controls.DarkButton colorButton_Sections; - private DarkUI.Controls.DarkButton colorButton_Properties; - private DarkUI.Controls.DarkButton colorButton_Values; - private DarkUI.Controls.DarkCheckBox checkBox_Autocomplete; - private DarkUI.Controls.DarkCheckBox checkBox_HighlightCurrentLine; - private DarkUI.Controls.DarkCheckBox checkBox_LineNumbers; - private DarkUI.Controls.DarkCheckBox checkBox_VisibleSpaces; - private DarkUI.Controls.DarkCheckBox checkBox_VisibleTabs; - private DarkUI.Controls.DarkCheckBox checkBox_WordWrapping; - private DarkUI.Controls.DarkComboBox comboBox_ColorSchemes; - private DarkUI.Controls.DarkComboBox comboBox_FontFamily; - private DarkUI.Controls.DarkContextMenu buttonContextMenu; - private DarkUI.Controls.DarkGroupBox groupBox_Colors; - private DarkUI.Controls.DarkGroupBox groupBox_Preview; - private DarkUI.Controls.DarkLabel darkLabel1; - private DarkUI.Controls.DarkLabel darkLabel10; - private DarkUI.Controls.DarkLabel darkLabel11; - private DarkUI.Controls.DarkLabel darkLabel12; - private DarkUI.Controls.DarkLabel darkLabel2; - private DarkUI.Controls.DarkLabel darkLabel3; - private DarkUI.Controls.DarkLabel darkLabel4; - private DarkUI.Controls.DarkLabel darkLabel5; - private DarkUI.Controls.DarkLabel darkLabel6; - private DarkUI.Controls.DarkLabel darkLabel7; - private DarkUI.Controls.DarkLabel darkLabel8; - private DarkUI.Controls.DarkLabel darkLabel9; - private DarkUI.Controls.DarkNumericUpDown numeric_FontSize; - private DarkUI.Controls.DarkNumericUpDown numeric_UndoStackSize; - private DarkUI.Controls.DarkSectionPanel sectionPanel; - private System.Windows.Forms.ColorDialog colorDialog; - private System.Windows.Forms.Integration.ElementHost elementHost; - private System.Windows.Forms.ToolStripMenuItem menuItem_Bold; - private System.Windows.Forms.ToolStripMenuItem menuItem_Italic; - private System.Windows.Forms.ToolTip toolTip; - } -} diff --git a/TombIDE/TombIDE.ScriptingStudio/Settings/GameFlowSettingsControl.cs b/TombIDE/TombIDE.ScriptingStudio/Settings/GameFlowSettingsControl.cs deleted file mode 100644 index 7503d7d2f2..0000000000 --- a/TombIDE/TombIDE.ScriptingStudio/Settings/GameFlowSettingsControl.cs +++ /dev/null @@ -1,471 +0,0 @@ -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.GameFlowScript; -using TombLib.Scripting.GameFlowScript.Objects; -using TombLib.Scripting.Objects; -using TombLib.Scripting.Resources; -using TombLib.Utils; - -namespace TombIDE.ScriptingStudio.Settings -{ - internal partial class GameFlowSettingsControl : UserControl - { - // TODO: Refactor !!! - - private GameFlowEditor editorPreview; - - #region Construction - - public GameFlowSettingsControl() - { - InitializeComponent(); - } - - public void Initialize(GameFlowEditorConfiguration config) - { - InitializePreview(); - - FillFontList(); - UpdateSchemeList(); - UpdateControlsWithSettings(config); - } - - private void InitializePreview() - { - editorPreview = new GameFlowEditor(new Version(0, 0)) - { - Text = "DESCRIPTION: Tomb Raider 2 Script File\n" + - "\n" + - "LEVEL: Scotland Temple\n" + - " GAME: data\\temp.tr2 // Good level\n" + - " SECRETS: 21\n" + - " TRACK: 37\n" + - "END:", - 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 FillFontList() - { - var fontList = new List(); - - foreach (FontFamily font in new InstalledFontCollection().Families) - fontList.Add(font.Name); - - comboBox_FontFamily.Items.AddRange(fontList.ToArray()); - } - - private void UpdateSchemeList() - { - string cachedSelectedItem = null; - - if (comboBox_ColorSchemes.SelectedItem != null) - cachedSelectedItem = comboBox_ColorSchemes.SelectedItem.ToString(); - - comboBox_ColorSchemes.Items.Clear(); - - foreach (string file in Directory.GetFiles(DefaultPaths.GameFlowColorConfigsDirectory, "*.gflsch", SearchOption.TopDirectoryOnly)) - comboBox_ColorSchemes.Items.Add(Path.GetFileNameWithoutExtension(file)); - - if (cachedSelectedItem != null) - comboBox_ColorSchemes.SelectedItem = cachedSelectedItem; - } - - #endregion Construction - - #region Events - - private void VisiblePreviewSetting_Changed(object sender, EventArgs e) => - UpdatePreviewTemp(); - - private void comboBox_FontFamily_SelectedIndexChanged(object sender, EventArgs e) => - UpdatePreviewTemp(false); - - 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(); - - string fullSchemePath = Path.Combine(DefaultPaths.GameFlowColorConfigsDirectory, comboBox_ColorSchemes.SelectedItem.ToString() + ".gflsch"); - ColorScheme selectedScheme = XmlUtils.ReadXmlFile(fullSchemePath); - - UpdateColorButtons(selectedScheme); - UpdatePreviewColors(selectedScheme); - } - - 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 - { - Sections = (HighlightingObject)colorButton_Sections.Tag, - Values = (HighlightingObject)colorButton_Values.Tag, - Constants = (HighlightingObject)colorButton_Constants.Tag, - Properties = (HighlightingObject)colorButton_Properties.Tag, - SpecialProperties = (HighlightingObject)colorButton_SpecialProperties.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.GameFlowColorConfigsDirectory, comboBox_ColorSchemes.SelectedItem + ".gflsch"); - - 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 = "Game Flow Scheme|*.gflsch"; - - if (dialog.ShowDialog(this) == DialogResult.OK) - { - File.Copy(dialog.FileName, Path.Combine(DefaultPaths.GameFlowColorConfigsDirectory, 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.GameFlowColorConfigsDirectory, - UseShellExecute = true - }; - - Process.Start(startInfo); - } - - #endregion Events - - #region Loading - - private void UpdateControlsWithSettings(GameFlowEditorConfiguration config) - { - numeric_FontSize.Value = (decimal)config.FontSize - 4; // -4 because AvalonEdit has a different font size scale - comboBox_FontFamily.SelectedItem = config.FontFamily; - numeric_UndoStackSize.Value = config.UndoStackSize; - - LoadSettingsForCheckBoxes(config); - - comboBox_ColorSchemes.SelectedItem = config.SelectedColorSchemeName; - } - - private void LoadSettingsForCheckBoxes(GameFlowEditorConfiguration config) - { - checkBox_Autocomplete.Checked = config.AutocompleteEnabled; - checkBox_WordWrapping.Checked = config.WordWrapping; - checkBox_HighlightCurrentLine.Checked = config.HighlightCurrentLine; - checkBox_LineNumbers.Checked = config.ShowLineNumbers; - - checkBox_VisibleSpaces.Checked = config.ShowVisualSpaces; - checkBox_VisibleTabs.Checked = config.ShowVisualTabs; - } - - #endregion Loading - - #region Applying - - public void ApplySettings(GameFlowEditorConfiguration config) - { - config.FontSize = (double)(numeric_FontSize.Value + 4); // +4 because AvalonEdit has a different font size scale - config.FontFamily = comboBox_FontFamily.SelectedItem.ToString(); - config.UndoStackSize = (int)numeric_UndoStackSize.Value; - - ApplySettingsFromCheckBoxes(config); - - config.SelectedColorSchemeName = comboBox_ColorSchemes.SelectedItem.ToString(); - - config.Save(); - } - - private void ApplySettingsFromCheckBoxes(GameFlowEditorConfiguration config) - { - config.AutocompleteEnabled = checkBox_Autocomplete.Checked; - config.WordWrapping = checkBox_WordWrapping.Checked; - config.HighlightCurrentLine = checkBox_HighlightCurrentLine.Checked; - config.ShowLineNumbers = checkBox_LineNumbers.Checked; - - config.ShowVisualSpaces = checkBox_VisibleSpaces.Checked; - config.ShowVisualTabs = checkBox_VisibleTabs.Checked; - } - - #endregion Applying - - #region Resetting - - public void ResetToDefault() - { - numeric_FontSize.Value = (decimal)(TextEditorBaseDefaults.FontSize - 4); // -4 because AvalonEdit has a different font size scale - comboBox_FontFamily.SelectedItem = TextEditorBaseDefaults.FontFamily; - numeric_UndoStackSize.Value = TextEditorBaseDefaults.UndoStackSize; - - ResetCheckBoxSettings(); - } - - private void ResetCheckBoxSettings() - { - checkBox_Autocomplete.Checked = TextEditorBaseDefaults.AutocompleteEnabled; - checkBox_WordWrapping.Checked = TextEditorBaseDefaults.WordWrapping; - 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(); - - private void ChangeColor(DarkButton targetButton) - { - 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(); - - UpdateColorButtonStyleText(targetButton); - } - } - - private void UpdatePreview() - { - var currentScheme = new ColorScheme - { - Sections = (HighlightingObject)colorButton_Sections.Tag, - Values = (HighlightingObject)colorButton_Values.Tag, - Constants = (HighlightingObject)colorButton_Constants.Tag, - Properties = (HighlightingObject)colorButton_Properties.Tag, - SpecialProperties = (HighlightingObject)colorButton_SpecialProperties.Tag, - Comments = (HighlightingObject)colorButton_Comments.Tag, - Background = ColorTranslator.ToHtml(colorButton_Background.BackColor), - Foreground = ColorTranslator.ToHtml(colorButton_Foreground.BackColor) - }; - - bool itemFound = false; - - foreach (string item in comboBox_ColorSchemes.Items) - { - if (item == "~UNTITLED") - continue; - - ColorScheme itemScheme = XmlUtils.ReadXmlFile(Path.Combine(DefaultPaths.GameFlowColorConfigsDirectory, item + ".gflsch")); - - 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.GameFlowColorConfigsDirectory, "~UNTITLED.gflsch"), currentScheme); - - comboBox_ColorSchemes.SelectedItem = "~UNTITLED"; - } - - UpdatePreviewColors(currentScheme); - } - - private void UpdateColorButtons(ColorScheme scheme) - { - colorButton_Sections.BackColor = ColorTranslator.FromHtml(scheme.Sections.HtmlColor); - colorButton_Sections.Tag = scheme.Sections; - - colorButton_Values.BackColor = ColorTranslator.FromHtml(scheme.Values.HtmlColor); - colorButton_Values.Tag = scheme.Values; - - colorButton_Constants.BackColor = ColorTranslator.FromHtml(scheme.Constants.HtmlColor); - colorButton_Constants.Tag = scheme.Constants; - - colorButton_Properties.BackColor = ColorTranslator.FromHtml(scheme.Properties.HtmlColor); - colorButton_Properties.Tag = scheme.Properties; - - colorButton_SpecialProperties.BackColor = ColorTranslator.FromHtml(scheme.SpecialProperties.HtmlColor); - colorButton_SpecialProperties.Tag = scheme.SpecialProperties; - - colorButton_Comments.BackColor = ColorTranslator.FromHtml(scheme.Comments.HtmlColor); - colorButton_Comments.Tag = scheme.Comments; - - UpdateColorButtonStyleText(colorButton_Sections); - UpdateColorButtonStyleText(colorButton_Values); - UpdateColorButtonStyleText(colorButton_Constants); - UpdateColorButtonStyleText(colorButton_Properties); - UpdateColorButtonStyleText(colorButton_SpecialProperties); - UpdateColorButtonStyleText(colorButton_Comments); - - colorButton_Background.BackColor = ColorTranslator.FromHtml(scheme.Background); - colorButton_Foreground.BackColor = ColorTranslator.FromHtml(scheme.Foreground); - } - - 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(); - } - - private void UpdateColorButtonStyleText(DarkButton colorButton) - { - 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; - } - - private void UpdatePreviewColors(ColorScheme scheme) - { - 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); - } - - private void ToggleSaveSchemeButton() - { - bool isUntitled = comboBox_ColorSchemes.SelectedItem.ToString().Equals("~UNTITLED", StringComparison.OrdinalIgnoreCase); - - button_SaveScheme.Enabled = isUntitled; - button_SaveScheme.Visible = isUntitled; - - comboBox_ColorSchemes.Width = isUntitled ? 395 : 426; - } - - private void UpdatePreviewTemp(bool forceUpdate = true) - { - 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()); - - editorPreview.WordWrap = checkBox_WordWrapping.Checked; - editorPreview.Options.HighlightCurrentLine = checkBox_HighlightCurrentLine.Checked; - editorPreview.ShowLineNumbers = checkBox_LineNumbers.Checked; - - editorPreview.Options.ShowSpaces = checkBox_VisibleSpaces.Checked; - editorPreview.Options.ShowTabs = checkBox_VisibleTabs.Checked; - - if (forceUpdate) - ForcePreviewUpdate(); - } - } -} diff --git a/TombIDE/TombIDE.ScriptingStudio/Settings/GameFlowSettingsControl.resx b/TombIDE/TombIDE.ScriptingStudio/Settings/GameFlowSettingsControl.resx deleted file mode 100644 index 203aec3b78..0000000000 --- a/TombIDE/TombIDE.ScriptingStudio/Settings/GameFlowSettingsControl.resx +++ /dev/null @@ -1,129 +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 - - - 131, 17 - - - 221, 17 - - - 17, 17 - - \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/Settings/LuaSettingsControl.Designer.cs b/TombIDE/TombIDE.ScriptingStudio/Settings/LuaSettingsControl.Designer.cs deleted file mode 100644 index 17450950d1..0000000000 --- a/TombIDE/TombIDE.ScriptingStudio/Settings/LuaSettingsControl.Designer.cs +++ /dev/null @@ -1,665 +0,0 @@ -namespace TombIDE.ScriptingStudio.Settings -{ - partial class LuaSettingsControl - { - private System.ComponentModel.IContainer components = null; - - protected override void Dispose(bool disposing) - { - if (disposing && (components != null)) - { - components.Dispose(); - } - base.Dispose(disposing); - } - - #region Component Designer generated code - - private void InitializeComponent() - { - components = new System.ComponentModel.Container(); - button_ImportScheme = new DarkUI.Controls.DarkButton(); - buttonContextMenu = new DarkUI.Controls.DarkContextMenu(); - menuItem_Bold = new System.Windows.Forms.ToolStripMenuItem(); - menuItem_Italic = new System.Windows.Forms.ToolStripMenuItem(); - checkBox_Autocomplete = new DarkUI.Controls.DarkCheckBox(); - checkBox_HighlightCurrentLine = new DarkUI.Controls.DarkCheckBox(); - checkBox_LineNumbers = new DarkUI.Controls.DarkCheckBox(); - checkBox_VisibleSpaces = new DarkUI.Controls.DarkCheckBox(); - checkBox_VisibleTabs = new DarkUI.Controls.DarkCheckBox(); - checkBox_WordWrapping = new DarkUI.Controls.DarkCheckBox(); - colorButton_Background = new DarkUI.Controls.DarkButton(); - colorButton_Foreground = new DarkUI.Controls.DarkButton(); - colorButton_Comments = new DarkUI.Controls.DarkButton(); - colorButton_SpecialOperators = new DarkUI.Controls.DarkButton(); - colorButton_Values = new DarkUI.Controls.DarkButton(); - colorButton_Statements = new DarkUI.Controls.DarkButton(); - colorButton_Operators = new DarkUI.Controls.DarkButton(); - colorDialog = new System.Windows.Forms.ColorDialog(); - darkLabel1 = new DarkUI.Controls.DarkLabel(); - darkLabel10 = new DarkUI.Controls.DarkLabel(); - darkLabel11 = new DarkUI.Controls.DarkLabel(); - darkLabel12 = new DarkUI.Controls.DarkLabel(); - darkLabel2 = new DarkUI.Controls.DarkLabel(); - darkLabel3 = new DarkUI.Controls.DarkLabel(); - darkLabel4 = new DarkUI.Controls.DarkLabel(); - darkLabel5 = new DarkUI.Controls.DarkLabel(); - darkLabel6 = new DarkUI.Controls.DarkLabel(); - darkLabel7 = new DarkUI.Controls.DarkLabel(); - darkLabel8 = new DarkUI.Controls.DarkLabel(); - elementHost = new System.Windows.Forms.Integration.ElementHost(); - groupBox_Colors = new DarkUI.Controls.DarkGroupBox(); - button_SaveScheme = new DarkUI.Controls.DarkButton(); - button_DeleteScheme = new DarkUI.Controls.DarkButton(); - button_OpenSchemesFolder = new DarkUI.Controls.DarkButton(); - comboBox_ColorSchemes = new DarkUI.Controls.DarkComboBox(); - groupBox_Preview = new DarkUI.Controls.DarkGroupBox(); - numeric_FontSize = new DarkUI.Controls.DarkNumericUpDown(); - numeric_UndoStackSize = new DarkUI.Controls.DarkNumericUpDown(); - sectionPanel = new DarkUI.Controls.DarkSectionPanel(); - checkBox_CloseParentheses = new DarkUI.Controls.DarkCheckBox(); - checkBox_CloseBraces = new DarkUI.Controls.DarkCheckBox(); - checkBox_CloseQuotes = new DarkUI.Controls.DarkCheckBox(); - checkBox_CloseBrackets = new DarkUI.Controls.DarkCheckBox(); - comboBox_FontFamily = new DarkUI.Controls.DarkComboBox(); - toolTip = new System.Windows.Forms.ToolTip(components); - buttonContextMenu.SuspendLayout(); - groupBox_Colors.SuspendLayout(); - groupBox_Preview.SuspendLayout(); - ((System.ComponentModel.ISupportInitialize)numeric_FontSize).BeginInit(); - ((System.ComponentModel.ISupportInitialize)numeric_UndoStackSize).BeginInit(); - sectionPanel.SuspendLayout(); - SuspendLayout(); - // - // button_ImportScheme - // - button_ImportScheme.Checked = false; - button_ImportScheme.Image = Properties.Resources.Import_16; - button_ImportScheme.Location = new System.Drawing.Point(483, 16); - button_ImportScheme.Margin = new System.Windows.Forms.Padding(3, 0, 3, 3); - button_ImportScheme.Name = "button_ImportScheme"; - button_ImportScheme.Size = new System.Drawing.Size(25, 25); - button_ImportScheme.TabIndex = 21; - toolTip.SetToolTip(button_ImportScheme, "Import Scheme..."); - button_ImportScheme.Click += button_ImportScheme_Click; - // - // buttonContextMenu - // - buttonContextMenu.BackColor = System.Drawing.Color.FromArgb(60, 63, 65); - buttonContextMenu.ForeColor = System.Drawing.Color.FromArgb(220, 220, 220); - buttonContextMenu.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { menuItem_Bold, menuItem_Italic }); - buttonContextMenu.Name = "buttonContextMenu"; - buttonContextMenu.Size = new System.Drawing.Size(100, 48); - buttonContextMenu.Opening += buttonContextMenu_Opening; - // - // menuItem_Bold - // - menuItem_Bold.BackColor = System.Drawing.Color.FromArgb(60, 63, 65); - menuItem_Bold.ForeColor = System.Drawing.Color.FromArgb(220, 220, 220); - menuItem_Bold.Name = "menuItem_Bold"; - menuItem_Bold.Size = new System.Drawing.Size(99, 22); - menuItem_Bold.Text = "Bold"; - menuItem_Bold.Click += menuItem_Bold_Click; - // - // menuItem_Italic - // - menuItem_Italic.BackColor = System.Drawing.Color.FromArgb(60, 63, 65); - menuItem_Italic.ForeColor = System.Drawing.Color.FromArgb(220, 220, 220); - menuItem_Italic.Name = "menuItem_Italic"; - menuItem_Italic.Size = new System.Drawing.Size(99, 22); - menuItem_Italic.Text = "Italic"; - menuItem_Italic.Click += menuItem_Italic_Click; - // - // 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.TabIndex = 6; - checkBox_Autocomplete.Text = "-"; - // - // checkBox_HighlightCurrentLine - // - checkBox_HighlightCurrentLine.AutoSize = true; - checkBox_HighlightCurrentLine.Location = new System.Drawing.Point(6, 298); - checkBox_HighlightCurrentLine.Margin = new System.Windows.Forms.Padding(3, 9, 3, 0); - checkBox_HighlightCurrentLine.Name = "checkBox_HighlightCurrentLine"; - checkBox_HighlightCurrentLine.Size = new System.Drawing.Size(137, 17); - checkBox_HighlightCurrentLine.TabIndex = 11; - checkBox_HighlightCurrentLine.Text = "Highlight current line"; - checkBox_HighlightCurrentLine.CheckedChanged += VisiblePreviewSetting_Changed; - // - // checkBox_LineNumbers - // - checkBox_LineNumbers.AutoSize = true; - checkBox_LineNumbers.Location = new System.Drawing.Point(6, 320); - checkBox_LineNumbers.Margin = new System.Windows.Forms.Padding(3, 9, 3, 0); - checkBox_LineNumbers.Name = "checkBox_LineNumbers"; - checkBox_LineNumbers.Size = new System.Drawing.Size(125, 17); - checkBox_LineNumbers.TabIndex = 12; - checkBox_LineNumbers.Text = "Show line numbers"; - checkBox_LineNumbers.CheckedChanged += VisiblePreviewSetting_Changed; - // - // checkBox_VisibleSpaces - // - checkBox_VisibleSpaces.AutoSize = true; - checkBox_VisibleSpaces.Location = new System.Drawing.Point(6, 342); - checkBox_VisibleSpaces.Margin = new System.Windows.Forms.Padding(3, 9, 3, 0); - checkBox_VisibleSpaces.Name = "checkBox_VisibleSpaces"; - checkBox_VisibleSpaces.Size = new System.Drawing.Size(127, 17); - checkBox_VisibleSpaces.TabIndex = 14; - checkBox_VisibleSpaces.Text = "Show visible spaces"; - checkBox_VisibleSpaces.CheckedChanged += VisiblePreviewSetting_Changed; - // - // checkBox_VisibleTabs - // - checkBox_VisibleTabs.AutoSize = true; - checkBox_VisibleTabs.Location = new System.Drawing.Point(6, 364); - checkBox_VisibleTabs.Margin = new System.Windows.Forms.Padding(3, 0, 3, 0); - checkBox_VisibleTabs.Name = "checkBox_VisibleTabs"; - checkBox_VisibleTabs.Size = new System.Drawing.Size(115, 17); - checkBox_VisibleTabs.TabIndex = 15; - checkBox_VisibleTabs.Text = "Show visible tabs"; - checkBox_VisibleTabs.CheckedChanged += VisiblePreviewSetting_Changed; - // - // checkBox_WordWrapping - // - checkBox_WordWrapping.AutoSize = true; - checkBox_WordWrapping.Location = new System.Drawing.Point(6, 188); - checkBox_WordWrapping.Margin = new System.Windows.Forms.Padding(3, 9, 3, 0); - checkBox_WordWrapping.Name = "checkBox_WordWrapping"; - checkBox_WordWrapping.Size = new System.Drawing.Size(108, 17); - checkBox_WordWrapping.TabIndex = 10; - checkBox_WordWrapping.Text = "Word wrapping"; - checkBox_WordWrapping.CheckedChanged += VisiblePreviewSetting_Changed; - // - // colorButton_Background - // - colorButton_Background.BackColor = System.Drawing.Color.FromArgb(32, 32, 32); - colorButton_Background.BackColorUseGeneric = false; - colorButton_Background.Checked = false; - colorButton_Background.Location = new System.Drawing.Point(370, 59); - colorButton_Background.Margin = new System.Windows.Forms.Padding(6, 0, 9, 3); - colorButton_Background.Name = "colorButton_Background"; - colorButton_Background.Size = new System.Drawing.Size(169, 25); - colorButton_Background.TabIndex = 14; - colorButton_Background.Click += button_Color_Click; - // - // colorButton_Foreground - // - colorButton_Foreground.BackColor = System.Drawing.Color.Gainsboro; - colorButton_Foreground.BackColorUseGeneric = false; - colorButton_Foreground.Checked = false; - colorButton_Foreground.Location = new System.Drawing.Point(370, 100); - colorButton_Foreground.Margin = new System.Windows.Forms.Padding(6, 0, 9, 3); - colorButton_Foreground.Name = "colorButton_Foreground"; - colorButton_Foreground.Size = new System.Drawing.Size(169, 25); - colorButton_Foreground.TabIndex = 16; - colorButton_Foreground.Click += button_Color_Click; - // - // colorButton_Comments - // - colorButton_Comments.BackColor = System.Drawing.Color.Green; - colorButton_Comments.BackColorUseGeneric = false; - colorButton_Comments.Checked = false; - colorButton_Comments.ContextMenuStrip = buttonContextMenu; - colorButton_Comments.Font = new System.Drawing.Font("Segoe UI", 9.75F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point); - colorButton_Comments.Location = new System.Drawing.Point(191, 100); - colorButton_Comments.Margin = new System.Windows.Forms.Padding(3, 0, 3, 3); - colorButton_Comments.Name = "colorButton_Comments"; - colorButton_Comments.Size = new System.Drawing.Size(170, 25); - colorButton_Comments.TabIndex = 9; - colorButton_Comments.UseForeColor = true; - colorButton_Comments.Click += button_Color_Click; - // - // colorButton_SpecialOperators - // - colorButton_SpecialOperators.BackColor = System.Drawing.Color.Orchid; - colorButton_SpecialOperators.BackColorUseGeneric = false; - colorButton_SpecialOperators.Checked = false; - colorButton_SpecialOperators.ContextMenuStrip = buttonContextMenu; - colorButton_SpecialOperators.Font = new System.Drawing.Font("Segoe UI", 9.75F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point); - colorButton_SpecialOperators.Location = new System.Drawing.Point(12, 141); - colorButton_SpecialOperators.Margin = new System.Windows.Forms.Padding(9, 0, 3, 8); - colorButton_SpecialOperators.Name = "colorButton_SpecialOperators"; - colorButton_SpecialOperators.Size = new System.Drawing.Size(170, 25); - colorButton_SpecialOperators.TabIndex = 5; - colorButton_SpecialOperators.UseForeColor = true; - colorButton_SpecialOperators.Click += button_Color_Click; - // - // colorButton_Values - // - colorButton_Values.BackColor = System.Drawing.Color.SteelBlue; - colorButton_Values.BackColorUseGeneric = false; - colorButton_Values.Checked = false; - colorButton_Values.ContextMenuStrip = buttonContextMenu; - colorButton_Values.Font = new System.Drawing.Font("Segoe UI", 9.75F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point); - colorButton_Values.Location = new System.Drawing.Point(12, 59); - colorButton_Values.Margin = new System.Windows.Forms.Padding(9, 0, 3, 3); - colorButton_Values.Name = "colorButton_Values"; - colorButton_Values.Size = new System.Drawing.Size(170, 25); - colorButton_Values.TabIndex = 1; - colorButton_Values.UseForeColor = true; - colorButton_Values.Click += button_Color_Click; - // - // colorButton_Statements - // - colorButton_Statements.BackColor = System.Drawing.Color.MediumAquamarine; - colorButton_Statements.BackColorUseGeneric = false; - colorButton_Statements.Checked = false; - colorButton_Statements.ContextMenuStrip = buttonContextMenu; - colorButton_Statements.Font = new System.Drawing.Font("Segoe UI", 9.75F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point); - colorButton_Statements.Location = new System.Drawing.Point(191, 59); - colorButton_Statements.Margin = new System.Windows.Forms.Padding(6, 0, 3, 3); - colorButton_Statements.Name = "colorButton_Statements"; - colorButton_Statements.Size = new System.Drawing.Size(170, 25); - colorButton_Statements.TabIndex = 7; - colorButton_Statements.UseForeColor = true; - colorButton_Statements.Click += button_Color_Click; - // - // colorButton_Operators - // - colorButton_Operators.BackColor = System.Drawing.Color.LightSalmon; - colorButton_Operators.BackColorUseGeneric = false; - colorButton_Operators.Checked = false; - colorButton_Operators.ContextMenuStrip = buttonContextMenu; - colorButton_Operators.Font = new System.Drawing.Font("Segoe UI", 9.75F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point); - colorButton_Operators.Location = new System.Drawing.Point(12, 100); - colorButton_Operators.Margin = new System.Windows.Forms.Padding(3, 0, 3, 3); - colorButton_Operators.Name = "colorButton_Operators"; - colorButton_Operators.Size = new System.Drawing.Size(170, 25); - colorButton_Operators.TabIndex = 3; - colorButton_Operators.UseForeColor = true; - colorButton_Operators.Click += button_Color_Click; - // - // colorDialog - // - colorDialog.AnyColor = true; - colorDialog.FullOpen = true; - // - // darkLabel1 - // - darkLabel1.AutoSize = true; - darkLabel1.ForeColor = System.Drawing.Color.FromArgb(220, 220, 220); - darkLabel1.Location = new System.Drawing.Point(7, 34); - darkLabel1.Margin = new System.Windows.Forms.Padding(6, 9, 3, 0); - darkLabel1.Name = "darkLabel1"; - darkLabel1.Size = new System.Drawing.Size(56, 13); - darkLabel1.TabIndex = 0; - darkLabel1.Text = "Font size:"; - // - // darkLabel10 - // - darkLabel10.AutoSize = true; - darkLabel10.ForeColor = System.Drawing.Color.FromArgb(220, 220, 220); - darkLabel10.Location = new System.Drawing.Point(370, 46); - darkLabel10.Margin = new System.Windows.Forms.Padding(3, 3, 3, 0); - darkLabel10.Name = "darkLabel10"; - darkLabel10.Size = new System.Drawing.Size(72, 13); - darkLabel10.TabIndex = 13; - darkLabel10.Text = "Background:"; - // - // darkLabel11 - // - darkLabel11.AutoSize = true; - darkLabel11.ForeColor = System.Drawing.Color.FromArgb(220, 220, 220); - darkLabel11.Location = new System.Drawing.Point(370, 87); - darkLabel11.Name = "darkLabel11"; - darkLabel11.Size = new System.Drawing.Size(98, 13); - darkLabel11.TabIndex = 15; - darkLabel11.Text = "Normal text color:"; - // - // darkLabel12 - // - darkLabel12.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle; - darkLabel12.ForeColor = System.Drawing.Color.FromArgb(220, 220, 220); - darkLabel12.Location = new System.Drawing.Point(475, 17); - darkLabel12.Name = "darkLabel12"; - darkLabel12.Size = new System.Drawing.Size(2, 23); - darkLabel12.TabIndex = 20; - // - // darkLabel2 - // - darkLabel2.AutoSize = true; - darkLabel2.ForeColor = System.Drawing.Color.FromArgb(220, 220, 220); - darkLabel2.Location = new System.Drawing.Point(7, 76); - darkLabel2.Margin = new System.Windows.Forms.Padding(6, 3, 3, 0); - darkLabel2.Name = "darkLabel2"; - darkLabel2.Size = new System.Drawing.Size(67, 13); - darkLabel2.TabIndex = 2; - darkLabel2.Text = "Font family:"; - // - // darkLabel3 - // - darkLabel3.AutoSize = true; - darkLabel3.ForeColor = System.Drawing.Color.FromArgb(220, 220, 220); - darkLabel3.Location = new System.Drawing.Point(7, 119); - darkLabel3.Margin = new System.Windows.Forms.Padding(6, 3, 3, 0); - darkLabel3.Name = "darkLabel3"; - darkLabel3.Size = new System.Drawing.Size(90, 13); - darkLabel3.TabIndex = 4; - darkLabel3.Text = "Undo stack size:"; - // - // darkLabel4 - // - darkLabel4.AutoSize = true; - darkLabel4.ForeColor = System.Drawing.Color.FromArgb(220, 220, 220); - darkLabel4.Location = new System.Drawing.Point(12, 46); - darkLabel4.Margin = new System.Windows.Forms.Padding(9, 3, 3, 0); - darkLabel4.Name = "darkLabel4"; - darkLabel4.Size = new System.Drawing.Size(40, 13); - darkLabel4.TabIndex = 0; - darkLabel4.Text = "Values"; - // - // darkLabel5 - // - darkLabel5.AutoSize = true; - darkLabel5.ForeColor = System.Drawing.Color.FromArgb(220, 220, 220); - darkLabel5.Location = new System.Drawing.Point(12, 87); - darkLabel5.Name = "darkLabel5"; - darkLabel5.Size = new System.Drawing.Size(62, 13); - darkLabel5.TabIndex = 2; - darkLabel5.Text = "Operators:"; - // - // darkLabel6 - // - darkLabel6.AutoSize = true; - darkLabel6.ForeColor = System.Drawing.Color.FromArgb(220, 220, 220); - darkLabel6.Location = new System.Drawing.Point(12, 128); - darkLabel6.Name = "darkLabel6"; - darkLabel6.Size = new System.Drawing.Size(98, 13); - darkLabel6.TabIndex = 4; - darkLabel6.Text = "Special Operators"; - // - // darkLabel7 - // - darkLabel7.AutoSize = true; - darkLabel7.ForeColor = System.Drawing.Color.FromArgb(220, 220, 220); - darkLabel7.Location = new System.Drawing.Point(190, 46); - darkLabel7.Margin = new System.Windows.Forms.Padding(3, 3, 3, 0); - darkLabel7.Name = "darkLabel7"; - darkLabel7.Size = new System.Drawing.Size(67, 13); - darkLabel7.TabIndex = 6; - darkLabel7.Text = "Statements:"; - // - // darkLabel8 - // - darkLabel8.AutoSize = true; - darkLabel8.ForeColor = System.Drawing.Color.FromArgb(220, 220, 220); - darkLabel8.Location = new System.Drawing.Point(190, 87); - darkLabel8.Name = "darkLabel8"; - darkLabel8.Size = new System.Drawing.Size(61, 13); - darkLabel8.TabIndex = 8; - darkLabel8.Text = "Comments"; - // - // elementHost - // - elementHost.Dock = System.Windows.Forms.DockStyle.Fill; - elementHost.Location = new System.Drawing.Point(3, 18); - elementHost.Name = "elementHost"; - elementHost.Size = new System.Drawing.Size(545, 170); - elementHost.TabIndex = 0; - // - // groupBox_Colors - // - groupBox_Colors.Controls.Add(button_ImportScheme); - groupBox_Colors.Controls.Add(darkLabel12); - groupBox_Colors.Controls.Add(button_SaveScheme); - groupBox_Colors.Controls.Add(button_DeleteScheme); - groupBox_Colors.Controls.Add(button_OpenSchemesFolder); - groupBox_Colors.Controls.Add(darkLabel11); - groupBox_Colors.Controls.Add(colorButton_Foreground); - groupBox_Colors.Controls.Add(darkLabel10); - groupBox_Colors.Controls.Add(colorButton_Background); - groupBox_Colors.Controls.Add(comboBox_ColorSchemes); - groupBox_Colors.Controls.Add(darkLabel8); - groupBox_Colors.Controls.Add(darkLabel7); - groupBox_Colors.Controls.Add(darkLabel6); - groupBox_Colors.Controls.Add(darkLabel5); - groupBox_Colors.Controls.Add(darkLabel4); - groupBox_Colors.Controls.Add(colorButton_Statements); - groupBox_Colors.Controls.Add(colorButton_Comments); - groupBox_Colors.Controls.Add(colorButton_Values); - groupBox_Colors.Controls.Add(colorButton_Operators); - groupBox_Colors.Controls.Add(colorButton_SpecialOperators); - groupBox_Colors.Enabled = false; - groupBox_Colors.Location = new System.Drawing.Point(162, 28); - groupBox_Colors.Margin = new System.Windows.Forms.Padding(3, 3, 6, 3); - groupBox_Colors.Name = "groupBox_Colors"; - groupBox_Colors.Size = new System.Drawing.Size(551, 177); - groupBox_Colors.TabIndex = 17; - groupBox_Colors.TabStop = false; - groupBox_Colors.Text = "Color schemes (Not implemented yet)"; - // - // button_SaveScheme - // - button_SaveScheme.Checked = false; - button_SaveScheme.Image = Properties.Resources.Save_16; - button_SaveScheme.Location = new System.Drawing.Point(413, 16); - button_SaveScheme.Name = "button_SaveScheme"; - button_SaveScheme.Size = new System.Drawing.Size(25, 25); - button_SaveScheme.TabIndex = 19; - toolTip.SetToolTip(button_SaveScheme, "Save Scheme As..."); - button_SaveScheme.Click += button_SaveScheme_Click; - // - // button_DeleteScheme - // - button_DeleteScheme.Checked = false; - button_DeleteScheme.Image = Properties.Resources.Trash_16; - button_DeleteScheme.Location = new System.Drawing.Point(444, 16); - button_DeleteScheme.Name = "button_DeleteScheme"; - button_DeleteScheme.Size = new System.Drawing.Size(25, 25); - button_DeleteScheme.TabIndex = 18; - toolTip.SetToolTip(button_DeleteScheme, "Delete Scheme"); - button_DeleteScheme.Click += button_DeleteScheme_Click; - // - // button_OpenSchemesFolder - // - button_OpenSchemesFolder.Checked = false; - button_OpenSchemesFolder.Image = Properties.Resources.ForwardArrow_16; - button_OpenSchemesFolder.Location = new System.Drawing.Point(514, 16); - button_OpenSchemesFolder.Margin = new System.Windows.Forms.Padding(3, 0, 3, 3); - button_OpenSchemesFolder.Name = "button_OpenSchemesFolder"; - button_OpenSchemesFolder.Size = new System.Drawing.Size(25, 25); - button_OpenSchemesFolder.TabIndex = 17; - toolTip.SetToolTip(button_OpenSchemesFolder, "Open Schemes Folder"); - button_OpenSchemesFolder.Click += button_OpenSchemesFolder_Click; - // - // comboBox_ColorSchemes - // - comboBox_ColorSchemes.FormattingEnabled = true; - comboBox_ColorSchemes.Location = new System.Drawing.Point(12, 19); - comboBox_ColorSchemes.Margin = new System.Windows.Forms.Padding(9, 3, 3, 3); - comboBox_ColorSchemes.Name = "comboBox_ColorSchemes"; - comboBox_ColorSchemes.Size = new System.Drawing.Size(395, 23); - comboBox_ColorSchemes.TabIndex = 12; - comboBox_ColorSchemes.SelectedIndexChanged += comboBox_ColorSchemes_SelectedIndexChanged; - // - // groupBox_Preview - // - groupBox_Preview.Controls.Add(elementHost); - groupBox_Preview.Location = new System.Drawing.Point(162, 214); - groupBox_Preview.Margin = new System.Windows.Forms.Padding(3, 6, 6, 6); - groupBox_Preview.Name = "groupBox_Preview"; - groupBox_Preview.Size = new System.Drawing.Size(551, 191); - groupBox_Preview.TabIndex = 2; - groupBox_Preview.TabStop = false; - groupBox_Preview.Text = "Preview"; - // - // numeric_FontSize - // - numeric_FontSize.IncrementAlternate = new decimal(new int[] { 10, 0, 0, 65536 }); - numeric_FontSize.Location = new System.Drawing.Point(6, 50); - numeric_FontSize.LoopValues = false; - numeric_FontSize.Margin = new System.Windows.Forms.Padding(6, 3, 3, 3); - numeric_FontSize.Maximum = new decimal(new int[] { 32, 0, 0, 0 }); - numeric_FontSize.Minimum = new decimal(new int[] { 4, 0, 0, 0 }); - numeric_FontSize.Name = "numeric_FontSize"; - numeric_FontSize.Size = new System.Drawing.Size(150, 22); - numeric_FontSize.TabIndex = 1; - numeric_FontSize.Value = new decimal(new int[] { 12, 0, 0, 0 }); - numeric_FontSize.ValueChanged += VisiblePreviewSetting_Changed; - // - // numeric_UndoStackSize - // - numeric_UndoStackSize.IncrementAlternate = new decimal(new int[] { 10, 0, 0, 65536 }); - numeric_UndoStackSize.Location = new System.Drawing.Point(6, 135); - numeric_UndoStackSize.LoopValues = false; - numeric_UndoStackSize.Margin = new System.Windows.Forms.Padding(6, 3, 3, 3); - numeric_UndoStackSize.Maximum = new decimal(new int[] { 1024, 0, 0, 0 }); - numeric_UndoStackSize.Minimum = new decimal(new int[] { 16, 0, 0, 0 }); - numeric_UndoStackSize.Name = "numeric_UndoStackSize"; - numeric_UndoStackSize.Size = new System.Drawing.Size(150, 22); - numeric_UndoStackSize.TabIndex = 5; - numeric_UndoStackSize.Value = new decimal(new int[] { 256, 0, 0, 0 }); - // - // sectionPanel - // - sectionPanel.Controls.Add(checkBox_CloseParentheses); - sectionPanel.Controls.Add(checkBox_CloseBraces); - sectionPanel.Controls.Add(checkBox_CloseQuotes); - sectionPanel.Controls.Add(checkBox_CloseBrackets); - sectionPanel.Controls.Add(groupBox_Preview); - sectionPanel.Controls.Add(checkBox_HighlightCurrentLine); - sectionPanel.Controls.Add(checkBox_VisibleTabs); - sectionPanel.Controls.Add(checkBox_VisibleSpaces); - sectionPanel.Controls.Add(checkBox_LineNumbers); - sectionPanel.Controls.Add(darkLabel3); - sectionPanel.Controls.Add(darkLabel2); - sectionPanel.Controls.Add(darkLabel1); - sectionPanel.Controls.Add(numeric_UndoStackSize); - sectionPanel.Controls.Add(checkBox_Autocomplete); - sectionPanel.Controls.Add(checkBox_WordWrapping); - sectionPanel.Controls.Add(groupBox_Colors); - sectionPanel.Controls.Add(comboBox_FontFamily); - sectionPanel.Controls.Add(numeric_FontSize); - sectionPanel.Dock = System.Windows.Forms.DockStyle.Fill; - sectionPanel.Location = new System.Drawing.Point(0, 0); - sectionPanel.Name = "sectionPanel"; - sectionPanel.SectionHeader = "Lua"; - sectionPanel.Size = new System.Drawing.Size(720, 412); - sectionPanel.TabIndex = 0; - // - // checkBox_CloseParentheses - // - checkBox_CloseParentheses.Location = new System.Drawing.Point(6, 210); - checkBox_CloseParentheses.Margin = new System.Windows.Forms.Padding(3, 9, 3, 0); - checkBox_CloseParentheses.Name = "checkBox_CloseParentheses"; - checkBox_CloseParentheses.Size = new System.Drawing.Size(150, 17); - checkBox_CloseParentheses.TabIndex = 21; - checkBox_CloseParentheses.Text = "Auto close parentheses ( )"; - // - // checkBox_CloseBraces - // - checkBox_CloseBraces.AutoSize = true; - checkBox_CloseBraces.Location = new System.Drawing.Point(6, 254); - checkBox_CloseBraces.Margin = new System.Windows.Forms.Padding(3, 0, 3, 0); - checkBox_CloseBraces.Name = "checkBox_CloseBraces"; - checkBox_CloseBraces.Size = new System.Drawing.Size(128, 17); - checkBox_CloseBraces.TabIndex = 20; - checkBox_CloseBraces.Text = "Auto close braces { }"; - // - // checkBox_CloseQuotes - // - checkBox_CloseQuotes.AutoSize = true; - checkBox_CloseQuotes.Location = new System.Drawing.Point(6, 276); - checkBox_CloseQuotes.Margin = new System.Windows.Forms.Padding(3, 0, 3, 0); - checkBox_CloseQuotes.Name = "checkBox_CloseQuotes"; - checkBox_CloseQuotes.Size = new System.Drawing.Size(133, 17); - checkBox_CloseQuotes.TabIndex = 19; - checkBox_CloseQuotes.Text = "Auto close quotes \" \""; - // - // checkBox_CloseBrackets - // - checkBox_CloseBrackets.AutoSize = true; - checkBox_CloseBrackets.Location = new System.Drawing.Point(6, 232); - checkBox_CloseBrackets.Margin = new System.Windows.Forms.Padding(3, 9, 3, 0); - checkBox_CloseBrackets.Name = "checkBox_CloseBrackets"; - checkBox_CloseBrackets.Size = new System.Drawing.Size(138, 17); - checkBox_CloseBrackets.TabIndex = 18; - checkBox_CloseBrackets.Text = "Auto close brackets [ ]"; - // - // comboBox_FontFamily - // - comboBox_FontFamily.FormattingEnabled = true; - comboBox_FontFamily.Location = new System.Drawing.Point(6, 92); - comboBox_FontFamily.Margin = new System.Windows.Forms.Padding(6, 3, 3, 3); - comboBox_FontFamily.Name = "comboBox_FontFamily"; - comboBox_FontFamily.Size = new System.Drawing.Size(150, 23); - comboBox_FontFamily.TabIndex = 3; - comboBox_FontFamily.SelectedIndexChanged += comboBox_FontFamily_SelectedIndexChanged; - // - // LuaSettingsControl - // - AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); - AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; - BackColor = System.Drawing.Color.FromArgb(63, 65, 69); - Controls.Add(sectionPanel); - Font = new System.Drawing.Font("Segoe UI", 8.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point); - MaximumSize = new System.Drawing.Size(720, 412); - MinimumSize = new System.Drawing.Size(720, 412); - Name = "LuaSettingsControl"; - Size = new System.Drawing.Size(720, 412); - buttonContextMenu.ResumeLayout(false); - groupBox_Colors.ResumeLayout(false); - groupBox_Colors.PerformLayout(); - groupBox_Preview.ResumeLayout(false); - ((System.ComponentModel.ISupportInitialize)numeric_FontSize).EndInit(); - ((System.ComponentModel.ISupportInitialize)numeric_UndoStackSize).EndInit(); - sectionPanel.ResumeLayout(false); - sectionPanel.PerformLayout(); - ResumeLayout(false); - } - - #endregion - - private DarkUI.Controls.DarkButton button_DeleteScheme; - private DarkUI.Controls.DarkButton button_ImportScheme; - private DarkUI.Controls.DarkButton button_OpenSchemesFolder; - private DarkUI.Controls.DarkButton button_SaveScheme; - private DarkUI.Controls.DarkButton colorButton_Background; - private DarkUI.Controls.DarkButton colorButton_Foreground; - private DarkUI.Controls.DarkButton colorButton_Comments; - private DarkUI.Controls.DarkButton colorButton_SpecialOperators; - private DarkUI.Controls.DarkButton colorButton_Values; - private DarkUI.Controls.DarkButton colorButton_Statements; - private DarkUI.Controls.DarkButton colorButton_Operators; - private DarkUI.Controls.DarkCheckBox checkBox_Autocomplete; - private DarkUI.Controls.DarkCheckBox checkBox_HighlightCurrentLine; - private DarkUI.Controls.DarkCheckBox checkBox_LineNumbers; - private DarkUI.Controls.DarkCheckBox checkBox_VisibleSpaces; - private DarkUI.Controls.DarkCheckBox checkBox_VisibleTabs; - private DarkUI.Controls.DarkCheckBox checkBox_WordWrapping; - private DarkUI.Controls.DarkComboBox comboBox_ColorSchemes; - private DarkUI.Controls.DarkComboBox comboBox_FontFamily; - private DarkUI.Controls.DarkContextMenu buttonContextMenu; - private DarkUI.Controls.DarkGroupBox groupBox_Colors; - private DarkUI.Controls.DarkGroupBox groupBox_Preview; - private DarkUI.Controls.DarkLabel darkLabel1; - private DarkUI.Controls.DarkLabel darkLabel10; - private DarkUI.Controls.DarkLabel darkLabel11; - private DarkUI.Controls.DarkLabel darkLabel12; - private DarkUI.Controls.DarkLabel darkLabel2; - private DarkUI.Controls.DarkLabel darkLabel3; - private DarkUI.Controls.DarkLabel darkLabel4; - private DarkUI.Controls.DarkLabel darkLabel5; - private DarkUI.Controls.DarkLabel darkLabel6; - private DarkUI.Controls.DarkLabel darkLabel7; - private DarkUI.Controls.DarkLabel darkLabel8; - private DarkUI.Controls.DarkNumericUpDown numeric_FontSize; - private DarkUI.Controls.DarkNumericUpDown numeric_UndoStackSize; - private DarkUI.Controls.DarkSectionPanel sectionPanel; - private System.Windows.Forms.ColorDialog colorDialog; - private System.Windows.Forms.Integration.ElementHost elementHost; - private System.Windows.Forms.ToolStripMenuItem menuItem_Bold; - private System.Windows.Forms.ToolStripMenuItem menuItem_Italic; - private System.Windows.Forms.ToolTip toolTip; - private DarkUI.Controls.DarkCheckBox checkBox_CloseBraces; - private DarkUI.Controls.DarkCheckBox checkBox_CloseQuotes; - private DarkUI.Controls.DarkCheckBox checkBox_CloseBrackets; - private DarkUI.Controls.DarkCheckBox checkBox_CloseParentheses; - } -} diff --git a/TombIDE/TombIDE.ScriptingStudio/Settings/LuaSettingsControl.cs b/TombIDE/TombIDE.ScriptingStudio/Settings/LuaSettingsControl.cs deleted file mode 100644 index 290814c554..0000000000 --- a/TombIDE/TombIDE.ScriptingStudio/Settings/LuaSettingsControl.cs +++ /dev/null @@ -1,488 +0,0 @@ -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.Objects; -using TombLib.Scripting.Resources; -using TombLib.Utils; - -namespace TombIDE.ScriptingStudio.Settings -{ - internal partial class LuaSettingsControl : UserControl - { - // TODO: Refactor !!! - - private LuaEditor editorPreview; - - #region Construction - - public LuaSettingsControl() - { - InitializeComponent(); - } - - public void Initialize(LuaEditorConfiguration config) - { - InitializePreview(); - - FillFontList(); - UpdateSchemeList(); - UpdateControlsWithSettings(config); - } - - 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", - 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 FillFontList() - { - var fontList = new List(); - - foreach (FontFamily font in new InstalledFontCollection().Families) - fontList.Add(font.Name); - - comboBox_FontFamily.Items.AddRange(fontList.ToArray()); - } - - private void UpdateSchemeList() - { - string cachedSelectedItem = null; - - if (comboBox_ColorSchemes.SelectedItem != null) - 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)); - - if (cachedSelectedItem != null) - comboBox_ColorSchemes.SelectedItem = cachedSelectedItem; - } - - #endregion Construction - - #region Events - - private void VisiblePreviewSetting_Changed(object sender, EventArgs e) => - UpdatePreviewTemp(); - - private void comboBox_FontFamily_SelectedIndexChanged(object sender, EventArgs e) => - UpdatePreviewTemp(false); - - 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(); - - 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) => - 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 - - #region Loading - - private void UpdateControlsWithSettings(LuaEditorConfiguration config) - { - numeric_FontSize.Value = (decimal)config.FontSize - 4; // -4 because AvalonEdit has a different font size scale - comboBox_FontFamily.SelectedItem = config.FontFamily; - numeric_UndoStackSize.Value = config.UndoStackSize; - - LoadSettingsForCheckBoxes(config); - - comboBox_ColorSchemes.SelectedItem = config.SelectedColorSchemeName; - } - - private void LoadSettingsForCheckBoxes(LuaEditorConfiguration config) - { - checkBox_Autocomplete.Checked = config.AutocompleteEnabled; - checkBox_WordWrapping.Checked = config.WordWrapping; - - checkBox_CloseParentheses.Checked = config.AutoCloseParentheses; - checkBox_CloseBrackets.Checked = config.AutoCloseBrackets; - checkBox_CloseQuotes.Checked = config.AutoCloseQuotes; - checkBox_CloseBraces.Checked = config.AutoCloseBraces; - - checkBox_HighlightCurrentLine.Checked = config.HighlightCurrentLine; - checkBox_LineNumbers.Checked = config.ShowLineNumbers; - - checkBox_VisibleSpaces.Checked = config.ShowVisualSpaces; - checkBox_VisibleTabs.Checked = config.ShowVisualTabs; - } - - #endregion Loading - - #region Applying - - 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.UndoStackSize = (int)numeric_UndoStackSize.Value; - - ApplySettingsFromCheckBoxes(config); - - //config.SelectedColorSchemeName = comboBox_ColorSchemes.SelectedItem.ToString(); - - config.Save(); - } - - private void ApplySettingsFromCheckBoxes(LuaEditorConfiguration config) - { - config.AutocompleteEnabled = checkBox_Autocomplete.Checked; - config.WordWrapping = checkBox_WordWrapping.Checked; - - config.AutoCloseParentheses = checkBox_CloseParentheses.Checked; - config.AutoCloseBrackets = checkBox_CloseBrackets.Checked; - config.AutoCloseQuotes = checkBox_CloseQuotes.Checked; - config.AutoCloseBraces = checkBox_CloseBraces.Checked; - - config.HighlightCurrentLine = checkBox_HighlightCurrentLine.Checked; - config.ShowLineNumbers = checkBox_LineNumbers.Checked; - - config.ShowVisualSpaces = checkBox_VisibleSpaces.Checked; - config.ShowVisualTabs = checkBox_VisibleTabs.Checked; - } - - #endregion Applying - - #region Resetting - - public void ResetToDefault() - { - numeric_FontSize.Value = (decimal)(TextEditorBaseDefaults.FontSize - 4); // -4 because AvalonEdit has a different font size scale - comboBox_FontFamily.SelectedItem = TextEditorBaseDefaults.FontFamily; - numeric_UndoStackSize.Value = TextEditorBaseDefaults.UndoStackSize; - - ResetCheckBoxSettings(); - } - - private void ResetCheckBoxSettings() - { - checkBox_Autocomplete.Checked = TextEditorBaseDefaults.AutocompleteEnabled; - checkBox_WordWrapping.Checked = TextEditorBaseDefaults.WordWrapping; - - checkBox_CloseParentheses.Checked = TextEditorBaseDefaults.AutoCloseParentheses; - checkBox_CloseBrackets.Checked = TextEditorBaseDefaults.AutoCloseBrackets; - checkBox_CloseQuotes.Checked = TextEditorBaseDefaults.AutoCloseQuotes; - checkBox_CloseBraces.Checked = TextEditorBaseDefaults.AutoCloseBraces; - - 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(); - - private void ChangeColor(DarkButton targetButton) - { - 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(); - - UpdateColorButtonStyleText(targetButton); - } - } - - private void UpdatePreview() - { - 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) - }; - - 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); - } - - 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(); - } - - private void UpdateColorButtonStyleText(DarkButton colorButton) - { - 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; - } - - private void UpdatePreviewColors(ColorScheme scheme) - { - 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); - } - - private void ToggleSaveSchemeButton() - { - bool isUntitled = comboBox_ColorSchemes.SelectedItem.ToString().Equals("~UNTITLED", StringComparison.OrdinalIgnoreCase); - - button_SaveScheme.Enabled = isUntitled; - button_SaveScheme.Visible = isUntitled; - - comboBox_ColorSchemes.Width = isUntitled ? 395 : 426; - } - - private void UpdatePreviewTemp(bool forceUpdate = true) - { - 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()); - - editorPreview.WordWrap = checkBox_WordWrapping.Checked; - editorPreview.Options.HighlightCurrentLine = checkBox_HighlightCurrentLine.Checked; - editorPreview.ShowLineNumbers = checkBox_LineNumbers.Checked; - - editorPreview.Options.ShowSpaces = checkBox_VisibleSpaces.Checked; - editorPreview.Options.ShowTabs = checkBox_VisibleTabs.Checked; - - if (forceUpdate) - ForcePreviewUpdate(); - } - } -} diff --git a/TombIDE/TombIDE.ScriptingStudio/Settings/LuaSettingsControl.resx b/TombIDE/TombIDE.ScriptingStudio/Settings/LuaSettingsControl.resx deleted file mode 100644 index 7acb45ee43..0000000000 --- a/TombIDE/TombIDE.ScriptingStudio/Settings/LuaSettingsControl.resx +++ /dev/null @@ -1,135 +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 - - - 254, 17 - - - 345, 17 - - - 139, 17 - - - 17, 17 - - - 254, 17 - - \ No newline at end of file diff --git a/TombIDE/TombIDE.ScriptingStudio/Settings/ScriptingSettingsWindow.xaml b/TombIDE/TombIDE.ScriptingStudio/Settings/ScriptingSettingsWindow.xaml new file mode 100644 index 0000000000..206ef7ffd5 --- /dev/null +++ b/TombIDE/TombIDE.ScriptingStudio/Settings/ScriptingSettingsWindow.xaml @@ -0,0 +1,212 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +