From 5e9c5ea4c0e49f6f1dbcca8aeee83ccfcf5ab8dc Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 9 Sep 2024 15:27:25 -0500 Subject: [PATCH 01/18] Rework LSP To Use Workspace Files, Standardize Errors --- CodeEdit.xcodeproj/project.pbxproj | 115 +++++--- CodeEdit/AppDelegate.swift | 13 +- .../CodeFileDocument/CodeFileDocument.swift | 21 ++ .../CodeEditDocumentController.swift | 9 + .../Features/Editor/Views/CodeFileView.swift | 13 +- .../LanguageIdentifier+CodeLanguage.swift | 83 ++++++ CodeEdit/Features/LSP/LSPService.swift | 212 --------------- .../LanguageServer+CallHierarchy.swift | 57 ++++ .../LanguageServer+ColorPresentation.swift | 31 +++ .../LanguageServer+Completion.swift | 37 +++ .../LanguageServer+Declaration.swift | 7 +- .../LanguageServer+Definition.swift | 37 +++ .../LanguageServer+Diagnostics.swift | 37 +++ .../LanguageServer+DocumentColor.swift | 17 +- .../LanguageServer+DocumentHighlight.swift | 13 +- .../LanguageServer+DocumentLink.swift | 7 +- .../LanguageServer+DocumentSymbol.swift | 22 ++ .../LanguageServer+DocumentSync.swift | 122 +++++++++ .../LanguageServer+FoldingRange.swift | 7 +- .../LanguageServer+Formatting.swift | 26 +- .../Capabilities}/LanguageServer+Hover.swift | 6 +- .../LanguageServer+Implementation.swift | 6 +- .../LanguageServer+InlayHint.swift | 12 +- .../LanguageServer+References.swift | 8 +- .../Capabilities}/LanguageServer+Rename.swift | 16 +- .../LanguageServer+SelectionRange.swift | 6 +- .../LanguageServer+SemanticTokens.swift | 27 +- .../LanguageServer+SignatureHelp.swift | 6 +- .../LanguageServer+TypeDefinition.swift | 6 +- .../{ => LanguageServer}/LSPCache+Data.swift | 0 .../LSP/{ => LanguageServer}/LSPCache.swift | 2 +- .../{ => LanguageServer}/LanguageServer.swift | 115 +++++--- .../LanguageServerFileMap.swift | 62 +++++ .../LanguageServer+CallHierarchy.swift | 70 ----- .../LanguageServer+ColorPresentation.swift | 32 --- .../LanguageServer+Completion.swift | 32 --- .../LanguageServer+Definition.swift | 36 --- .../LanguageServer+Diagnostics.swift | 32 --- .../LanguageServer+DocumentSymbol.swift | 20 -- .../LanguageServer+DocumentUtil.swift | 180 ------------- .../LSPService+Events.swift} | 26 +- .../Features/LSP/Service/LSPService.swift | 247 ++++++++++++++++++ .../LazyServiceWrapper.swift | 36 +++ .../Extensions/URL}/URL+FuzzySearchable.swift | 0 .../Extensions/URL/URL+LanguageServer.swift | 14 + .../Extensions/URL}/URL+URLParameters.swift | 0 46 files changed, 1086 insertions(+), 797 deletions(-) create mode 100644 CodeEdit/Features/LSP/Extensions/LanguageIdentifier+CodeLanguage.swift delete mode 100644 CodeEdit/Features/LSP/LSPService.swift create mode 100644 CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+CallHierarchy.swift create mode 100644 CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+ColorPresentation.swift create mode 100644 CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Completion.swift rename CodeEdit/Features/LSP/{LanguageServerExtensions => LanguageServer/Capabilities}/LanguageServer+Declaration.swift (68%) create mode 100644 CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Definition.swift create mode 100644 CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Diagnostics.swift rename CodeEdit/Features/LSP/{LanguageServerExtensions => LanguageServer/Capabilities}/LanguageServer+DocumentColor.swift (65%) rename CodeEdit/Features/LSP/{LanguageServerExtensions => LanguageServer/Capabilities}/LanguageServer+DocumentHighlight.swift (77%) rename CodeEdit/Features/LSP/{LanguageServerExtensions => LanguageServer/Capabilities}/LanguageServer+DocumentLink.swift (60%) create mode 100644 CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSymbol.swift create mode 100644 CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift rename CodeEdit/Features/LSP/{LanguageServerExtensions => LanguageServer/Capabilities}/LanguageServer+FoldingRange.swift (66%) rename CodeEdit/Features/LSP/{LanguageServerExtensions => LanguageServer/Capabilities}/LanguageServer+Formatting.swift (75%) rename CodeEdit/Features/LSP/{LanguageServerExtensions => LanguageServer/Capabilities}/LanguageServer+Hover.swift (75%) rename CodeEdit/Features/LSP/{LanguageServerExtensions => LanguageServer/Capabilities}/LanguageServer+Implementation.swift (72%) rename CodeEdit/Features/LSP/{LanguageServerExtensions => LanguageServer/Capabilities}/LanguageServer+InlayHint.swift (73%) rename CodeEdit/Features/LSP/{LanguageServerExtensions => LanguageServer/Capabilities}/LanguageServer+References.swift (80%) rename CodeEdit/Features/LSP/{LanguageServerExtensions => LanguageServer/Capabilities}/LanguageServer+Rename.swift (71%) rename CodeEdit/Features/LSP/{LanguageServerExtensions => LanguageServer/Capabilities}/LanguageServer+SelectionRange.swift (72%) rename CodeEdit/Features/LSP/{LanguageServerExtensions => LanguageServer/Capabilities}/LanguageServer+SemanticTokens.swift (60%) rename CodeEdit/Features/LSP/{LanguageServerExtensions => LanguageServer/Capabilities}/LanguageServer+SignatureHelp.swift (72%) rename CodeEdit/Features/LSP/{LanguageServerExtensions => LanguageServer/Capabilities}/LanguageServer+TypeDefinition.swift (72%) rename CodeEdit/Features/LSP/{ => LanguageServer}/LSPCache+Data.swift (100%) rename CodeEdit/Features/LSP/{ => LanguageServer}/LSPCache.swift (98%) rename CodeEdit/Features/LSP/{ => LanguageServer}/LanguageServer.swift (62%) create mode 100644 CodeEdit/Features/LSP/LanguageServer/LanguageServerFileMap.swift delete mode 100644 CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+CallHierarchy.swift delete mode 100644 CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+ColorPresentation.swift delete mode 100644 CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+Completion.swift delete mode 100644 CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+Definition.swift delete mode 100644 CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+Diagnostics.swift delete mode 100644 CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+DocumentSymbol.swift delete mode 100644 CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+DocumentUtil.swift rename CodeEdit/Features/LSP/{LSPEventHandler.swift => Service/LSPService+Events.swift} (78%) create mode 100644 CodeEdit/Features/LSP/Service/LSPService.swift create mode 100644 CodeEdit/Utils/DependencyInjection/LazyServiceWrapper.swift rename CodeEdit/{Features/OpenQuickly/ViewModels => Utils/Extensions/URL}/URL+FuzzySearchable.swift (100%) create mode 100644 CodeEdit/Utils/Extensions/URL/URL+LanguageServer.swift rename CodeEdit/{Features/SourceControl/Accounts/Utils => Utils/Extensions/URL}/URL+URLParameters.swift (100%) diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 2a2bd8d04..34b9b7ace 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -64,7 +64,6 @@ 3000516A2BBD3A8200A98562 /* ServiceType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 300051692BBD3A8200A98562 /* ServiceType.swift */; }; 3000516C2BBD3A9500A98562 /* ServiceWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3000516B2BBD3A9500A98562 /* ServiceWrapper.swift */; }; 3026F50F2AC006C80061227E /* InspectorAreaViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3026F50E2AC006C80061227E /* InspectorAreaViewModel.swift */; }; - 30571B762C282592009CD5BB /* LSPCache+Data.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30571B752C282592009CD5BB /* LSPCache+Data.swift */; }; 30AB4EBB2BF718A100ED4431 /* DeveloperSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30AB4EBA2BF718A100ED4431 /* DeveloperSettings.swift */; }; 30AB4EBD2BF71CA800ED4431 /* DeveloperSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30AB4EBC2BF71CA800ED4431 /* DeveloperSettingsView.swift */; }; 30AB4EC22BF7253200ED4431 /* KeyValueTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30AB4EC12BF7253200ED4431 /* KeyValueTable.swift */; }; @@ -78,7 +77,6 @@ 30B088042C0D53080063A882 /* LanguageServer+DocumentHighlight.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B087E72C0D53080063A882 /* LanguageServer+DocumentHighlight.swift */; }; 30B088052C0D53080063A882 /* LanguageServer+DocumentLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B087E82C0D53080063A882 /* LanguageServer+DocumentLink.swift */; }; 30B088062C0D53080063A882 /* LanguageServer+DocumentSymbol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B087E92C0D53080063A882 /* LanguageServer+DocumentSymbol.swift */; }; - 30B088072C0D53080063A882 /* LanguageServer+DocumentUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B087EA2C0D53080063A882 /* LanguageServer+DocumentUtil.swift */; }; 30B088082C0D53080063A882 /* LanguageServer+FoldingRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B087EB2C0D53080063A882 /* LanguageServer+FoldingRange.swift */; }; 30B088092C0D53080063A882 /* LanguageServer+Formatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B087EC2C0D53080063A882 /* LanguageServer+Formatting.swift */; }; 30B0880A2C0D53080063A882 /* LanguageServer+Hover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B087ED2C0D53080063A882 /* LanguageServer+Hover.swift */; }; @@ -92,9 +90,8 @@ 30B088122C0D53080063A882 /* LanguageServer+TypeDefinition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B087F52C0D53080063A882 /* LanguageServer+TypeDefinition.swift */; }; 30B088142C0D53080063A882 /* LanguageServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B087F72C0D53080063A882 /* LanguageServer.swift */; }; 30B088152C0D53080063A882 /* LSPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B087F82C0D53080063A882 /* LSPService.swift */; }; - 30B088162C0D53080063A882 /* LSPCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B087F92C0D53080063A882 /* LSPCache.swift */; }; 30B088172C0D53080063A882 /* LSPUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30B087FA2C0D53080063A882 /* LSPUtil.swift */; }; - 30CB648D2C12680F00CC8A9E /* LSPEventHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30CB648C2C12680F00CC8A9E /* LSPEventHandler.swift */; }; + 30CB648D2C12680F00CC8A9E /* LSPService+Events.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30CB648C2C12680F00CC8A9E /* LSPService+Events.swift */; }; 30CB64912C16CA8100CC8A9E /* LanguageServerProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = 30CB64902C16CA8100CC8A9E /* LanguageServerProtocol */; }; 30CB64942C16CA9100CC8A9E /* LanguageClient in Frameworks */ = {isa = PBXBuildFile; productRef = 30CB64932C16CA9100CC8A9E /* LanguageClient */; }; 30E6D0012A6E505200A58B20 /* NavigatorSidebarViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30E6D0002A6E505200A58B20 /* NavigatorSidebarViewModel.swift */; }; @@ -449,11 +446,19 @@ 6CC17B4F2C432AE000834E2C /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6CC17B4E2C432AE000834E2C /* CodeEditSourceEditor */; }; 6CC17B512C43311900834E2C /* ProjectNavigatorViewController+NSOutlineViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CC17B502C43311900834E2C /* ProjectNavigatorViewController+NSOutlineViewDataSource.swift */; }; 6CC17B532C43314000834E2C /* ProjectNavigatorViewController+NSOutlineViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CC17B522C43314000834E2C /* ProjectNavigatorViewController+NSOutlineViewDelegate.swift */; }; - 6CC17B592C43F53700834E2C /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6CC17B582C43F53700834E2C /* CodeEditSourceEditor */; }; 6CC17B5B2C44258700834E2C /* WindowControllerPropertyWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CC17B5A2C44258700834E2C /* WindowControllerPropertyWrapper.swift */; }; 6CC9E4B229B5669900C97388 /* Environment+ActiveEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CC9E4B129B5669900C97388 /* Environment+ActiveEditor.swift */; }; 6CD0358A2C3461160091E1F4 /* KeyWindowControllerObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD035892C3461160091E1F4 /* KeyWindowControllerObserver.swift */; }; 6CD03B6A29FC773F001BD1D0 /* SettingsInjector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD03B6929FC773F001BD1D0 /* SettingsInjector.swift */; }; + 6CD26C6E2C8EA1E600ADBA38 /* LanguageServerFileMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD26C6D2C8EA1E600ADBA38 /* LanguageServerFileMap.swift */; }; + 6CD26C772C8EA83900ADBA38 /* URL+LanguageServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD26C762C8EA83900ADBA38 /* URL+LanguageServer.swift */; }; + 6CD26C7A2C8EA8A500ADBA38 /* LSPCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD26C782C8EA8A500ADBA38 /* LSPCache.swift */; }; + 6CD26C7B2C8EA8A500ADBA38 /* LSPCache+Data.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD26C792C8EA8A500ADBA38 /* LSPCache+Data.swift */; }; + 6CD26C7D2C8EA8F400ADBA38 /* LanguageServer+DocumentSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD26C7C2C8EA8F400ADBA38 /* LanguageServer+DocumentSync.swift */; }; + 6CD26C812C8F8A4400ADBA38 /* LanguageIdentifier+CodeLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD26C802C8F8A4400ADBA38 /* LanguageIdentifier+CodeLanguage.swift */; }; + 6CD26C852C8F907800ADBA38 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6CD26C842C8F907800ADBA38 /* CodeEditSourceEditor */; }; + 6CD26C872C8F90FD00ADBA38 /* LazyServiceWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD26C862C8F90FD00ADBA38 /* LazyServiceWrapper.swift */; }; + 6CD3CA552C8B508200D83DCD /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6CD3CA542C8B508200D83DCD /* CodeEditSourceEditor */; }; 6CDA84AD284C1BA000C1CC3A /* EditorTabBarContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CDA84AC284C1BA000C1CC3A /* EditorTabBarContextMenu.swift */; }; 6CE21E812C643D8F0031B056 /* CETerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CE21E802C643D8F0031B056 /* CETerminalView.swift */; }; 6CE21E872C650D2C0031B056 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 6CE21E862C650D2C0031B056 /* SwiftTerm */; }; @@ -726,7 +731,6 @@ 300051692BBD3A8200A98562 /* ServiceType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceType.swift; sourceTree = ""; }; 3000516B2BBD3A9500A98562 /* ServiceWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceWrapper.swift; sourceTree = ""; }; 3026F50E2AC006C80061227E /* InspectorAreaViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectorAreaViewModel.swift; sourceTree = ""; }; - 30571B752C282592009CD5BB /* LSPCache+Data.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LSPCache+Data.swift"; sourceTree = ""; }; 30AB4EBA2BF718A100ED4431 /* DeveloperSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettings.swift; sourceTree = ""; }; 30AB4EBC2BF71CA800ED4431 /* DeveloperSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettingsView.swift; sourceTree = ""; }; 30AB4EC12BF7253200ED4431 /* KeyValueTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyValueTable.swift; sourceTree = ""; }; @@ -740,7 +744,6 @@ 30B087E72C0D53080063A882 /* LanguageServer+DocumentHighlight.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "LanguageServer+DocumentHighlight.swift"; sourceTree = ""; }; 30B087E82C0D53080063A882 /* LanguageServer+DocumentLink.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "LanguageServer+DocumentLink.swift"; sourceTree = ""; }; 30B087E92C0D53080063A882 /* LanguageServer+DocumentSymbol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "LanguageServer+DocumentSymbol.swift"; sourceTree = ""; }; - 30B087EA2C0D53080063A882 /* LanguageServer+DocumentUtil.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "LanguageServer+DocumentUtil.swift"; sourceTree = ""; }; 30B087EB2C0D53080063A882 /* LanguageServer+FoldingRange.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "LanguageServer+FoldingRange.swift"; sourceTree = ""; }; 30B087EC2C0D53080063A882 /* LanguageServer+Formatting.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "LanguageServer+Formatting.swift"; sourceTree = ""; }; 30B087ED2C0D53080063A882 /* LanguageServer+Hover.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "LanguageServer+Hover.swift"; sourceTree = ""; }; @@ -754,9 +757,8 @@ 30B087F52C0D53080063A882 /* LanguageServer+TypeDefinition.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "LanguageServer+TypeDefinition.swift"; sourceTree = ""; }; 30B087F72C0D53080063A882 /* LanguageServer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LanguageServer.swift; sourceTree = ""; }; 30B087F82C0D53080063A882 /* LSPService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LSPService.swift; sourceTree = ""; }; - 30B087F92C0D53080063A882 /* LSPCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LSPCache.swift; sourceTree = ""; }; 30B087FA2C0D53080063A882 /* LSPUtil.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LSPUtil.swift; sourceTree = ""; }; - 30CB648C2C12680F00CC8A9E /* LSPEventHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LSPEventHandler.swift; sourceTree = ""; }; + 30CB648C2C12680F00CC8A9E /* LSPService+Events.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LSPService+Events.swift"; sourceTree = ""; }; 30E6D0002A6E505200A58B20 /* NavigatorSidebarViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigatorSidebarViewModel.swift; sourceTree = ""; }; 3E0196722A3921AC002648D8 /* codeedit_shell_integration_rc.zsh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = codeedit_shell_integration_rc.zsh; sourceTree = ""; }; 3E0196792A392B45002648D8 /* codeedit_shell_integration.bash */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = codeedit_shell_integration.bash; sourceTree = ""; }; @@ -1098,6 +1100,13 @@ 6CC9E4B129B5669900C97388 /* Environment+ActiveEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Environment+ActiveEditor.swift"; sourceTree = ""; }; 6CD035892C3461160091E1F4 /* KeyWindowControllerObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyWindowControllerObserver.swift; sourceTree = ""; }; 6CD03B6929FC773F001BD1D0 /* SettingsInjector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsInjector.swift; sourceTree = ""; }; + 6CD26C6D2C8EA1E600ADBA38 /* LanguageServerFileMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageServerFileMap.swift; sourceTree = ""; }; + 6CD26C762C8EA83900ADBA38 /* URL+LanguageServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+LanguageServer.swift"; sourceTree = ""; }; + 6CD26C782C8EA8A500ADBA38 /* LSPCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LSPCache.swift; sourceTree = ""; }; + 6CD26C792C8EA8A500ADBA38 /* LSPCache+Data.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "LSPCache+Data.swift"; sourceTree = ""; }; + 6CD26C7C2C8EA8F400ADBA38 /* LanguageServer+DocumentSync.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "LanguageServer+DocumentSync.swift"; sourceTree = ""; }; + 6CD26C802C8F8A4400ADBA38 /* LanguageIdentifier+CodeLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LanguageIdentifier+CodeLanguage.swift"; sourceTree = ""; }; + 6CD26C862C8F90FD00ADBA38 /* LazyServiceWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyServiceWrapper.swift; sourceTree = ""; }; 6CDA84AC284C1BA000C1CC3A /* EditorTabBarContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorTabBarContextMenu.swift; sourceTree = ""; }; 6CE21E802C643D8F0031B056 /* CETerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CETerminalView.swift; sourceTree = ""; }; 6CE622682A2A174A0013085C /* InspectorTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectorTab.swift; sourceTree = ""; }; @@ -1271,6 +1280,7 @@ 58F2EB1E292FB954004A9BDE /* Sparkle in Frameworks */, 6C147C4529A329350089B630 /* OrderedCollections in Frameworks */, 6CE21E872C650D2C0031B056 /* SwiftTerm in Frameworks */, + 6CD3CA552C8B508200D83DCD /* CodeEditSourceEditor in Frameworks */, 6C0617D62BDB4432008C9C42 /* LogStream in Frameworks */, 6CC17B4F2C432AE000834E2C /* CodeEditSourceEditor in Frameworks */, 30CB64912C16CA8100CC8A9E /* LanguageServerProtocol in Frameworks */, @@ -1279,8 +1289,8 @@ 6C85BB442C210EFD00EB5DEF /* SwiftUIIntrospect in Frameworks */, 6CB446402B6DFF3A00539ED0 /* CodeEditSourceEditor in Frameworks */, 2816F594280CF50500DD548B /* CodeEditSymbols in Frameworks */, + 6CD26C852C8F907800ADBA38 /* CodeEditSourceEditor in Frameworks */, 30CB64942C16CA9100CC8A9E /* LanguageClient in Frameworks */, - 6CC17B592C43F53700834E2C /* CodeEditSourceEditor in Frameworks */, 6C6BD6F829CD14D100235D17 /* CodeEditKit in Frameworks */, 6C0824A12C5C0C9700A0751E /* SwiftTerm in Frameworks */, 6C81916B29B41DD300B75C92 /* DequeModule in Frameworks */, @@ -1485,6 +1495,7 @@ 300051662BBD3A5D00A98562 /* ServiceContainer.swift */, 300051692BBD3A8200A98562 /* ServiceType.swift */, 3000516B2BBD3A9500A98562 /* ServiceWrapper.swift */, + 6CD26C862C8F90FD00ADBA38 /* LazyServiceWrapper.swift */, ); path = DependencyInjection; sourceTree = ""; @@ -1525,18 +1536,15 @@ 30B087FB2C0D53080063A882 /* LSP */ = { isa = PBXGroup; children = ( - 30B0881E2C12626B0063A882 /* LanguageServerExtensions */, - 30B087F72C0D53080063A882 /* LanguageServer.swift */, - 30B087F82C0D53080063A882 /* LSPService.swift */, - 30B087F92C0D53080063A882 /* LSPCache.swift */, + 6CD26C822C8F8A5F00ADBA38 /* Extensions */, + 6CD26C732C8EA71F00ADBA38 /* LanguageServer */, + 6CD26C742C8EA79100ADBA38 /* Service */, 30B087FA2C0D53080063A882 /* LSPUtil.swift */, - 30CB648C2C12680F00CC8A9E /* LSPEventHandler.swift */, - 30571B752C282592009CD5BB /* LSPCache+Data.swift */, ); path = LSP; sourceTree = ""; }; - 30B0881E2C12626B0063A882 /* LanguageServerExtensions */ = { + 30B0881E2C12626B0063A882 /* Capabilities */ = { isa = PBXGroup; children = ( 30B087DF2C0D53080063A882 /* LanguageServer+CallHierarchy.swift */, @@ -1545,11 +1553,11 @@ 30B087E32C0D53080063A882 /* LanguageServer+Declaration.swift */, 30B087E42C0D53080063A882 /* LanguageServer+Definition.swift */, 30B087E52C0D53080063A882 /* LanguageServer+Diagnostics.swift */, + 6CD26C7C2C8EA8F400ADBA38 /* LanguageServer+DocumentSync.swift */, 30B087E62C0D53080063A882 /* LanguageServer+DocumentColor.swift */, 30B087E72C0D53080063A882 /* LanguageServer+DocumentHighlight.swift */, 30B087E82C0D53080063A882 /* LanguageServer+DocumentLink.swift */, 30B087E92C0D53080063A882 /* LanguageServer+DocumentSymbol.swift */, - 30B087EA2C0D53080063A882 /* LanguageServer+DocumentUtil.swift */, 30B087EB2C0D53080063A882 /* LanguageServer+FoldingRange.swift */, 30B087EC2C0D53080063A882 /* LanguageServer+Formatting.swift */, 30B087ED2C0D53080063A882 /* LanguageServer+Hover.swift */, @@ -1562,7 +1570,7 @@ 30B087F42C0D53080063A882 /* LanguageServer+SignatureHelp.swift */, 30B087F52C0D53080063A882 /* LanguageServer+TypeDefinition.swift */, ); - path = LanguageServerExtensions; + path = Capabilities; sourceTree = ""; }; 3E0196712A392170002648D8 /* ShellIntegration */ = { @@ -1859,7 +1867,6 @@ isa = PBXGroup; children = ( 5878DAA3291AE76700DD95A3 /* OpenQuicklyViewModel.swift */, - 613899BB2B6E709C00A5CAF6 /* URL+FuzzySearchable.swift */, ); path = ViewModels; sourceTree = ""; @@ -2137,7 +2144,6 @@ 587B9E2929301D8F00AC7927 /* GitTime.swift */, 587B9E2A29301D8F00AC7927 /* String+PercentEncoding.swift */, 587B9E2829301D8F00AC7927 /* String+QueryParameters.swift */, - 587B9E2729301D8F00AC7927 /* URL+URLParameters.swift */, ); path = Utils; sourceTree = ""; @@ -2423,6 +2429,7 @@ 58D01C8B293167DC00C5B6B4 /* String */, 5831E3CB2933E89A00D5A6D2 /* SwiftTerm */, 6CBD1BC42978DE3E006639D5 /* Text */, + 6CD26C752C8EA80000ADBA38 /* URL */, 5831E3CA2933E86F00D5A6D2 /* View */, ); path = Extensions; @@ -2979,6 +2986,45 @@ path = CodeFileDocument; sourceTree = ""; }; + 6CD26C732C8EA71F00ADBA38 /* LanguageServer */ = { + isa = PBXGroup; + children = ( + 30B087F72C0D53080063A882 /* LanguageServer.swift */, + 6CD26C6D2C8EA1E600ADBA38 /* LanguageServerFileMap.swift */, + 6CD26C782C8EA8A500ADBA38 /* LSPCache.swift */, + 6CD26C792C8EA8A500ADBA38 /* LSPCache+Data.swift */, + 30B0881E2C12626B0063A882 /* Capabilities */, + ); + path = LanguageServer; + sourceTree = ""; + }; + 6CD26C742C8EA79100ADBA38 /* Service */ = { + isa = PBXGroup; + children = ( + 30B087F82C0D53080063A882 /* LSPService.swift */, + 30CB648C2C12680F00CC8A9E /* LSPService+Events.swift */, + ); + path = Service; + sourceTree = ""; + }; + 6CD26C752C8EA80000ADBA38 /* URL */ = { + isa = PBXGroup; + children = ( + 613899BB2B6E709C00A5CAF6 /* URL+FuzzySearchable.swift */, + 6CD26C762C8EA83900ADBA38 /* URL+LanguageServer.swift */, + 587B9E2729301D8F00AC7927 /* URL+URLParameters.swift */, + ); + path = URL; + sourceTree = ""; + }; + 6CD26C822C8F8A5F00ADBA38 /* Extensions */ = { + isa = PBXGroup; + children = ( + 6CD26C802C8F8A4400ADBA38 /* LanguageIdentifier+CodeLanguage.swift */, + ); + path = Extensions; + sourceTree = ""; + }; 6CFBA54E2C4E182100E3A914 /* Other Tests */ = { isa = PBXGroup; children = ( @@ -3595,10 +3641,11 @@ 6C85BB3F2C2105ED00EB5DEF /* CodeEditKit */, 6C85BB432C210EFD00EB5DEF /* SwiftUIIntrospect */, 6CC17B4E2C432AE000834E2C /* CodeEditSourceEditor */, - 6CC17B582C43F53700834E2C /* CodeEditSourceEditor */, 6C0824A02C5C0C9700A0751E /* SwiftTerm */, 6CE21E862C650D2C0031B056 /* SwiftTerm */, 6C4E37FB2C73E00700AEE7B5 /* SwiftTerm */, + 6CD3CA542C8B508200D83DCD /* CodeEditSourceEditor */, + 6CD26C842C8F907800ADBA38 /* CodeEditSourceEditor */, ); productName = CodeEdit; productReference = B658FB2C27DA9E0F00EA4DBD /* CodeEdit.app */; @@ -3694,8 +3741,8 @@ 6C85BB422C210EFD00EB5DEF /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, 303E88452C276FD100EEA8D9 /* XCRemoteSwiftPackageReference "LanguageClient" */, 303E88462C276FD600EEA8D9 /* XCRemoteSwiftPackageReference "LanguageServerProtocol" */, - 6CC17B572C43F53700834E2C /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */, 6C4E37FA2C73E00700AEE7B5 /* XCRemoteSwiftPackageReference "SwiftTerm" */, + 6CD26C832C8F907800ADBA38 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */, ); productRefGroup = B658FB2D27DA9E0F00EA4DBD /* Products */; projectDirPath = ""; @@ -3842,11 +3889,13 @@ 3000516C2BBD3A9500A98562 /* ServiceWrapper.swift in Sources */, 587B9DA029300ABD00AC7927 /* PanelDivider.swift in Sources */, 201169E52837B40300F92B46 /* SourceControlNavigatorRepositoryView.swift in Sources */, + 6CD26C6E2C8EA1E600ADBA38 /* LanguageServerFileMap.swift in Sources */, 587B9E6A29301D8F00AC7927 /* GitLabPermissions.swift in Sources */, B6EA1FF529DA380E001BF195 /* TextEditingSettingsView.swift in Sources */, D7DC4B76298FFBE900D6C83D /* ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift in Sources */, 587B9E9229301D8F00AC7927 /* BitBucketAccount.swift in Sources */, DE513F52281B672D002260B9 /* EditorTabBarAccessory.swift in Sources */, + 6CD26C7A2C8EA8A500ADBA38 /* LSPCache.swift in Sources */, 618725A42C29F00400987354 /* WorkspaceMenuItemView.swift in Sources */, 2813F93927ECC4C300E305E4 /* NavigatorAreaView.swift in Sources */, B664C3B02B965F6C00816B4E /* NavigationSettings.swift in Sources */, @@ -3893,7 +3942,6 @@ 66AF6CE42BF17F6800D83C9D /* StatusBarFileInfoView.swift in Sources */, 587B9E5E29301D8F00AC7927 /* GitLabCommitRouter.swift in Sources */, 58F2EB0D292FB2B0004A9BDE /* ThemeSettings.swift in Sources */, - 30571B762C282592009CD5BB /* LSPCache+Data.swift in Sources */, 587B9D9F29300ABD00AC7927 /* SegmentedControl.swift in Sources */, 6C7256D729A3D7D000C2D3E0 /* SplitViewControllerView.swift in Sources */, B6EA1FE529DA33DB001BF195 /* ThemeModel.swift in Sources */, @@ -3945,7 +3993,7 @@ 6C2C156129B4F52F00EA60A5 /* SplitViewModifiers.swift in Sources */, 61A53A812B4449F00093BF8A /* WorkspaceDocument+Index.swift in Sources */, 66AF6CE22BF17CC300D83C9D /* StatusBarViewModel.swift in Sources */, - 30CB648D2C12680F00CC8A9E /* LSPEventHandler.swift in Sources */, + 30CB648D2C12680F00CC8A9E /* LSPService+Events.swift in Sources */, 201169DD2837B3AC00F92B46 /* SourceControlNavigatorToolbarBottom.swift in Sources */, 587B9E8B29301D8F00AC7927 /* GitHubAccount+deleteReference.swift in Sources */, 6CD0358A2C3461160091E1F4 /* KeyWindowControllerObserver.swift in Sources */, @@ -4048,6 +4096,7 @@ 581550CF29FBD30400684881 /* StandardTableViewCell.swift in Sources */, B62AEDB82A1FE2DC009A9F52 /* UtilityAreaOutputView.swift in Sources */, B67DB0FC2AFDF71F002DC647 /* IconToggleStyle.swift in Sources */, + 6CD26C812C8F8A4400ADBA38 /* LanguageIdentifier+CodeLanguage.swift in Sources */, 5994B6DA2BD6B408006A4C5F /* Editor+TabSwitch.swift in Sources */, 587B9E5C29301D8F00AC7927 /* Parameters.swift in Sources */, 61538B932B11201900A88846 /* String+Character.swift in Sources */, @@ -4083,6 +4132,7 @@ B66A4E4529C8E86D004573B4 /* CommandsFixes.swift in Sources */, B6966A2A2C2F687A00259C2D /* SourceControlFetchView.swift in Sources */, 5882252F292C280D00E83CDE /* UtilityAreaClearButton.swift in Sources */, + 6CD26C7D2C8EA8F400ADBA38 /* LanguageServer+DocumentSync.swift in Sources */, 6CE622692A2A174A0013085C /* InspectorTab.swift in Sources */, 58F2EB04292FB2B0004A9BDE /* SourceControlSettings.swift in Sources */, 618725AB2C29F2C000987354 /* TaskDropDownView.swift in Sources */, @@ -4147,7 +4197,6 @@ 610C0FDA2B44438F00A01CA7 /* WorkspaceDocument+FindAndReplace.swift in Sources */, B67431CC2C3E45F30047FCA6 /* SourceControlSwitchView.swift in Sources */, 5882252C292C280D00E83CDE /* UtilityAreaView.swift in Sources */, - 30B088072C0D53080063A882 /* LanguageServer+DocumentUtil.swift in Sources */, 2847019E27FDDF7600F87B6B /* ProjectNavigatorOutlineView.swift in Sources */, B607184C2B17E037009CDAB4 /* SourceControlStashView.swift in Sources */, 6C14CEB32877A68F001468FE /* FindNavigatorMatchListCell.swift in Sources */, @@ -4179,12 +4228,14 @@ B697937A29FF5668002027EC /* AccountsSettingsAccountLink.swift in Sources */, 5B698A0D2B26327800DE9392 /* SearchSettings.swift in Sources */, B685DE7929CC9CCD002860C8 /* StatusBarIcon.swift in Sources */, + 6CD26C872C8F90FD00ADBA38 /* LazyServiceWrapper.swift in Sources */, 587B9DA629300ABD00AC7927 /* ToolbarBranchPicker.swift in Sources */, 6C6BD6F629CD145F00235D17 /* ExtensionInfo.swift in Sources */, 04BA7C202AE2D92B00584E1C /* GitClient+Status.swift in Sources */, 58F2EB05292FB2B0004A9BDE /* Settings.swift in Sources */, 6CBD1BC62978DE53006639D5 /* Font+Caption3.swift in Sources */, 30E6D0012A6E505200A58B20 /* NavigatorSidebarViewModel.swift in Sources */, + 6CD26C7B2C8EA8A500ADBA38 /* LSPCache+Data.swift in Sources */, B6E41C9429DEAE260088F9F4 /* SourceControlAccount.swift in Sources */, 2806E9022979588B000040F4 /* Contributor.swift in Sources */, 58D01C98293167DC00C5B6B4 /* String+RemoveOccurrences.swift in Sources */, @@ -4247,6 +4298,7 @@ 6C147C4129A328BF0089B630 /* EditorLayout.swift in Sources */, B6D7EA592971078500301FAC /* InspectorSection.swift in Sources */, B6AB09A32AAABFEC0003A3A6 /* EditorTabBarLeadingAccessories.swift in Sources */, + 6CD26C772C8EA83900ADBA38 /* URL+LanguageServer.swift in Sources */, B69BFDC72B0686910050D9A6 /* GitClient+Initiate.swift in Sources */, 58F2EAEF292FB2B0004A9BDE /* ThemeSettingsView.swift in Sources */, 85745D632A38F8D900089AAB /* String+HighlightOccurrences.swift in Sources */, @@ -4261,7 +4313,6 @@ 5878DAA5291AE76700DD95A3 /* OpenQuicklyView.swift in Sources */, B6966A302C33282200259C2D /* RemoteBranchPicker.swift in Sources */, 201169D72837B2E300F92B46 /* SourceControlNavigatorView.swift in Sources */, - 30B088162C0D53080063A882 /* LSPCache.swift in Sources */, B6F0517929D9E3C900D72287 /* SourceControlGitView.swift in Sources */, 587B9E8329301D8F00AC7927 /* GitHubPullRequest.swift in Sources */, 6C85F7562C3CA638008E9836 /* EditorHistoryMenus.swift in Sources */, @@ -5568,7 +5619,7 @@ minimumVersion = 1.2.0; }; }; - 6CC17B572C43F53700834E2C /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */ = { + 6CD26C832C8F907800ADBA38 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/CodeEditApp/CodeEditSourceEditor"; requirement = { @@ -5665,9 +5716,13 @@ isa = XCSwiftPackageProductDependency; productName = CodeEditSourceEditor; }; - 6CC17B582C43F53700834E2C /* CodeEditSourceEditor */ = { + 6CD26C842C8F907800ADBA38 /* CodeEditSourceEditor */ = { + isa = XCSwiftPackageProductDependency; + package = 6CD26C832C8F907800ADBA38 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */; + productName = CodeEditSourceEditor; + }; + 6CD3CA542C8B508200D83DCD /* CodeEditSourceEditor */ = { isa = XCSwiftPackageProductDependency; - package = 6CC17B572C43F53700834E2C /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */; productName = CodeEditSourceEditor; }; 6CE21E862C650D2C0031B056 /* SwiftTerm */ = { diff --git a/CodeEdit/AppDelegate.swift b/CodeEdit/AppDelegate.swift index e86b55a79..a3ce021f3 100644 --- a/CodeEdit/AppDelegate.swift +++ b/CodeEdit/AppDelegate.swift @@ -226,13 +226,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { func documentController(_ docController: NSDocumentController, didCloseAll: Bool, contextInfo: Any) { NSApplication.shared.reply(toApplicationShouldTerminate: didCloseAll) } -} -/// Setup all the services into a ServiceContainer for the application to use. -private func setupServiceContainer() { - ServiceContainer.register( - LSPService() - ) + /// Setup all the services into a ServiceContainer for the application to use. + @MainActor + private func setupServiceContainer() { + ServiceContainer.register( + LSPService() + ) + } } extension AppDelegate { diff --git a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift index afcfda12d..3b7b415b2 100644 --- a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift +++ b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift @@ -29,6 +29,8 @@ final class CodeFileDocument: NSDocument, ObservableObject { static let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "CodeFileDocument") + @Service var lspService: LSPService + /// The text content of the document, stored as a text storage /// /// This is intentionally not a `@Published` variable. If it were published, SwiftUI would do a string @@ -75,6 +77,9 @@ final class CodeFileDocument: NSDocument, ObservableObject { return type } + /// A stable string to use when identifying documents with language servers. + var languageServerURI: String? { fileURL?.languageServerURI } + /// Specify options for opening the file such as the initial cursor positions. /// Nulled by ``CodeFileView`` on first load. var openOptions: OpenOptions? @@ -173,4 +178,20 @@ final class CodeFileDocument: NSDocument, ObservableObject { self.isDocumentEditedSubject.send(self.isDocumentEdited) } + + override func close() { + super.close() + lspService.closeDocument(self) + } + + func getLanguage() -> CodeLanguage { + guard let url = fileURL else { + return .default + } + return language ?? CodeLanguage.detectLanguageFrom( + url: url, + prefixBuffer: content?.string.getFirstLines(5), + suffixBuffer: content?.string.getLastLines(5) + ) + } } diff --git a/CodeEdit/Features/Documents/Controllers/CodeEditDocumentController.swift b/CodeEdit/Features/Documents/Controllers/CodeEditDocumentController.swift index a90db1e38..e8b2e7038 100644 --- a/CodeEdit/Features/Documents/Controllers/CodeEditDocumentController.swift +++ b/CodeEdit/Features/Documents/Controllers/CodeEditDocumentController.swift @@ -12,6 +12,8 @@ final class CodeEditDocumentController: NSDocumentController { @Environment(\.openWindow) private var openWindow + @LazyService var lspService: LSPService + private let fileManager = FileManager.default override func newDocument(_ sender: Any?) { @@ -96,6 +98,13 @@ final class CodeEditDocumentController: NSDocumentController { super.clearRecentDocuments(sender) UserDefaults.standard.set([Any](), forKey: "recentProjectPaths") } + + override func addDocument(_ document: NSDocument) { + super.addDocument(document) + if let document = document as? CodeFileDocument { + lspService.openDocument(document) + } + } } extension NSDocumentController { diff --git a/CodeEdit/Features/Editor/Views/CodeFileView.swift b/CodeEdit/Features/Editor/Views/CodeFileView.swift index 627d96074..e6cccee5c 100644 --- a/CodeEdit/Features/Editor/Views/CodeFileView.swift +++ b/CodeEdit/Features/Editor/Views/CodeFileView.swift @@ -119,7 +119,7 @@ struct CodeFileView: View { var body: some View { CodeEditSourceEditor( codeFile.content ?? NSTextStorage(), - language: getLanguage(), + language: codeFile.getLanguage(), theme: currentTheme.editor.editorTheme, font: font, tabWidth: codeFile.defaultTabWidth ?? defaultTabWidth, @@ -156,17 +156,6 @@ struct CodeFileView: View { } } - private func getLanguage() -> CodeLanguage { - guard let url = codeFile.fileURL else { - return .default - } - return codeFile.language ?? CodeLanguage.detectLanguageFrom( - url: url, - prefixBuffer: codeFile.content?.string.getFirstLines(5), - suffixBuffer: codeFile.content?.string.getLastLines(5) - ) - } - private func getBracketPairHighlight() -> BracketPairHighlight? { let color = if Settings[\.textEditing].bracketHighlight.useCustomColor { Settings[\.textEditing].bracketHighlight.color.nsColor diff --git a/CodeEdit/Features/LSP/Extensions/LanguageIdentifier+CodeLanguage.swift b/CodeEdit/Features/LSP/Extensions/LanguageIdentifier+CodeLanguage.swift new file mode 100644 index 000000000..b9171f74d --- /dev/null +++ b/CodeEdit/Features/LSP/Extensions/LanguageIdentifier+CodeLanguage.swift @@ -0,0 +1,83 @@ +// +// LanguageIdentifier+CodeLanguage.swift +// CodeEdit +// +// Created by Khan Winter on 9/9/24. +// + +import LanguageServerProtocol +import CodeEditLanguages + +extension CodeLanguage { + var lspLanguage: LanguageIdentifier? { + switch self.id { + case .agda, + .bash, + .haskell, + .julia, + .kotlin, + .ocaml, + .ocamlInterface, + .regex, + .toml, + .verilog, + .zig, + .plainText: + return nil + case .c: + return .c + case .cpp: + return .cpp + case .cSharp: + return .csharp + case .css: + return .css + case .dart: + return .dart + case .dockerfile: + return .dockerfile + case .elixir: + return .elixir + case .go, .goMod: + return .go + case .html: + return .html + case .java: + return .java + case .javascript, .jsdoc: + return .javascript + case .json: + return .json + case .jsx: + return .javascriptreact + case .lua: + return .lua + case .markdown, .markdownInline: + return .markdown + case .objc: + return .objc + case .perl: + return .perl + case .php: + return .php + case .python: + return .python + case .ruby: + return .ruby + case .rust: + return .rust + case .scala: + return .scala + case .sql: + return .sql + case .swift: + return .swift + case .tsx: + return .typescriptreact + case .typescript: + return .typescript + case .yaml: + return .yaml + } + } +} diff --git a/CodeEdit/Features/LSP/LSPService.swift b/CodeEdit/Features/LSP/LSPService.swift deleted file mode 100644 index a4846b560..000000000 --- a/CodeEdit/Features/LSP/LSPService.swift +++ /dev/null @@ -1,212 +0,0 @@ -// -// LSPService.swift -// CodeEdit -// -// Created by Abe Malla on 2/7/24. -// - -import os.log -import JSONRPC -import Foundation -import LanguageClient -import LanguageServerProtocol - -/// `LSPService` is a service class responsible for managing the lifecycle and event handling -/// of Language Server Protocol (LSP) clients within the CodeEdit application. It handles the initialization, -/// communication, and termination of language servers, ensuring that code assistance features -/// such as code completion, diagnostics, and more are available for various programming languages. -/// -/// This class uses Swift's concurrency model to manage background tasks and event streams -/// efficiently. Each language server runs in its own asynchronous task, listening for events and -/// handling them as they occur. The `LSPService` class also provides functionality to start -/// and stop individual language servers, as well as to stop all running servers. -/// -/// ## Example Usage -/// ```swift -/// @Service var lspService -/// -/// try await lspService.startServer( -/// for: .python, -/// projectURL: projectURL, -/// workspaceFolders: workspaceFolders -/// ) -/// try await lspService.stopServer(for: .python) -/// ``` -final class LSPService: ObservableObject { - internal let logger: Logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "LSPService") - - /// Holds the active language clients - internal var languageClients: [LanguageIdentifier: LanguageServer] = [:] - /// Holds the language server configurations for all the installed language servers - internal var languageConfigs: [LanguageIdentifier: LanguageServerBinary] = [:] - /// Holds all the event listeners for each active language client - internal var eventListeningTasks: [LanguageIdentifier: Task] = [:] - - @AppSettings(\.developerSettings.lspBinaries) - internal var lspBinaries - - init() { - // Load the LSP binaries from the developer menu - for binary in lspBinaries { - if let language = LanguageIdentifier(rawValue: binary.key) { - self.languageConfigs[language] = LanguageServerBinary( - execPath: binary.value, - args: [], - env: ProcessInfo.processInfo.environment - ) - } - } - } - - /// Gets the language server for the specified language - func server(for languageId: LanguageIdentifier) -> InitializingServer? { - return languageClients[languageId]?.lspInstance - } - - /// Gets the language client for the specified language - func languageClient(for languageId: LanguageIdentifier) -> LanguageServer? { - return languageClients[languageId] - } - - /// Given a language, will attempt to start the language server - func startServer( - for languageId: LanguageIdentifier, - projectURL: URL, - workspaceFolders: [WorkspaceFolder]? - ) async throws { - guard let serverBinary = languageConfigs[languageId] else { - logger.error("Couldn't find language sever binary for \(languageId.rawValue)") - throw LSPError.binaryNotFound - } - - let server = try LanguageServer.createServer( - for: languageId, - with: serverBinary, - rootPath: projectURL, - workspaceFolders: workspaceFolders - ) - languageClients[languageId] = server - - logger.info("Initializing \(languageId.rawValue) language server") - try await server.initialize() - logger.info("Successfully initialized \(languageId.rawValue) language server") - - self.startListeningToEvents(for: languageId) - } - - /// Notify the proper language server that we opened a document. - func documentWasOpened(for languageId: LanguageIdentifier, file fileURL: URL) async throws -> Bool { - // TODO: GET FILE TYPE FROM DOCUMENT, USING NEW FILE SOLUTION - guard var languageClient = self.languageClient(for: .python) else { - logger.error("Failed to get \(languageId.rawValue) client") - throw ServerManagerError.languageClientNotFound - } - return await languageClient.addDocument(fileURL) - } - - /// Notify the proper language server that we closed a document so we can stop tracking the file. - func documentWasClosed(for languageId: LanguageIdentifier, file fileURL: URL) async throws -> Bool { - // TODO: GET FILE TYPE FROM DOCUMENT, USING NEW FILE SOLUTION - guard var languageClient = self.languageClient(for: .python) else { - logger.error("Failed to get \(languageId.rawValue) client") - throw ServerManagerError.languageClientNotFound - } - return await languageClient.closeDocument(fileURL.absoluteString) - } - - /// NOTE: This function is intended to be removed when the frontend is being developed. - /// For now this is just for reference of a working example. - func testCompletion() async throws { - do { - guard var languageClient = self.languageClient(for: .python) else { - print("Failed to get client") - throw ServerManagerError.languageClientNotFound - } - - let testFilePathStr = "" - let testFileURL = URL(fileURLWithPath: testFilePathStr) - - // Tell server we opened a document - _ = await languageClient.addDocument(testFileURL) - - // Completion example - let textPosition = Position(line: 32, character: 18) // Lines and characters start at 0 - let completions = try await languageClient.requestCompletion( - document: testFileURL.absoluteString, - position: textPosition - ) - switch completions { - case .optionA(let completionItems): - // Handle the case where completions is an array of CompletionItem - print("\n*******\nCompletion Items:\n*******\n") - for item in completionItems { - let textEdits = LSPCompletionItemsUtil.getCompletionItemEdits( - startPosition: textPosition, - item: item - ) - for edit in textEdits { - print(edit) - } - } - - case .optionB(let completionList): - // Handle the case where completions is a CompletionList - print("\n*******\nCompletion Items:\n*******\n") - for item in completionList.items { - let textEdits = LSPCompletionItemsUtil.getCompletionItemEdits( - startPosition: textPosition, - item: item - ) - for edit in textEdits { - print(edit) - } - } - - print(completionList.items[0]) - - case .none: - print("No completions found") - } - - // Close the document - _ = await languageClient.closeDocument(testFilePathStr) - } catch { - print(error) - } - } - - /// Attempts to stop a running language server. Throws an error if the server is not found - /// or if the language server throws an error while trying to shutdown. - func stopServer(for languageId: LanguageIdentifier) async throws { - guard let server = self.server(for: languageId) else { - logger.error("Server not found for language \(languageId.rawValue) during stop operation") - throw ServerManagerError.serverNotFound - } - do { - try await server.shutdownAndExit() - } catch { - logger.error("Failed to stop server for language \(languageId.rawValue): \(error.localizedDescription)") - throw error - } - languageClients.removeValue(forKey: languageId) - logger.info("Server stopped for language \(languageId.rawValue)") - - stopListeningToEvents(for: languageId) - } - - /// Goes through all active language servers and attempts to shut them down. - func stopAllServers() async throws { - for languageId in languageClients.keys { - try await stopServer(for: languageId) - } - } -} - -// MARK: - Errors - -enum ServerManagerError: Error { - case serverNotFound - case serverStartFailed - case serverStopFailed - case languageClientNotFound -} diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+CallHierarchy.swift b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+CallHierarchy.swift new file mode 100644 index 000000000..3cf99ba12 --- /dev/null +++ b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+CallHierarchy.swift @@ -0,0 +1,57 @@ +// +// LanguageServer+CallHierarchy.swift +// CodeEdit +// +// Created by Abe Malla on 2/7/24. +// + +import Foundation +import LanguageServerProtocol + +extension LanguageServer { + func requestPrepareCallHierarchy( + for documentURI: String, position: Position + ) async throws -> CallHierarchyPrepareResponse { + do { + let prepareParams = CallHierarchyPrepareParams( + textDocument: TextDocumentIdentifier(uri: documentURI), + position: position, + workDoneToken: nil + ) + return try await lspInstance.prepareCallHierarchy(prepareParams) + } catch { + logger.warning("requestPrepareCallHierarchy: Error \(error)") + throw error + } + } + + func requestCallHierarchyIncomingCalls( + _ callHierarchyItem: CallHierarchyItem + ) async throws -> CallHierarchyIncomingCallsResponse { + do { + let incomingParams = CallHierarchyIncomingCallsParams( + item: callHierarchyItem, + workDoneToken: nil + ) + return try await lspInstance.callHierarchyIncomingCalls(incomingParams) + } catch { + logger.warning("requestCallHierarchyIncomingCalls: Error \(error)") + throw error + } + } + + func requestCallHierarchyOutgoingCalls( + _ callHierarchyItem: CallHierarchyItem + ) async throws -> CallHierarchyOutgoingCallsResponse { + do { + let outgoingParams = CallHierarchyOutgoingCallsParams( + item: callHierarchyItem, + workDoneToken: nil + ) + return try await lspInstance.callHierarchyOutgoingCalls(outgoingParams) + } catch { + logger.warning("requestCallHierarchyOutgoingCalls: Error \(error)") + throw error + } + } +} diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+ColorPresentation.swift b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+ColorPresentation.swift new file mode 100644 index 000000000..1b054ea9a --- /dev/null +++ b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+ColorPresentation.swift @@ -0,0 +1,31 @@ +// +// LanguageServer+ColorPresentation.swift +// CodeEdit +// +// Created by Abe Malla on 2/7/24. +// + +import Foundation +import LanguageServerProtocol + +extension LanguageServer { + func requestColorPresentation( + for documentURI: String, + color: Color, + range: LSPRange + ) async throws -> ColorPresentationResponse { + do { + let params = ColorPresentationParams( + workDoneToken: nil, + partialResultToken: nil, + textDocument: TextDocumentIdentifier(uri: documentURI), + color: color, + range: range + ) + return try await lspInstance.colorPresentation(params) + } catch { + logger.warning("requestColorPresentation: Error \(error)") + throw error + } + } +} diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Completion.swift b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Completion.swift new file mode 100644 index 000000000..ab9cfdaea --- /dev/null +++ b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Completion.swift @@ -0,0 +1,37 @@ +// +// LanguageServer+Completion.swift +// CodeEdit +// +// Created by Abe Malla on 2/7/24. +// + +import Foundation +import LanguageServerProtocol + +extension LanguageServer { + func requestCompletion(for documentURI: String, position: Position) async throws -> CompletionResponse { + do { + let cacheKey = CacheKey( + uri: documentURI, + requestType: "completion", + extraData: position + ) + if let cachedResponse: CompletionResponse = lspCache.get(key: cacheKey, as: CompletionResponse.self) { + return cachedResponse + } + let completionParams = CompletionParams( + uri: documentURI, + position: position, + triggerKind: .invoked, + triggerCharacter: nil + ) + let response = try await lspInstance.completion(completionParams) + + lspCache.set(key: cacheKey, value: response) + return response + } catch { + logger.warning("requestCompletion: Error \(error)") + throw error + } + } +} diff --git a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+Declaration.swift b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Declaration.swift similarity index 68% rename from CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+Declaration.swift rename to CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Declaration.swift index 28aec4a60..0be433492 100644 --- a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+Declaration.swift +++ b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Declaration.swift @@ -9,7 +9,7 @@ import Foundation import LanguageServerProtocol extension LanguageServer { - func requestGoToDeclaration(document documentURI: String, _ position: Position) async -> DeclarationResponse { + func requestGoToDeclaration(for documentURI: String, position: Position) async throws -> DeclarationResponse { do { let params = TextDocumentPositionParams( textDocument: TextDocumentIdentifier(uri: documentURI), @@ -17,9 +17,8 @@ extension LanguageServer { ) return try await lspInstance.declaration(params) } catch { - print("requestGoToDeclaration Error \(error)") + logger.warning("requestGoToDeclaration: Error \(error)") + throw error } - - return nil } } diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Definition.swift b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Definition.swift new file mode 100644 index 000000000..8882f73a5 --- /dev/null +++ b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Definition.swift @@ -0,0 +1,37 @@ +// +// LanguageServer+Definition.swift +// CodeEdit +// +// Created by Abe Malla on 2/7/24. +// + +import Foundation +import LanguageServerProtocol + +extension LanguageServer { + func requestGoToDefinition(for documentURI: String, position: Position) async throws -> DefinitionResponse { + do { + let cacheKey = CacheKey( + uri: documentURI, + requestType: "goToDefinition", + extraData: NoExtraData() + ) + if let cachedResponse: DefinitionResponse = lspCache.get(key: cacheKey, as: DefinitionResponse.self) { + return cachedResponse + } + + let textDocumentIdentifier = TextDocumentIdentifier(uri: documentURI) + let textDocumentPositionParams = TextDocumentPositionParams( + textDocument: textDocumentIdentifier, + position: position + ) + let response = try await lspInstance.definition(textDocumentPositionParams) + + lspCache.set(key: cacheKey, value: response) + return response + } catch { + logger.warning("requestGoToDefinition: Error \(error)") + throw error + } + } +} diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Diagnostics.swift b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Diagnostics.swift new file mode 100644 index 000000000..b2ea13536 --- /dev/null +++ b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Diagnostics.swift @@ -0,0 +1,37 @@ +// +// LanguageServer+Diagnostics.swift +// CodeEdit +// +// Created by Abe Malla on 2/7/24. +// + +import Foundation +import LanguageServerProtocol + +extension LanguageServer { + func requestPullDiagnostics(document documentURI: String) async throws -> DocumentDiagnosticReport { + do { + let cacheKey = CacheKey( + uri: documentURI, + requestType: "diagnostics", + extraData: NoExtraData() + ) + if let cachedResponse: DocumentDiagnosticReport = lspCache.get( + key: cacheKey, as: DocumentDiagnosticReport.self + ) { + return cachedResponse + } + + let response = try await lspInstance.diagnostics( + DocumentDiagnosticParams( + textDocument: TextDocumentIdentifier(uri: documentURI) + ) + ) + lspCache.set(key: cacheKey, value: response) + return response + } catch { + logger.warning("requestPullDiagnostics: Error \(error)") + throw error + } + } +} diff --git a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+DocumentColor.swift b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentColor.swift similarity index 65% rename from CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+DocumentColor.swift rename to CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentColor.swift index 711bc6ab7..78817813e 100644 --- a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+DocumentColor.swift +++ b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentColor.swift @@ -14,17 +14,18 @@ extension LanguageServer { /// Clients can use the result to decorate color references in an editor. For example: /// 1. Color boxes showing the actual color next to the reference /// 2. Show a color picker when a color reference is edited - func requestDocumentColor(document documentURI: String) async -> DocumentColorResponse { + func requestColor(for documentURI: String) async throws -> DocumentColorResponse { + let params = DocumentColorParams( + textDocument: TextDocumentIdentifier(uri: documentURI), + workDoneToken: nil, + partialResultToken: nil + ) do { - let params = DocumentColorParams( - textDocument: TextDocumentIdentifier(uri: documentURI), - workDoneToken: nil, - partialResultToken: nil - ) return try await lspInstance.documentColor(params) } catch { - print("requestDocumentColor Error \(error)") + logger.warning("requestDocumentColor: Error \(error)") + throw error } - return [] } } + diff --git a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+DocumentHighlight.swift b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentHighlight.swift similarity index 77% rename from CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+DocumentHighlight.swift rename to CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentHighlight.swift index dec93142c..4bd66e649 100644 --- a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+DocumentHighlight.swift +++ b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentHighlight.swift @@ -11,10 +11,10 @@ extension LanguageServer { /// The document highlight request is sent from the client to the server to resolve document /// highlights for a given text document position. For programming languages this usually /// highlights all references to the symbol scoped to this file. - func requestDocumentHighlight( - document documentURI: String, - _ position: Position - ) async -> DocumentHighlightResponse { + func requestHighlight( + for documentURI: String, + position: Position + ) async throws -> DocumentHighlightResponse { do { let params = DocumentHighlightParams( textDocument: TextDocumentIdentifier(uri: documentURI), @@ -24,9 +24,8 @@ extension LanguageServer { ) return try await lspInstance.documentHighlight(params) } catch { - print("requestDocumentHighlight Error: \(error)") + logger.warning("requestDocumentHighlight Error: \(error)") + throw error } - - return nil } } diff --git a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+DocumentLink.swift b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentLink.swift similarity index 60% rename from CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+DocumentLink.swift rename to CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentLink.swift index 075729f91..fafd7f2f7 100644 --- a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+DocumentLink.swift +++ b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentLink.swift @@ -11,12 +11,13 @@ import LanguageServerProtocol // TODO: DocumentLinkParams IS MISSING `textDocument: TextDocumentIdentifier;` FIELD IN LSP LIBRARY extension LanguageServer { - func requestDocumentLinkResolve(_ documentLink: DocumentLink) async -> DocumentLink? { + @available(*, deprecated, message: "Not functional, see comment.") + func requestLinkResolve(_ documentLink: DocumentLink) async throws -> DocumentLink? { do { return try await lspInstance.documentLinkResolve(documentLink) } catch { - print("requestDocumentLinkResolve Error: \(error)") + logger.warning("requestDocumentLinkResolve: Error \(error)") + throw error } - return nil } } diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSymbol.swift b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSymbol.swift new file mode 100644 index 000000000..4a50aabbb --- /dev/null +++ b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSymbol.swift @@ -0,0 +1,22 @@ +// +// LanguageServer+DocumentSymbol.swift +// CodeEdit +// +// Created by Abe Malla on 2/7/24. +// + +import Foundation +import LanguageServerProtocol + +extension LanguageServer { + func requestSymbols(for documentURI: String) async throws -> DocumentSymbolResponse { + do { + let textDocumentIdentifier = TextDocumentIdentifier(uri: documentURI) + let documentSymbolParams = DocumentSymbolParams(textDocument: textDocumentIdentifier) + return try await lspInstance.documentSymbol(documentSymbolParams) + } catch { + logger.warning("requestSymbols: Error \(error)") + throw error + } + } +} diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift new file mode 100644 index 000000000..bd425f554 --- /dev/null +++ b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift @@ -0,0 +1,122 @@ +// +// LanguageServer+DocumentSync.swift +// CodeEdit +// +// Created by Abe Malla on 2/7/24. +// + +import Foundation +import LanguageServerProtocol + +extension LanguageServer { + fileprivate func serverDocumentSyncSupport() -> TextDocumentSyncKind { + var syncKind: TextDocumentSyncKind = .none + switch serverCapabilities.textDocumentSync { + case .optionA(let options): + syncKind = options.change ?? .none + case .optionB(let kind): + syncKind = kind + default: + syncKind = .none + } + return syncKind + } + + fileprivate func serverSupportsOpenClose() -> Bool { + switch serverCapabilities.textDocumentSync { + case .optionA(let options): + return options.openClose ?? false + case .optionB: + return true + default: + return true + } + } + + /// Tells the language server we've opened a document and would like to begin working with it. + /// - Parameter document: The code document to open. + func openDocument(_ document: CodeFileDocument) async throws { + do { + guard serverSupportsOpenClose(), + let uri = await document.languageServerURI, + let language = await document.getLanguage().lspLanguage else { + return + } + let content = await MainActor.run { + let storage = document.content + return storage?.string + } + guard let content else { return } + logger.debug("Opening Document \(uri, privacy: .private)") + + self.openFiles.addDocument(document) + + let textDocument = TextDocumentItem( + uri: uri, + languageId: language, + version: 0, + text: content + ) + try await lspInstance.textDocumentDidOpen(DidOpenTextDocumentParams(textDocument: textDocument)) + } catch { + logger.warning("addDocument: Error \(error)") + throw error + } + } + + /// Stops tracking a file and notifies the language server + /// - Parameter uri: The URI of the document to close. + func closeDocument(_ uri: String) async throws { + do { + guard serverSupportsOpenClose() else { return } + logger.debug("Closing document \(uri, privacy: .private)") + openFiles.removeDocument(for: uri) + let params = DidCloseTextDocumentParams(textDocument: TextDocumentIdentifier(uri: uri)) + try await lspInstance.textDocumentDidClose(params) + } catch { + logger.warning("closeDocument: Error \(error)") + throw error + } + } + + /// Updates the document with the specified URI with new text and increments its version. + /// - Parameters: + /// - uri: The URI of the document to update. + /// - range: The range being replaced. + /// - string: The string being inserted into the replacement range. + /// - Returns: `true` if the document was successfully updated, `false` + func documentChanged( + uri: String, + replacedContentIn range: LSPRange, + with string: String + ) async throws { + do { + logger.debug("Document updated, \(uri, privacy: .private)") + switch serverDocumentSyncSupport() { + case .full: + guard let file = openFiles.document(for: uri) else { return } + let content = await MainActor.run { + let storage = file.content + return storage?.string + } + guard let content else { return } + let changeEvent = TextDocumentContentChangeEvent(range: nil, rangeLength: nil, text: content) + try await lspInstance.textDocumentDidChange( + DidChangeTextDocumentParams(uri: uri, version: 0, contentChange: changeEvent) + ) + case .incremental: + let fileVersion = openFiles.incrementVersion(for: uri) + // rangeLength is depreciated in the LSP spec. + let changeEvent = TextDocumentContentChangeEvent(range: range, rangeLength: nil, text: string) + try await lspInstance.textDocumentDidChange( + DidChangeTextDocumentParams(uri: uri, version: fileVersion, contentChange: changeEvent) + ) + case .none: + return + } + } catch { + logger.warning("closeDocument: Error \(error)") + throw error + } + } +} diff --git a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+FoldingRange.swift b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+FoldingRange.swift similarity index 66% rename from CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+FoldingRange.swift rename to CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+FoldingRange.swift index 0e0c2b5b6..c4a35c012 100644 --- a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+FoldingRange.swift +++ b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+FoldingRange.swift @@ -9,14 +9,13 @@ import Foundation import LanguageServerProtocol extension LanguageServer { - func requestFoldingRange(document documentURI: String) async -> FoldingRangeResponse { + func requestFoldingRange(for documentURI: String) async throws -> FoldingRangeResponse { do { let params = FoldingRangeParams(textDocument: TextDocumentIdentifier(uri: documentURI)) return try await lspInstance.foldingRange(params) } catch { - // TODO: LOGGING - print("requestFoldingRange Error: \(error)") + logger.warning("requestFoldingRange: Error \(error)") + throw error } - return nil } } diff --git a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+Formatting.swift b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Formatting.swift similarity index 75% rename from CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+Formatting.swift rename to CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Formatting.swift index 4de818e6c..fd0d810a8 100644 --- a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+Formatting.swift +++ b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Formatting.swift @@ -8,13 +8,11 @@ import Foundation import LanguageServerProtocol -// TODO: LOGGING - extension LanguageServer { func requestFormatting( - document documentURI: String, + for documentURI: String, withFormat formattingOptions: FormattingOptions - ) async -> FormattingResult { + ) async throws -> FormattingResult { do { let params = DocumentFormattingParams( textDocument: TextDocumentIdentifier(uri: documentURI), @@ -22,16 +20,16 @@ extension LanguageServer { ) return try await lspInstance.formatting(params) } catch { - print("requestFormatting Error \(error)") + logger.warning("requestFormatting: Error \(error)") + throw error } - return [] } func requestRangeFormatting( - document documentURI: String, + for documentURI: String, _ range: LSPRange, withFormat formattingOptions: FormattingOptions - ) async -> FormattingResult { + ) async throws -> FormattingResult { do { let params = DocumentRangeFormattingParams( textDocument: TextDocumentIdentifier(uri: documentURI), @@ -40,17 +38,17 @@ extension LanguageServer { ) return try await lspInstance.rangeFormatting(params) } catch { - print("requestRangeFormatting Error \(error)") + logger.warning("requestRangeFormatting: Error \(error)") + throw error } - return [] } func requestOnTypeFormatting( - document documentURI: String, + for documentURI: String, _ position: Position, character char: String, withFormat formattingOptions: FormattingOptions - ) async -> FormattingResult { + ) async throws -> FormattingResult { do { let params = DocumentOnTypeFormattingParams( textDocument: TextDocumentIdentifier(uri: documentURI), @@ -60,8 +58,8 @@ extension LanguageServer { ) return try await lspInstance.onTypeFormatting(params) } catch { - print("requestOnTypeFormatting Error \(error)") + logger.warning("requestOnTypeFormatting: Error \(error)") + throw error } - return [] } } diff --git a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+Hover.swift b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Hover.swift similarity index 75% rename from CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+Hover.swift rename to CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Hover.swift index 01c213d56..5a11ca0a9 100644 --- a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+Hover.swift +++ b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Hover.swift @@ -11,7 +11,7 @@ import LanguageServerProtocol extension LanguageServer { /// The hover request is sent from the client to the server to request hover /// information at a given text document position. - func requestHover(document documentURI: String, _ position: Position) async -> HoverResponse { + func requestHover(for documentURI: String, _ position: Position) async throws -> HoverResponse { do { let params = TextDocumentPositionParams( textDocument: TextDocumentIdentifier(uri: documentURI), @@ -19,8 +19,8 @@ extension LanguageServer { ) return try await lspInstance.hover(params) } catch { - print("requestHover Error \(error)") + logger.warning("requestHover: Error \(error)") + throw error } - return nil } } diff --git a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+Implementation.swift b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Implementation.swift similarity index 72% rename from CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+Implementation.swift rename to CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Implementation.swift index b22f97848..87c7a424a 100644 --- a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+Implementation.swift +++ b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Implementation.swift @@ -10,7 +10,7 @@ import LanguageServerProtocol extension LanguageServer { /// Resolve the implementation location of a symbol at a given text document position - func requestImplementation(document documentURI: String, _ position: Position) async -> ImplementationResponse { + func requestImplementation(for documentURI: String, _ position: Position) async throws -> ImplementationResponse { do { let params = TextDocumentPositionParams( textDocument: TextDocumentIdentifier(uri: documentURI), @@ -18,8 +18,8 @@ extension LanguageServer { ) return try await lspInstance.implementation(params) } catch { - print("requestImplementation Error \(error)") + logger.warning("requestImplementation: Error \(error)") + throw error } - return nil } } diff --git a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+InlayHint.swift b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+InlayHint.swift similarity index 73% rename from CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+InlayHint.swift rename to CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+InlayHint.swift index 2bc9cbca2..7725c57a2 100644 --- a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+InlayHint.swift +++ b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+InlayHint.swift @@ -11,7 +11,7 @@ import LanguageServerProtocol extension LanguageServer { /// Compute inlay hints for a given [text document, range] tuple that may be rendered in the /// editor in place with other text - func requestInlayHint(document documentURI: String, _ range: LSPRange) async -> InlayHintResponse { + func requestInlayHint(for documentURI: String, _ range: LSPRange) async throws -> InlayHintResponse { do { let params = InlayHintParams( workDoneToken: nil, @@ -20,20 +20,20 @@ extension LanguageServer { ) return try await lspInstance.inlayHint(params) } catch { - print("requestInlayHint Error \(error)") + logger.warning("requestInlayHint: Error \(error)") + throw error } - return nil } /// The request is sent from the client to the server to resolve additional information for a given inlay hint. /// This is usually used to compute the tooltip, location or command properties of an inlay hint’s label part /// to avoid its unnecessary computation during the textDocument/inlayHint request. - func requestInlayHintResolve(_ inlayHint: InlayHint) async -> InlayHint? { + func requestInlayHintResolve(_ inlayHint: InlayHint) async throws -> InlayHint? { do { return try await lspInstance.inlayHintResolve(inlayHint) } catch { - print("requestInlayHint Error \(error)") + logger.warning("requestInlayHintResolve: Error \(error)") + throw error } - return nil } } diff --git a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+References.swift b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+References.swift similarity index 80% rename from CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+References.swift rename to CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+References.swift index 56c8737cf..48ca34d03 100644 --- a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+References.swift +++ b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+References.swift @@ -11,10 +11,10 @@ import LanguageServerProtocol extension LanguageServer { /// Resolve project-wide references for the symbol denoted by the given text document position func requestFindReferences( - document documentURI: String, + for documentURI: String, _ position: Position, _ includeDeclaration: Bool = false - ) async -> ReferenceResponse { + ) async throws -> ReferenceResponse { do { let params = ReferenceParams( textDocument: TextDocumentIdentifier(uri: documentURI), @@ -23,8 +23,8 @@ extension LanguageServer { ) return try await lspInstance.references(params) } catch { - print("requestInlayHint Error \(error)") + logger.warning("requestFindReferences: Error \(error)") + throw error } - return nil } } diff --git a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+Rename.swift b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Rename.swift similarity index 71% rename from CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+Rename.swift rename to CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Rename.swift index 7fa5e3f2c..bed869c0e 100644 --- a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+Rename.swift +++ b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+Rename.swift @@ -9,7 +9,7 @@ import LanguageServerProtocol extension LanguageServer { /// Setup and test the validity of a rename operation at a given location - func requestPrepareRename(document documentURI: String, _ position: Position) async -> PrepareRenameResponse { + func requestPrepareRename(for documentURI: String, _ position: Position) async throws -> PrepareRenameResponse { do { let params = TextDocumentPositionParams( textDocument: TextDocumentIdentifier(uri: documentURI), @@ -17,17 +17,17 @@ extension LanguageServer { ) return try await lspInstance.prepareRename(params) } catch { - print("requestInlayHint Error \(error)") + logger.warning("requestPrepareRename: Error \(error)") + throw error } - return nil } /// Ask the server to compute a workspace change so that the client can perform a workspace-wide rename of a symbol func requestRename( - document documentURI: String, - _ position: Position, + for documentURI: String, + position: Position, newName name: String - ) async -> RenameResponse { + ) async throws -> RenameResponse { do { let params = RenameParams( textDocument: TextDocumentIdentifier(uri: documentURI), @@ -36,8 +36,8 @@ extension LanguageServer { ) return try await lspInstance.rename(params) } catch { - print("requestInlayHint Error \(error)") + logger.warning("requestRename: Error \(error)") + throw error } - return nil } } diff --git a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+SelectionRange.swift b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+SelectionRange.swift similarity index 72% rename from CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+SelectionRange.swift rename to CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+SelectionRange.swift index 328c8f562..06585932b 100644 --- a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+SelectionRange.swift +++ b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+SelectionRange.swift @@ -10,7 +10,7 @@ import LanguageServerProtocol extension LanguageServer { /// Setup and test the validity of a rename operation at a given location - func requestSelectionRange(document documentURI: String, _ positions: [Position]) async -> SelectionRangeResponse { + func requestSelectionRange(for documentURI: String, positions: [Position]) async throws -> SelectionRangeResponse { do { let params = SelectionRangeParams( workDoneToken: nil, @@ -19,8 +19,8 @@ extension LanguageServer { ) return try await lspInstance.selectionRange(params) } catch { - print("requestInlayHint Error \(error)") + logger.warning("requestSelectionRange: Error \(error)") + throw error } - return nil } } diff --git a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+SemanticTokens.swift b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+SemanticTokens.swift similarity index 60% rename from CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+SemanticTokens.swift rename to CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+SemanticTokens.swift index 76e5b1396..02cb29947 100644 --- a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+SemanticTokens.swift +++ b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+SemanticTokens.swift @@ -10,32 +10,35 @@ import LanguageServerProtocol extension LanguageServer { /// Setup and test the validity of a rename operation at a given location - func requestSemanticTokensFull(document documentURI: String) async -> SemanticTokensResponse { + func requestSemanticTokens(for documentURI: String) async throws -> SemanticTokensResponse { do { let params = SemanticTokensParams( textDocument: TextDocumentIdentifier(uri: documentURI) ) return try await lspInstance.semanticTokensFull(params) } catch { - print("requestInlayHint Error \(error)") + logger.warning("requestSemanticTokens full: Error \(error)") + throw error } - return nil } - func requestSemanticTokensRange(document documentURI: String, _ range: LSPRange) async -> SemanticTokensResponse { + func requestSemanticTokens( + for documentURI: String, + forRange range: LSPRange + ) async throws -> SemanticTokensResponse { do { let params = SemanticTokensRangeParams(textDocument: TextDocumentIdentifier(uri: documentURI), range: range) return try await lspInstance.semanticTokensRange(params) } catch { - print("requestInlayHint Error \(error)") + logger.warning("requestSemanticTokens range: Error \(error)") + throw error } - return nil } - func requestSemanticTokensFullDelta( - document documentURI: String, - _ previousResultId: String - ) async -> SemanticTokensDeltaResponse { + func requestSemanticTokens( + for documentURI: String, + previousResultId: String + ) async throws -> SemanticTokensDeltaResponse { do { let params = SemanticTokensDeltaParams( textDocument: TextDocumentIdentifier(uri: documentURI), @@ -43,8 +46,8 @@ extension LanguageServer { ) return try await lspInstance.semanticTokensFullDelta(params) } catch { - print("requestInlayHint Error \(error)") + logger.warning("requestSemanticTokens versioned: Error \(error)") + throw error } - return nil } } diff --git a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+SignatureHelp.swift b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+SignatureHelp.swift similarity index 72% rename from CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+SignatureHelp.swift rename to CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+SignatureHelp.swift index 1fd3c991b..b9031d328 100644 --- a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+SignatureHelp.swift +++ b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+SignatureHelp.swift @@ -10,7 +10,7 @@ import LanguageServerProtocol extension LanguageServer { /// Request signature information at a given cursor position - func requestSignatureHelp(document documentURI: String, _ position: Position) async -> SignatureHelpResponse { + func requestSignatureHelp(for documentURI: String, _ position: Position) async throws -> SignatureHelpResponse { do { let params = TextDocumentPositionParams( textDocument: TextDocumentIdentifier(uri: documentURI), @@ -18,8 +18,8 @@ extension LanguageServer { ) return try await lspInstance.signatureHelp(params) } catch { - print("requestInlayHint Error \(error)") + logger.warning("requestInlayHint: Error \(error)") + throw error } - return nil } } diff --git a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+TypeDefinition.swift b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+TypeDefinition.swift similarity index 72% rename from CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+TypeDefinition.swift rename to CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+TypeDefinition.swift index 3b68a6a5b..868ea023c 100644 --- a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+TypeDefinition.swift +++ b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+TypeDefinition.swift @@ -10,7 +10,7 @@ import LanguageServerProtocol extension LanguageServer { /// Resolve the type definition location of a symbol at a given text document position - func requestTypeDefinition(document documentURI: String, _ position: Position) async -> TypeDefinitionResponse { + func requestTypeDefinition(for documentURI: String, _ position: Position) async throws -> TypeDefinitionResponse { do { let params = TextDocumentPositionParams( textDocument: TextDocumentIdentifier(uri: documentURI), @@ -18,8 +18,8 @@ extension LanguageServer { ) return try await lspInstance.typeDefinition(params) } catch { - print("requestInlayHint Error \(error)") + logger.warning("requestTypeDefinition: Error \(error)") + throw error } - return nil } } diff --git a/CodeEdit/Features/LSP/LSPCache+Data.swift b/CodeEdit/Features/LSP/LanguageServer/LSPCache+Data.swift similarity index 100% rename from CodeEdit/Features/LSP/LSPCache+Data.swift rename to CodeEdit/Features/LSP/LanguageServer/LSPCache+Data.swift diff --git a/CodeEdit/Features/LSP/LSPCache.swift b/CodeEdit/Features/LSP/LanguageServer/LSPCache.swift similarity index 98% rename from CodeEdit/Features/LSP/LSPCache.swift rename to CodeEdit/Features/LSP/LanguageServer/LSPCache.swift index 7045fc7b0..424bbf6d9 100644 --- a/CodeEdit/Features/LSP/LSPCache.swift +++ b/CodeEdit/Features/LSP/LanguageServer/LSPCache.swift @@ -7,7 +7,7 @@ import Foundation -final class LSPCache { +class LSPCache { private var cache = NSCache() func get(key: CacheKey, as type: T.Type) -> T? { diff --git a/CodeEdit/Features/LSP/LanguageServer.swift b/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift similarity index 62% rename from CodeEdit/Features/LSP/LanguageServer.swift rename to CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift index d5664c39d..d10134506 100644 --- a/CodeEdit/Features/LSP/LanguageServer.swift +++ b/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift @@ -9,8 +9,12 @@ import JSONRPC import Foundation import LanguageClient import LanguageServerProtocol +import OSLog + +class LanguageServer { + static let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "LanguageServer") + let logger: Logger -struct LanguageServer { /// Identifies which language the server belongs to let languageId: LanguageIdentifier /// Holds information about the language server binary @@ -18,55 +22,91 @@ struct LanguageServer { /// A cache to hold responses from the server, to minimize duplicate server requests let lspCache = LSPCache() - // TODO: REMOVE WHEN NEW DOCUMENT TRACKER IS IMPLEMENTED. IS PART OF NEW FILE SOLUTION. - var trackedDocuments: [String: TextDocumentItem] = [:] + let openFiles: LanguageServerFileMap + + /// The configuration options this server supports. + var serverCapabilities: ServerCapabilities /// An instance of a language server, that may or may not be initialized private(set) var lspInstance: InitializingServer /// The path to the root of the project private(set) var rootPath: URL + init( + languageId: LanguageIdentifier, + binary: LanguageServerBinary, + lspInstance: InitializingServer, + serverCapabilities: ServerCapabilities, + rootPath: URL + ) { + self.languageId = languageId + self.binary = binary + self.lspInstance = lspInstance + self.serverCapabilities = serverCapabilities + self.rootPath = rootPath + self.openFiles = LanguageServerFileMap() + self.logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "", + category: "LanguageServer.\(languageId.rawValue)" + ) + } + + /// Creates and initializes a language server. + /// - Parameters: + /// - languageId: The id of the language to create. + /// - binary: The binary where the language server is stored. + /// - workspacePath: The path of the workspace being opened. + /// - Returns: An initialized language server. static func createServer( for languageId: LanguageIdentifier, with binary: LanguageServerBinary, - rootPath: URL, - workspaceFolders: [WorkspaceFolder]? - ) throws -> Self { + workspacePath: String + ) async throws -> LanguageServer { let executionParams = Process.ExecutionParameters( path: binary.execPath, arguments: binary.args, environment: binary.env ) - var channel: DataChannel? + let server = InitializingServer( + server: try makeLocalServerConnection(languageId: languageId, executionParams: executionParams), + initializeParamsProvider: getInitParams(workspacePath: workspacePath) + ) + let capabilities = try await server.initializeIfNeeded() + return LanguageServer( + languageId: languageId, + binary: binary, + lspInstance: server, + serverCapabilities: capabilities, + rootPath: URL(filePath: workspacePath) + ) + } + + /// Creates a data channel for sending and receiving data with an LSP. + /// - Parameters: + /// - languageId: The ID of the language to create the channel for. + /// - executionParams: The parameters for executing the local process. + /// - Returns: A new connection to the language server. + private static func makeLocalServerConnection( + languageId: LanguageIdentifier, + executionParams: Process.ExecutionParameters + ) throws -> JSONRPCServerConnection { do { - channel = try DataChannel.localProcessChannel( + let channel = try DataChannel.localProcessChannel( parameters: executionParams, terminationHandler: { - print("Terminated \(languageId)") + logger.debug("Terminated data channel for \(languageId.rawValue)") } ) + return JSONRPCServerConnection(dataChannel: channel) } catch { + logger.warning("Failed to initialize data channel for \(languageId.rawValue)") throw error } - guard let channel = channel else { - throw NSError( - domain: "LanguageClient", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Failed to start server for language \(languageId.rawValue)"] - ) - } - - let localServer = LanguageServerProtocol.JSONRPCServerConnection(dataChannel: channel) - let server = InitializingServer( - server: localServer, - initializeParamsProvider: getInitParams(projectURL: rootPath) - ) - return LanguageServer(languageId: languageId, binary: binary, lspInstance: server, rootPath: rootPath) } // swiftlint:disable function_body_length - private static func getInitParams(projectURL: URL) -> InitializingServer.InitializeParamsProvider { + private static func getInitParams(workspacePath: String) -> InitializingServer.InitializeParamsProvider { let provider: InitializingServer.InitializeParamsProvider = { // Text Document Capabilities let textDocumentCapabilities = TextDocumentClientCapabilities( @@ -92,6 +132,19 @@ struct LanguageServer { completionList: CompletionClientCapabilities.CompletionList( itemDefaults: ["default1", "default2"] ) + ), + // swiftlint:disable:next line_length + // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#semanticTokensClientCapabilities + semanticTokens: SemanticTokensClientCapabilities( + dynamicRegistration: true, + requests: .init(range: true, delta: false), + tokenTypes: [], + tokenModifiers: [], + formats: [.relative], + overlappingTokenSupport: true, + multilineTokenSupport: true, + serverCancelSupport: true, + augmentsSyntaxTokens: false ) ) @@ -150,7 +203,7 @@ struct LanguageServer { processId: nil, locale: nil, rootPath: nil, - rootUri: projectURL.absoluteString, + rootUri: workspacePath, initializationOptions: [], capabilities: capabilities, trace: nil, @@ -161,19 +214,9 @@ struct LanguageServer { // swiftlint:enable function_body_length } - /// Initializes the language server if it hasn't been initialized already. - public func initialize() async throws { - do { - _ = try await lspInstance.initializeIfNeeded() - print("Language server for \(languageId.rawValue) initialized successfully") - } catch { - print("Failed to initialize \(languageId.rawValue) LSP instance: \(error.localizedDescription)") - throw error - } - } - /// Shuts down the language server and exits it. public func shutdown() async throws { + self.logger.info("Shutting down language server") try await lspInstance.shutdownAndExit() } } diff --git a/CodeEdit/Features/LSP/LanguageServer/LanguageServerFileMap.swift b/CodeEdit/Features/LSP/LanguageServer/LanguageServerFileMap.swift new file mode 100644 index 000000000..0f3d4469f --- /dev/null +++ b/CodeEdit/Features/LSP/LanguageServer/LanguageServerFileMap.swift @@ -0,0 +1,62 @@ +// +// LanguageServerFileMap.swift +// CodeEdit +// +// Created by Khan Winter on 9/8/24. +// + +import Foundation +import LanguageServerProtocol + +class LanguageServerFileMap { + private var trackedDocuments: NSMapTable + private var trackedDocumentVersions: [String: Int] = [:] + + init() { + trackedDocuments = NSMapTable(valueOptions: [.weakMemory]) + } + + // MARK: - Track & Remove Documents + + func addDocument(_ document: CodeFileDocument) { + guard let uri = document.languageServerURI else { return } + trackedDocuments.setObject(document, forKey: uri as NSString) + trackedDocumentVersions[uri] = 0 + } + + func document(for uri: DocumentUri) -> CodeFileDocument? { + let url = URL(filePath: uri) + return trackedDocuments.object(forKey: url.languageServerURI as NSString) + } + + func removeDocument(for document: CodeFileDocument) { + guard let uri = document.languageServerURI else { return } + removeDocument(for: uri) + } + + func removeDocument(for uri: DocumentUri) { + trackedDocuments.removeObject(forKey: uri as NSString) + trackedDocumentVersions.removeValue(forKey: uri) + } + + // MARK: - Version Number Tracking + + func incrementVersion(for document: CodeFileDocument) -> Int { + guard let uri = document.languageServerURI else { return 0 } + return incrementVersion(for: uri) + } + + func incrementVersion(for uri: DocumentUri) -> Int { + trackedDocumentVersions[uri] = (trackedDocumentVersions[uri] ?? 0) + 1 + return trackedDocumentVersions[uri] ?? 0 + } + + func documentVersion(for document: CodeFileDocument) -> Int? { + guard let uri = document.languageServerURI else { return nil } + return documentVersion(for: uri) + } + + func documentVersion(for uri: DocumentUri) -> Int? { + return trackedDocumentVersions[uri] + } +} diff --git a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+CallHierarchy.swift b/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+CallHierarchy.swift deleted file mode 100644 index d01227a79..000000000 --- a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+CallHierarchy.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// LanguageServer+CallHierarchy.swift -// CodeEdit -// -// Created by Abe Malla on 2/7/24. -// - -import Foundation -import LanguageServerProtocol - -extension LanguageServer { - func requestPrepareCallHierarchy( - document documentURI: String, _ position: Position - ) async -> CallHierarchyPrepareResponse { - let prepareParams = CallHierarchyPrepareParams( - textDocument: TextDocumentIdentifier(uri: documentURI), - position: position, - workDoneToken: nil - ) - - do { - guard let items = try await lspInstance.prepareCallHierarchy(prepareParams) else { - return [] - } - return items - } catch { - print("requestPrepareCallHierarchy: Error \(error)") - } - - return [] - } - - func requestCallHierarchyIncomingCalls( - _ callHierarchyItem: CallHierarchyItem - ) async -> CallHierarchyIncomingCallsResponse { - let incomingParams = CallHierarchyIncomingCallsParams( - item: callHierarchyItem, - workDoneToken: nil - ) - - do { - guard let incomingCalls = try await lspInstance.callHierarchyIncomingCalls(incomingParams) else { - return [] - } - return incomingCalls - } catch { - print("requestCallHierarchyIncomingCalls: Error \(error)") - } - return [] - } - - func requestCallHierarchyOutgoingCalls( - _ callHierarchyItem: CallHierarchyItem - ) async -> CallHierarchyOutgoingCallsResponse { - let outgoingParams = CallHierarchyOutgoingCallsParams( - item: callHierarchyItem, - workDoneToken: nil - ) - - do { - guard let outgoingCalls = try await lspInstance.callHierarchyOutgoingCalls(outgoingParams) else { - return [] - } - return outgoingCalls - } catch { - print("requestCallHierarchyOutgoingCalls: Error \(error)") - } - return [] - } -} diff --git a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+ColorPresentation.swift b/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+ColorPresentation.swift deleted file mode 100644 index 070de5bfd..000000000 --- a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+ColorPresentation.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// LanguageServer+ColorPresentation.swift -// CodeEdit -// -// Created by Abe Malla on 2/7/24. -// - -import Foundation -import LanguageServerProtocol - -extension LanguageServer { - func requestColorPresentation( - document documentURI: String, - _ color: Color, - _ range: LSPRange - ) async -> ColorPresentationResponse { - let params = ColorPresentationParams( - workDoneToken: nil, - partialResultToken: nil, - textDocument: TextDocumentIdentifier(uri: documentURI), - color: color, - range: range - ) - do { - return try await lspInstance.colorPresentation(params) - } catch { - // TODO: LOGGING - print("requestColorPresentation: Error \(error)") - } - return [] - } -} diff --git a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+Completion.swift b/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+Completion.swift deleted file mode 100644 index 2b1a43a70..000000000 --- a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+Completion.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// LanguageServer+Completion.swift -// CodeEdit -// -// Created by Abe Malla on 2/7/24. -// - -import Foundation -import LanguageServerProtocol - -extension LanguageServer { - func requestCompletion(document documentURI: String, position: Position) async throws -> CompletionResponse { - let cacheKey = CacheKey( - uri: documentURI, - requestType: "completion", - extraData: position - ) - if let cachedResponse: CompletionResponse = lspCache.get(key: cacheKey, as: CompletionResponse.self) { - return cachedResponse - } - let completionParams = CompletionParams( - uri: documentURI, - position: position, - triggerKind: .invoked, - triggerCharacter: nil - ) - let response = try await lspInstance.completion(completionParams) - - lspCache.set(key: cacheKey, value: response) - return response - } -} diff --git a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+Definition.swift b/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+Definition.swift deleted file mode 100644 index 394eb2f35..000000000 --- a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+Definition.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// LanguageServer+Definition.swift -// CodeEdit -// -// Created by Abe Malla on 2/7/24. -// - -import Foundation -import LanguageServerProtocol - -extension LanguageServer { - func requestGoToDefinition( - for languageId: LanguageIdentifier, - document documentURI: String, - position: Position - ) async throws -> DefinitionResponse { - let cacheKey = CacheKey( - uri: documentURI, - requestType: "goToDefinition", - extraData: NoExtraData() - ) - if let cachedResponse: DefinitionResponse = lspCache.get(key: cacheKey, as: DefinitionResponse.self) { - return cachedResponse - } - - let textDocumentIdentifier = TextDocumentIdentifier(uri: documentURI) - let textDocumentPositionParams = TextDocumentPositionParams( - textDocument: textDocumentIdentifier, - position: position - ) - let response = try await lspInstance.definition(textDocumentPositionParams) - - lspCache.set(key: cacheKey, value: response) - return response - } -} diff --git a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+Diagnostics.swift b/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+Diagnostics.swift deleted file mode 100644 index 83bcda43f..000000000 --- a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+Diagnostics.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// LanguageServer+Diagnostics.swift -// CodeEdit -// -// Created by Abe Malla on 2/7/24. -// - -import Foundation -import LanguageServerProtocol - -extension LanguageServer { - func requestPullDiagnostics(document documentURI: String) async throws -> DocumentDiagnosticReport { - let cacheKey = CacheKey( - uri: documentURI, - requestType: "diagnostics", - extraData: NoExtraData() - ) - if let cachedResponse: DocumentDiagnosticReport = lspCache.get( - key: cacheKey, as: DocumentDiagnosticReport.self - ) { - return cachedResponse - } - - let response = try await lspInstance.diagnostics( - DocumentDiagnosticParams( - textDocument: TextDocumentIdentifier(uri: documentURI) - ) - ) - lspCache.set(key: cacheKey, value: response) - return response - } -} diff --git a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+DocumentSymbol.swift b/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+DocumentSymbol.swift deleted file mode 100644 index b224c7f4d..000000000 --- a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+DocumentSymbol.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// LanguageServer+DocumentSymbol.swift -// CodeEdit -// -// Created by Abe Malla on 2/7/24. -// - -import Foundation -import LanguageServerProtocol - -extension LanguageServer { - func requestDocumentSymbols( - for languageId: LanguageIdentifier, - document documentURI: String - ) async throws -> DocumentSymbolResponse { - let textDocumentIdentifier = TextDocumentIdentifier(uri: documentURI) - let documentSymbolParams = DocumentSymbolParams(textDocument: textDocumentIdentifier) - return try await lspInstance.documentSymbol(documentSymbolParams) - } -} diff --git a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+DocumentUtil.swift b/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+DocumentUtil.swift deleted file mode 100644 index 5dda0c77f..000000000 --- a/CodeEdit/Features/LSP/LanguageServerExtensions/LanguageServer+DocumentUtil.swift +++ /dev/null @@ -1,180 +0,0 @@ -// -// LanguageServer+DocumentUtil.swift -// CodeEdit -// -// Created by Abe Malla on 2/7/24. -// - -import Foundation -import LanguageServerProtocol - -extension LanguageServer { - mutating func addDocument(_ fileURL: URL) async -> Bool { - do { - let docContent = try String(contentsOf: fileURL) - let textDocument = TextDocumentItem( - uri: fileURL.absoluteString, - languageId: .rust, - version: 0, - text: docContent - ) - return await self.addDocument(textDocument) - } catch { - print("addDocument: An error occurred: \(error)") - } - - return false - } - - /// Adds a TextDocumentItem to the tracked files and notifies the language server - mutating func addDocument(_ document: TextDocumentItem) async -> Bool { - do { - // Keep track of the document - trackedDocuments[document.uri] = document - - // Send notification to server about our opened file - let params = DidOpenTextDocumentParams(textDocument: document) - try await lspInstance.textDocumentDidOpen(params) - return true - } catch { - print("addDocument: An error occurred: \(error)") - } - - return false - } - - /// Stops tracking a file and notifies the language server - mutating func closeDocument(_ uri: String) async -> Bool { - guard let document = trackedDocuments.removeValue(forKey: uri) else { return false } - - do { - let params = DidCloseTextDocumentParams(textDocument: - TextDocumentIdentifier(uri: document.uri)) - try await lspInstance.textDocumentDidClose(params) - return true - } catch { - print("closeDocument: An error occurred: \(error)") - } - return false - } - - /// Updates the document with the specified URI with new text and increments its version. - /// - Parameters: - /// - uri: The URI of the document to update. - /// - newText: The new text to be set for the document. - /// - Returns: `true` if the document was successfully updated, `false` - mutating func updateDocument( - withUri uri: String, - newText: String, - range: LSPRange, - rangeLength: Int - ) async -> Bool { - // Update the document objects values, including the version - guard let currentDocument = trackedDocuments[uri], - let nsRange = convertLSPRangeToNSRange(range, in: currentDocument.text), - let stringRange = currentDocument.text.range(from: nsRange) else { - // TODO: LOG HERE - return false - } - - // Update the document's content and increment the version. - var updatedText = currentDocument.text - updatedText.replaceSubrange(stringRange, with: newText) - let updatedVersion = currentDocument.version + 1 - let updatedDocument = TextDocumentItem( - uri: currentDocument.uri, - languageId: currentDocument.languageId, - version: updatedVersion, - text: updatedText - ) - trackedDocuments[uri] = updatedDocument - - // Notify the server - do { - let change = TextDocumentContentChangeEvent( - range: range, - rangeLength: rangeLength, - text: newText - ) - let params = DidChangeTextDocumentParams( - textDocument: VersionedTextDocumentIdentifier(uri: uri, version: updatedVersion), - contentChanges: [change] - ) - try await lspInstance.textDocumentDidChange(params) - } catch { - print("updateDocument: An error occurred: \(error)") - } - return true - } -} - -fileprivate extension String { - func range(from nsRange: NSRange) -> Range? { - guard let range = Range(nsRange, in: self) else { return nil } - return range - } -} - -private func applyEditsToDocument(document: TextDocumentItem, edits: [TextEdit]) -> TextDocumentItem { - // Sort edits in reverse order to prevent offset issues - let sortedEdits = edits.sorted { $0.range.start > $1.range.start } - var updatedText = document.text - for edit in sortedEdits { - // Apply each edit to the document text - guard let nsRange = convertLSPRangeToNSRange(edit.range, in: updatedText), - let range = updatedText.range(from: nsRange) else { continue } - updatedText.replaceSubrange(range, with: edit.newText) - } - - return TextDocumentItem( - uri: document.uri, - languageId: document.languageId, - version: document.version, - text: updatedText - ) -} - -private func updateDocumentWithChanges( - document: TextDocumentItem, - changes: [TextDocumentContentChangeEvent] -) -> TextDocumentItem { - var updatedText = document.text - - for change in changes { - // Apply changes with range to the document text - if let lspRange = change.range, - let nsRange = convertLSPRangeToNSRange(lspRange, in: updatedText), - let range = updatedText.range(from: nsRange) { - updatedText.replaceSubrange(range, with: change.text) - } else { - // Replace the entire document text - updatedText = change.text - } - } - - return TextDocumentItem( - uri: document.uri, - languageId: document.languageId, - version: document.version, - text: updatedText - ) -} - -private func convertLSPRangeToNSRange(_ range: LSPRange, in text: String) -> NSRange? { - let lines = text.split(separator: "\n", omittingEmptySubsequences: false) - - // Calculate the start index - let startLineIndex = min(range.start.line, lines.count - 1) - let startCharacterIndex = min(range.start.character, lines[startLineIndex].count) - let startIndex = lines.prefix(startLineIndex).reduce(0, { $0 + $1.count + 1 }) + startCharacterIndex - - // Calculate the end index - let endLineIndex = min(range.end.line, lines.count - 1) - let endCharacterIndex = min(range.end.character, lines[endLineIndex].count) - let endIndex = lines.prefix(endLineIndex).reduce(0, { $0 + $1.count + 1 }) + endCharacterIndex - - // Ensure the range is valid - guard startIndex <= endIndex else { return nil } - - return NSRange(location: startIndex, length: endIndex - startIndex) -} diff --git a/CodeEdit/Features/LSP/LSPEventHandler.swift b/CodeEdit/Features/LSP/Service/LSPService+Events.swift similarity index 78% rename from CodeEdit/Features/LSP/LSPEventHandler.swift rename to CodeEdit/Features/LSP/Service/LSPService+Events.swift index ae6ee28b4..e1b6013c3 100644 --- a/CodeEdit/Features/LSP/LSPEventHandler.swift +++ b/CodeEdit/Features/LSP/Service/LSPService+Events.swift @@ -1,5 +1,5 @@ // -// LSPEventHandler.swift +// LSPService+Events.swift // CodeEdit // // Created by Abe Malla on 6/1/24. @@ -9,37 +9,37 @@ import LanguageClient import LanguageServerProtocol extension LSPService { - func startListeningToEvents(for languageId: LanguageIdentifier) { - guard let languageClient = languageClients[languageId] else { - logger.error("Language client not found for \(languageId.rawValue)") + func startListeningToEvents(for key: ClientKey) { + guard let languageClient = languageClients[key] else { + logger.error("Language client not found for \(key.languageId.rawValue)") return } // Create a new Task to listen to the events - let task = Task { + let task = Task.detached { [weak self] in for await event in languageClient.lspInstance.eventSequence { - handleEvent(event, for: languageId) + await self?.handleEvent(event, for: key) } } - eventListeningTasks[languageId] = task + eventListeningTasks[key] = task } - func stopListeningToEvents(for languageId: LanguageIdentifier) { - if let task = eventListeningTasks[languageId] { + func stopListeningToEvents(for key: ClientKey) { + if let task = eventListeningTasks[key] { task.cancel() - eventListeningTasks.removeValue(forKey: languageId) + eventListeningTasks.removeValue(forKey: key) } } - private func handleEvent(_ event: ServerEvent, for languageId: LanguageIdentifier) { + private func handleEvent(_ event: ServerEvent, for key: ClientKey) { switch event { case let .request(id, request): - print("Request ID: \(id) for \(languageId.rawValue)") + print("Request ID: \(id) for \(key.languageId.rawValue)") handleRequest(request) case let .notification(notification): handleNotification(notification) case let .error(error): - print("Error from EventStream for \(languageId.rawValue): \(error)") + print("Error from EventStream for \(key.languageId.rawValue): \(error)") } } diff --git a/CodeEdit/Features/LSP/Service/LSPService.swift b/CodeEdit/Features/LSP/Service/LSPService.swift new file mode 100644 index 000000000..184ea8710 --- /dev/null +++ b/CodeEdit/Features/LSP/Service/LSPService.swift @@ -0,0 +1,247 @@ +// +// LSPService.swift +// CodeEdit +// +// Created by Abe Malla on 2/7/24. +// + +import os.log +import JSONRPC +import Foundation +import LanguageClient +import LanguageServerProtocol +import CodeEditLanguages + +extension CodeFileDocument { + func findWorkspace() -> WorkspaceDocument? { + CodeEditDocumentController.shared.documents.first(where: { doc in + guard let workspace = doc as? WorkspaceDocument, let path = self.languageServerURI else { return false } + return workspace.workspaceFileManager?.getFile(path)?.fileDocument?.isEqual(self) ?? false + }) as? WorkspaceDocument + } +} + +/// `LSPService` is a service class responsible for managing the lifecycle and event handling +/// of Language Server Protocol (LSP) clients within the CodeEdit application. It handles the initialization, +/// communication, and termination of language servers, ensuring that code assistance features +/// such as code completion, diagnostics, and more are available for various programming languages. +/// +/// This class uses Swift's concurrency model to manage background tasks and event streams +/// efficiently. Each language server runs in its own asynchronous task, listening for events and +/// handling them as they occur. The `LSPService` class also provides functionality to start +/// and stop individual language servers, as well as to stop all running servers. +/// +/// ## Example Usage +/// +/// ```swift +/// @Service var lspService +/// +/// try await lspService.startServer( +/// for: .python, +/// projectURL: projectURL, +/// workspaceFolders: workspaceFolders +/// ) +/// try await lspService.stopServer(for: .python) +/// ``` +/// +/// ## Completion Example +/// +/// ```swift +/// func testCompletion() async throws { +/// do { +/// guard var languageClient = self.languageClient(for: .python) else { +/// print("Failed to get client") +/// throw ServerManagerError.languageClientNotFound +/// } +/// +/// let testFilePathStr = "" +/// let testFileURL = URL(fileURLWithPath: testFilePathStr) +/// +/// // Tell server we opened a document +/// _ = await languageClient.addDocument(testFileURL) +/// +/// // Completion example +/// let textPosition = Position(line: 32, character: 18) // Lines and characters start at 0 +/// let completions = try await languageClient.requestCompletion( +/// document: testFileURL.absoluteString, +/// position: textPosition +/// ) +/// switch completions { +/// case .optionA(let completionItems): +/// // Handle the case where completions is an array of CompletionItem +/// print("\n*******\nCompletion Items:\n*******\n") +/// for item in completionItems { +/// let textEdits = LSPCompletionItemsUtil.getCompletionItemEdits( +/// startPosition: textPosition, +/// item: item +/// ) +/// for edit in textEdits { +/// print(edit) +/// } +/// } +/// +/// case .optionB(let completionList): +/// // Handle the case where completions is a CompletionList +/// print("\n*******\nCompletion Items:\n*******\n") +/// for item in completionList.items { +/// let textEdits = LSPCompletionItemsUtil.getCompletionItemEdits( +/// startPosition: textPosition, +/// item: item +/// ) +/// for edit in textEdits { +/// print(edit) +/// } +/// } +/// +/// print(completionList.items[0]) +/// +/// case .none: +/// print("No completions found") +/// } +/// +/// // Close the document +/// _ = await languageClient.closeDocument(testFilePathStr) +/// } catch { +/// print(error) +/// } +/// } +/// ``` +@MainActor +final class LSPService: ObservableObject { + internal let logger: Logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "LSPService") + + struct ClientKey: Hashable, Equatable { + let languageId: LanguageIdentifier + let workspacePath: String + + init(_ languageId: LanguageIdentifier, _ workspacePath: String) { + self.languageId = languageId + self.workspacePath = workspacePath + } + } + + /// Holds the active language clients + internal var languageClients: [ClientKey: LanguageServer] = [:] + /// Holds the language server configurations for all the installed language servers + internal var languageConfigs: [LanguageIdentifier: LanguageServerBinary] = [:] + /// Holds all the event listeners for each active language client + internal var eventListeningTasks: [ClientKey: Task] = [:] + + @AppSettings(\.developerSettings.lspBinaries) + internal var lspBinaries + + init() { + // Load the LSP binaries from the developer menu + for binary in lspBinaries { + if let language = LanguageIdentifier(rawValue: binary.key) { + self.languageConfigs[language] = LanguageServerBinary( + execPath: binary.value, + args: [], + env: ProcessInfo.processInfo.environment + ) + } + } + } + + /// Gets the language server for the specified language and workspace. + func server(for languageId: LanguageIdentifier, workspacePath: String) async -> InitializingServer? { + return languageClients[ClientKey(languageId, workspacePath)]?.lspInstance + } + + /// Gets the language client for the specified language + func languageClient(for languageId: LanguageIdentifier, workspacePath: String) -> LanguageServer? { + return languageClients[ClientKey(languageId, workspacePath)] + } + + /// Given a language, will attempt to start the language server + func startServer( + for languageId: LanguageIdentifier, + workspacePath: String + ) async throws -> LanguageServer { + guard let serverBinary = languageConfigs[languageId] else { + logger.error("Couldn't find language sever binary for \(languageId.rawValue)") + throw LSPError.binaryNotFound + } + + logger.info("Starting \(languageId.rawValue) language server") + let server = try await LanguageServer.createServer( + for: languageId, + with: serverBinary, + workspacePath: workspacePath + ) + languageClients[ClientKey(languageId, workspacePath)] = server + logger.info("Successfully started \(languageId.rawValue) language server") + + self.startListeningToEvents(for: ClientKey(languageId, workspacePath)) + return server + } + + /// Notify the proper language server that we opened a document. + func openDocument(_ document: CodeFileDocument) { + Task.detached { + guard let workspace = await document.findWorkspace(), + let workspacePath = workspace.fileURL?.absoluteURL.path(), + let lspLanguage = await document.getLanguage().lspLanguage else { + return + } + let languageServer: LanguageServer + if let server = await self.languageClients[ClientKey(lspLanguage, workspacePath)] { + languageServer = server + } else { + languageServer = try await self.startServer(for: lspLanguage, workspacePath: workspacePath) + } + try await languageServer.openDocument(document) + } + } + + func closeDocument(_ document: CodeFileDocument) { + guard let workspace = document.findWorkspace(), + let workspacePath = workspace.fileURL?.absoluteURL.path(), + let lspLanguage = document.getLanguage().lspLanguage, + let languageClient = self.languageClient(for: lspLanguage, workspacePath: workspacePath), + let uri = document.languageServerURI else { + return + } + Task { + try await languageClient.closeDocument(uri) + } + } + + /// Attempts to stop a running language server. Throws an error if the server is not found + /// or if the language server throws an error while trying to shutdown. + /// - Parameters: + /// - languageId: The ID of the language server to stop. + /// - workspacePath: The path of the workspace to stop the language server for. + func stopServer(forLanguage languageId: LanguageIdentifier, workspacePath: String) async throws { + guard let server = await self.server(for: languageId, workspacePath: workspacePath) else { + logger.error("Server not found for language \(languageId.rawValue) during stop operation") + throw ServerManagerError.serverNotFound + } + do { + try await server.shutdownAndExit() + } catch { + logger.error("Failed to stop server for language \(languageId.rawValue): \(error.localizedDescription)") + throw error + } + languageClients.removeValue(forKey: ClientKey(languageId, workspacePath)) + logger.info("Server stopped for language \(languageId.rawValue)") + + stopListeningToEvents(for: ClientKey(languageId, workspacePath)) + } + + /// Goes through all active language servers and attempts to shut them down. + func stopAllServers() async throws { + for key in languageClients.keys { + try await stopServer(forLanguage: key.languageId, workspacePath: key.workspacePath) + } + } +} + +// MARK: - Errors + +enum ServerManagerError: Error { + case serverNotFound + case serverStartFailed + case serverStopFailed + case languageClientNotFound +} diff --git a/CodeEdit/Utils/DependencyInjection/LazyServiceWrapper.swift b/CodeEdit/Utils/DependencyInjection/LazyServiceWrapper.swift new file mode 100644 index 000000000..b64dc7f61 --- /dev/null +++ b/CodeEdit/Utils/DependencyInjection/LazyServiceWrapper.swift @@ -0,0 +1,36 @@ +// +// LazyServiceWrapper.swift +// CodeEdit +// +// Created by Khan Winter on 9/9/24. +// + +/// A property wrapper that provides lazily-loaded access to a service instance. +/// +/// Using this wrapper, the service is only resolved when the property is first accessed. +@propertyWrapper +struct LazyService { + private let type: ServiceType + private var service: Service? + + init(_ type: ServiceType = .singleton) { + self.type = type + } + + var wrappedValue: Service { + mutating get { + if let service { + return service + } else { + guard let resolvedService = ServiceContainer.resolve(type, Service.self) else { + let serviceName = String(describing: Service.self) + fatalError("No service of type \(serviceName) registered!") + } + self.service = resolvedService + return resolvedService + } + } mutating set { + self.service = newValue + } + } +} diff --git a/CodeEdit/Features/OpenQuickly/ViewModels/URL+FuzzySearchable.swift b/CodeEdit/Utils/Extensions/URL/URL+FuzzySearchable.swift similarity index 100% rename from CodeEdit/Features/OpenQuickly/ViewModels/URL+FuzzySearchable.swift rename to CodeEdit/Utils/Extensions/URL/URL+FuzzySearchable.swift diff --git a/CodeEdit/Utils/Extensions/URL/URL+LanguageServer.swift b/CodeEdit/Utils/Extensions/URL/URL+LanguageServer.swift new file mode 100644 index 000000000..c6c86966a --- /dev/null +++ b/CodeEdit/Utils/Extensions/URL/URL+LanguageServer.swift @@ -0,0 +1,14 @@ +// +// URL+LanguageServer.swift +// CodeEdit +// +// Created by Khan Winter on 9/8/24. +// + +import Foundation + +extension URL { + var languageServerURI: String { + absoluteURL.path(percentEncoded: false) + } +} diff --git a/CodeEdit/Features/SourceControl/Accounts/Utils/URL+URLParameters.swift b/CodeEdit/Utils/Extensions/URL/URL+URLParameters.swift similarity index 100% rename from CodeEdit/Features/SourceControl/Accounts/Utils/URL+URLParameters.swift rename to CodeEdit/Utils/Extensions/URL/URL+URLParameters.swift From f9000c5bb1d641cdf390db95322323867b768d9d Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 10 Sep 2024 10:07:41 -0500 Subject: [PATCH 02/18] Shutdown Servers When Workspace Closes --- CodeEdit.xcodeproj/project.pbxproj | 32 ++++++++----- .../WorkspaceDocument/WorkspaceDocument.swift | 5 ++ .../Features/LSP/Service/LSPService.swift | 46 +++++++++++++++++-- .../ShellIntegrationTests.swift | 0 .../LSP/BufferingServerConnection.swift | 43 +++++++++++++++++ .../LSP/LanguageServer+DocumentTests.swift | 18 ++++++++ 6 files changed, 128 insertions(+), 16 deletions(-) rename CodeEditTests/Features/{ => ActivityViewer}/TerminalEmulator/ShellIntegrationTests.swift (100%) create mode 100644 CodeEditTests/Features/LSP/BufferingServerConnection.swift create mode 100644 CodeEditTests/Features/LSP/LanguageServer+DocumentTests.swift diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 34b9b7ace..418b2a8a1 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -416,6 +416,7 @@ 6C6BD70129CD172700235D17 /* ExtensionsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C6BD70029CD172700235D17 /* ExtensionsListView.swift */; }; 6C6BD70429CD17B600235D17 /* ExtensionsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C6BD70329CD17B600235D17 /* ExtensionsManager.swift */; }; 6C7256D729A3D7D000C2D3E0 /* SplitViewControllerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C7256D629A3D7D000C2D3E0 /* SplitViewControllerView.swift */; }; + 6C7D6D462C9092EC00B69EE0 /* BufferingServerConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C7D6D452C9092EC00B69EE0 /* BufferingServerConnection.swift */; }; 6C7F37FE2A3EA6FA00217B83 /* View+focusedValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C7F37FD2A3EA6FA00217B83 /* View+focusedValue.swift */; }; 6C81916729B3E80700B75C92 /* ModifierKeysObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C81916629B3E80700B75C92 /* ModifierKeysObserver.swift */; }; 6C81916B29B41DD300B75C92 /* DequeModule in Frameworks */ = {isa = PBXBuildFile; productRef = 6C81916A29B41DD300B75C92 /* DequeModule */; }; @@ -458,6 +459,7 @@ 6CD26C812C8F8A4400ADBA38 /* LanguageIdentifier+CodeLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD26C802C8F8A4400ADBA38 /* LanguageIdentifier+CodeLanguage.swift */; }; 6CD26C852C8F907800ADBA38 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6CD26C842C8F907800ADBA38 /* CodeEditSourceEditor */; }; 6CD26C872C8F90FD00ADBA38 /* LazyServiceWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD26C862C8F90FD00ADBA38 /* LazyServiceWrapper.swift */; }; + 6CD26C8A2C8F91ED00ADBA38 /* LanguageServer+DocumentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD26C892C8F91ED00ADBA38 /* LanguageServer+DocumentTests.swift */; }; 6CD3CA552C8B508200D83DCD /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6CD3CA542C8B508200D83DCD /* CodeEditSourceEditor */; }; 6CDA84AD284C1BA000C1CC3A /* EditorTabBarContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CDA84AC284C1BA000C1CC3A /* EditorTabBarContextMenu.swift */; }; 6CE21E812C643D8F0031B056 /* CETerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CE21E802C643D8F0031B056 /* CETerminalView.swift */; }; @@ -1075,6 +1077,7 @@ 6C6BD70029CD172700235D17 /* ExtensionsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionsListView.swift; sourceTree = ""; }; 6C6BD70329CD17B600235D17 /* ExtensionsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionsManager.swift; sourceTree = ""; }; 6C7256D629A3D7D000C2D3E0 /* SplitViewControllerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitViewControllerView.swift; sourceTree = ""; }; + 6C7D6D452C9092EC00B69EE0 /* BufferingServerConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BufferingServerConnection.swift; sourceTree = ""; }; 6C7F37FD2A3EA6FA00217B83 /* View+focusedValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+focusedValue.swift"; sourceTree = ""; }; 6C81916629B3E80700B75C92 /* ModifierKeysObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModifierKeysObserver.swift; sourceTree = ""; }; 6C82D6B229BFD88700495C54 /* NavigateCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigateCommands.swift; sourceTree = ""; }; @@ -1107,6 +1110,7 @@ 6CD26C7C2C8EA8F400ADBA38 /* LanguageServer+DocumentSync.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "LanguageServer+DocumentSync.swift"; sourceTree = ""; }; 6CD26C802C8F8A4400ADBA38 /* LanguageIdentifier+CodeLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LanguageIdentifier+CodeLanguage.swift"; sourceTree = ""; }; 6CD26C862C8F90FD00ADBA38 /* LazyServiceWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyServiceWrapper.swift; sourceTree = ""; }; + 6CD26C892C8F91ED00ADBA38 /* LanguageServer+DocumentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LanguageServer+DocumentTests.swift"; sourceTree = ""; }; 6CDA84AC284C1BA000C1CC3A /* EditorTabBarContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorTabBarContextMenu.swift; sourceTree = ""; }; 6CE21E802C643D8F0031B056 /* CETerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CETerminalView.swift; sourceTree = ""; }; 6CE622682A2A174A0013085C /* InspectorTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectorTab.swift; sourceTree = ""; }; @@ -1994,14 +1998,14 @@ isa = PBXGroup; children = ( 283BDCC22972F211002AFF81 /* Acknowledgements */, - 4EE96EC82960562000FFBEA8 /* Documents */, 617DB3DD2C25E11500B58BFE /* ActivityViewer */, - 6141CF392C3DA4180073BC9F /* TerminalEmulator */, - 61FB03A92C3C1FC4001B3671 /* Tasks */, 583E527429361B39001AB554 /* CodeEditUI */, 587B612C2934199800D5CD8F /* CodeFile */, + 4EE96EC82960562000FFBEA8 /* Documents */, + 6CD26C882C8F91B600ADBA38 /* LSP */, 613899BD2B6E70E200A5CAF6 /* Search */, - 6C1F3DA02C18C53C00F6DEF6 /* TerminalEmulator */, + 61FB03A92C3C1FC4001B3671 /* Tasks */, + 6141CF392C3DA4180073BC9F /* TerminalEmulator */, ); path = Features; sourceTree = ""; @@ -2596,6 +2600,7 @@ 6141CF392C3DA4180073BC9F /* TerminalEmulator */ = { isa = PBXGroup; children = ( + 6C1F3DA12C18C55800F6DEF6 /* ShellIntegrationTests.swift */, 61FB03AA2C3C1FD5001B3671 /* Shell */, ); name = TerminalEmulator; @@ -2818,14 +2823,6 @@ path = FindNavigatorResultList; sourceTree = ""; }; - 6C1F3DA02C18C53C00F6DEF6 /* TerminalEmulator */ = { - isa = PBXGroup; - children = ( - 6C1F3DA12C18C55800F6DEF6 /* ShellIntegrationTests.swift */, - ); - path = TerminalEmulator; - sourceTree = ""; - }; 6C2384302C796EBD003FBDD4 /* ChangedFile */ = { isa = PBXGroup; children = ( @@ -3025,6 +3022,15 @@ path = Extensions; sourceTree = ""; }; + 6CD26C882C8F91B600ADBA38 /* LSP */ = { + isa = PBXGroup; + children = ( + 6CD26C892C8F91ED00ADBA38 /* LanguageServer+DocumentTests.swift */, + 6C7D6D452C9092EC00B69EE0 /* BufferingServerConnection.swift */, + ); + path = LSP; + sourceTree = ""; + }; 6CFBA54E2C4E182100E3A914 /* Other Tests */ = { isa = PBXGroup; children = ( @@ -4401,6 +4407,7 @@ 61FB03B02C3C76AF001B3671 /* TaskManagerTests.swift in Sources */, 61FB03AC2C3C1FDF001B3671 /* ShellTests.swift in Sources */, 583E528C29361B39001AB554 /* CodeEditUITests.swift in Sources */, + 6C7D6D462C9092EC00B69EE0 /* BufferingServerConnection.swift in Sources */, 613053652B23A49300D767E3 /* TemporaryFile.swift in Sources */, 617DB3DF2C25E13800B58BFE /* TaskNotificationHandlerTests.swift in Sources */, 775566502C27FD1B001E7A4D /* CodeFileDocument+UTTypeTests.swift in Sources */, @@ -4409,6 +4416,7 @@ 587B61012934170A00D5CD8F /* UnitTests_Extensions.swift in Sources */, 6C1F3DA22C18C55800F6DEF6 /* ShellIntegrationTests.swift in Sources */, 283BDCC52972F236002AFF81 /* AcknowledgementsTests.swift in Sources */, + 6CD26C8A2C8F91ED00ADBA38 /* LanguageServer+DocumentTests.swift in Sources */, 4EE96ECB2960565E00FFBEA8 /* DocumentsUnitTests.swift in Sources */, 4EE96ECE296059E000FFBEA8 /* NSHapticFeedbackPerformerMock.swift in Sources */, 61FB03AE2C3C2493001B3671 /* CEActiveTaskTests.swift in Sources */, diff --git a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift index 83eb1a045..5236091fc 100644 --- a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift +++ b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift @@ -13,6 +13,7 @@ import LanguageServerProtocol @objc(WorkspaceDocument) final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate { + @Service var lspService: LSPService @Published var sortFoldersOnTop: Bool = true @@ -176,6 +177,10 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate { workspaceSettingsManager?.cleanUp() workspaceSettingsManager = nil taskManager = nil + + if let path = self.fileURL?.absoluteURL.path() { + lspService.closeWorkspace(path) + } } /// Determines the windows should be closed. diff --git a/CodeEdit/Features/LSP/Service/LSPService.swift b/CodeEdit/Features/LSP/Service/LSPService.swift index 184ea8710..9b85f8888 100644 --- a/CodeEdit/Features/LSP/Service/LSPService.swift +++ b/CodeEdit/Features/LSP/Service/LSPService.swift @@ -176,7 +176,9 @@ final class LSPService: ObservableObject { return server } - /// Notify the proper language server that we opened a document. + /// Notify all relevant language clients that a document was opened. + /// - Note: Must be invoked after the contents of the file are available. + /// - Parameter document: The code document that was opened. func openDocument(_ document: CodeFileDocument) { Task.detached { guard let workspace = await document.findWorkspace(), @@ -190,10 +192,18 @@ final class LSPService: ObservableObject { } else { languageServer = try await self.startServer(for: lspLanguage, workspacePath: workspacePath) } - try await languageServer.openDocument(document) + do { + try await languageServer.openDocument(document) + } catch { + let uri = await document.languageServerURI + // swiftlint:disable:next line_length + self.logger.error("Failed to close document: \(uri ?? "", privacy: .private), language: \(lspLanguage.rawValue). Error \(error)") + } } } - + + /// Notify all relevant language clients that a document was closed. + /// - Parameter document: The code document that was closed. func closeDocument(_ document: CodeFileDocument) { guard let workspace = document.findWorkspace(), let workspacePath = workspace.fileURL?.absoluteURL.path(), @@ -203,7 +213,35 @@ final class LSPService: ObservableObject { return } Task { - try await languageClient.closeDocument(uri) + do { + try await languageClient.closeDocument(uri) + } catch { + // swiftlint:disable:next line_length + logger.error("Failed to close document: \(uri, privacy: .private), language: \(lspLanguage.rawValue). Error \(error)") + } + } + } + + /// Close all language clients for a workspace. + /// + /// This is intentionally synchronous so we can exit from the workspace document's ``WorkspaceDocument/close()`` + /// method ASAP. + /// + /// Errors thrown in this method are logged and otherwise not handled. + /// - Parameter workspacePath: The path of the workspace. + func closeWorkspace(_ workspacePath: String) { + Task { + let clientKeys = self.languageClients.filter({ $0.key.workspacePath == workspacePath }) + for (key, languageClient) in clientKeys { + do { + try await languageClient.shutdown() + } catch { + logger.error("Failed to shutdown \(key.languageId.rawValue) Language Server: Error \(error)") + } + } + for (key, _) in clientKeys { + self.languageClients.removeValue(forKey: key) + } } } diff --git a/CodeEditTests/Features/TerminalEmulator/ShellIntegrationTests.swift b/CodeEditTests/Features/ActivityViewer/TerminalEmulator/ShellIntegrationTests.swift similarity index 100% rename from CodeEditTests/Features/TerminalEmulator/ShellIntegrationTests.swift rename to CodeEditTests/Features/ActivityViewer/TerminalEmulator/ShellIntegrationTests.swift diff --git a/CodeEditTests/Features/LSP/BufferingServerConnection.swift b/CodeEditTests/Features/LSP/BufferingServerConnection.swift new file mode 100644 index 000000000..e5a4dbe00 --- /dev/null +++ b/CodeEditTests/Features/LSP/BufferingServerConnection.swift @@ -0,0 +1,43 @@ +// +// BufferingServerConnection.swift +// CodeEditTests +// +// Created by Khan Winter on 9/10/24. +// + +import Foundation +import LanguageClient +import LanguageServerProtocol +import JSONRPC + +class BufferingServerConnection: ServerConnection { + var eventSequence: EventSequence + private var id = 0 + + public var clientRequests: [ClientRequest] = [] + public var clientNotifications: [ClientNotification] = [] + + init() { + let (sequence, continuation) = EventSequence.makeStream() + self.eventSequence = sequence + } + + func sendNotification(_ notif: ClientNotification) async throws { + clientNotifications.append(notif) + } + + func sendRequest(_ request: ClientRequest) async throws -> Response { + clientRequests.append(request) + id += 1 + switch request { + case .initialize: + var capabilities = ServerCapabilities() + capabilities.textDocumentSync = .optionA(.init( + openClose: true, change: .incremental, willSave: true, willSaveWaitUntil: false, save: .optionA(true) + )) + return InitializationResponse(capabilities: .init(), serverInfo: nil) as! Response + default: + return JSONRPCResponse(id: .numericId(id), result: "buh") as! Response + } + } +} diff --git a/CodeEditTests/Features/LSP/LanguageServer+DocumentTests.swift b/CodeEditTests/Features/LSP/LanguageServer+DocumentTests.swift new file mode 100644 index 000000000..2c785b338 --- /dev/null +++ b/CodeEditTests/Features/LSP/LanguageServer+DocumentTests.swift @@ -0,0 +1,18 @@ +// +// LanguageServer+DocumentTests.swift +// CodeEditTests +// +// Created by Khan Winter on 9/9/24. +// + +import XCTest +import LanguageClient +import LanguageServerProtocol + +@testable import CodeEdit + +final class LanguageServerDocumentTests: XCTestCase { + // Test opening documents in CodeEdit triggers creating a language server, + // further opened documents don't create new servers + // Test closing documents +} From ccad2606278e258f1c2b66fa69c44042d5a9af4a Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 11 Sep 2024 22:09:49 -0500 Subject: [PATCH 03/18] Add Document Sync Tests --- ...EWorkspaceFileManager+FileManagement.swift | 59 ++++---- .../CodeEditDocumentController.swift | 4 + .../WorkspaceDocument/WorkspaceDocument.swift | 4 - .../LanguageServer+DocumentSync.swift | 36 +++-- .../LSP/LanguageServer/LanguageServer.swift | 4 +- .../Features/LSP/Service/LSPService.swift | 16 ++- .../OutlineView/ProjectNavigatorMenu.swift | 8 +- .../ProjectNavigatorToolbarBottom.swift | 8 +- .../LSP/BufferingServerConnection.swift | 9 +- .../LSP/LanguageServer+DocumentTests.swift | 130 +++++++++++++++++- 10 files changed, 224 insertions(+), 54 deletions(-) diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift index cc536520b..cb21e707e 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift @@ -44,48 +44,59 @@ extension CEWorkspaceFileManager { /// - Parameters: /// - fileName: The name of the new file /// - file: The file to add the new file to. + /// - useExtension: The file extension to use. Leave `nil` to guess using relevant nearby files. /// - Authors: Mattijs Eikelenboom, KaiTheRedNinja. *Moved from 7c27b1e* - func addFile(fileName: String, toFile file: CEWorkspaceFile) { + /// - Returns: Boolean indicating whether or not the file was created + func addFile(fileName: String, toFile file: CEWorkspaceFile, useExtension: String? = nil) throws { // check the folder for other files, and see what the most common file extension is - var fileExtensions: [String: Int] = ["": 0] - - for child in ( - file.isFolder ? file.flattenedSiblings(withHeight: 2, ignoringFolders: true, using: self) - : file.parent?.flattenedSiblings(withHeight: 2, ignoringFolders: true, using: self) - ) ?? [] - where !child.isFolder { - // if the file extension was present before, add it now - let childFileName = child.fileName(typeHidden: false) - if let index = childFileName.lastIndex(of: ".") { - let childFileExtension = ".\(childFileName.suffix(from: index).dropFirst())" - fileExtensions[childFileExtension] = (fileExtensions[childFileExtension] ?? 0) + 1 - } else { - fileExtensions[""] = (fileExtensions[""] ?? 0) + 1 + var fileExtension: String + if let useExtension { + fileExtension = useExtension + } else { + var fileExtensions: [String: Int] = ["": 0] + + for child in ( + file.isFolder ? file.flattenedSiblings(withHeight: 2, ignoringFolders: true, using: self) + : file.parent?.flattenedSiblings(withHeight: 2, ignoringFolders: true, using: self) + ) ?? [] + where !child.isFolder { + // if the file extension was present before, add it now + let childFileName = child.fileName(typeHidden: false) + if let index = childFileName.lastIndex(of: ".") { + let childFileExtension = ".\(childFileName.suffix(from: index).dropFirst())" + fileExtensions[childFileExtension] = (fileExtensions[childFileExtension] ?? 0) + 1 + } else { + fileExtensions[""] = (fileExtensions[""] ?? 0) + 1 + } } + + var largestValue = 0 + fileExtension = fileExtensions.sorted(by: { $0.value > $1.value }).first?.key ?? "txt" } - var largestValue = 0 - var idealExtension = "" - for (extName, count) in fileExtensions where count > largestValue { - idealExtension = extName - largestValue = count + if !fileExtension.starts(with: ".") { + fileExtension = "." + fileExtension } - var fileUrl = file.nearestFolder.appendingPathComponent("\(fileName)\(idealExtension)") + var fileUrl = file.nearestFolder.appendingPathComponent("\(fileName)\(fileExtension)") // If a file/folder with the same name exists, add a number to the end. var fileNumber = 0 while fileManager.fileExists(atPath: fileUrl.path) { fileNumber += 1 fileUrl = fileUrl.deletingLastPathComponent() - .appendingPathComponent("\(fileName)\(fileNumber)\(idealExtension)") + .appendingPathComponent("\(fileName)\(fileNumber)\(fileExtension)") } // Create the file - fileManager.createFile( + guard fileManager.createFile( atPath: fileUrl.path, contents: nil, attributes: [FileAttributeKey.creationDate: Date()] - ) + ) else { + throw CocoaError.error(.fileWriteUnknown, url: fileUrl) + } + + try rebuildFiles(fromItem: file) } /// This function deletes the item or folder from the current project by moving to Trash diff --git a/CodeEdit/Features/Documents/Controllers/CodeEditDocumentController.swift b/CodeEdit/Features/Documents/Controllers/CodeEditDocumentController.swift index e8b2e7038..6e976d049 100644 --- a/CodeEdit/Features/Documents/Controllers/CodeEditDocumentController.swift +++ b/CodeEdit/Features/Documents/Controllers/CodeEditDocumentController.swift @@ -81,6 +81,10 @@ final class CodeEditDocumentController: NSDocumentController { override func removeDocument(_ document: NSDocument) { super.removeDocument(document) + if let workspace = document as? WorkspaceDocument, let path = workspace.fileURL?.absoluteURL.path() { + lspService.closeWorkspace(path) + } + if CodeEditDocumentController.shared.documents.isEmpty { switch Settings[\.general].reopenWindowAfterClose { case .showWelcomeWindow: diff --git a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift index 5236091fc..40f1276f9 100644 --- a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift +++ b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift @@ -177,10 +177,6 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate { workspaceSettingsManager?.cleanUp() workspaceSettingsManager = nil taskManager = nil - - if let path = self.fileURL?.absoluteURL.path() { - lspService.closeWorkspace(path) - } } /// Determines the windows should be closed. diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift index bd425f554..39e0ef00a 100644 --- a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift +++ b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift @@ -32,30 +32,29 @@ extension LanguageServer { return true } } - + + fileprivate struct DocumentContent { + let uri: String + let language: LanguageIdentifier + let content: String + } + /// Tells the language server we've opened a document and would like to begin working with it. /// - Parameter document: The code document to open. func openDocument(_ document: CodeFileDocument) async throws { do { - guard serverSupportsOpenClose(), - let uri = await document.languageServerURI, - let language = await document.getLanguage().lspLanguage else { + guard serverSupportsOpenClose(), let content = await getDocumentContent(document) else { return } - let content = await MainActor.run { - let storage = document.content - return storage?.string - } - guard let content else { return } - logger.debug("Opening Document \(uri, privacy: .private)") + logger.debug("Opening Document \(content.uri, privacy: .private)") self.openFiles.addDocument(document) let textDocument = TextDocumentItem( - uri: uri, - languageId: language, + uri: content.uri, + languageId: content.language, version: 0, - text: content + text: content.content ) try await lspInstance.textDocumentDidOpen(DidOpenTextDocumentParams(textDocument: textDocument)) } catch { @@ -64,6 +63,17 @@ extension LanguageServer { } } + /// Small helper function for grabbing a document's content from the main actor. + @MainActor + private func getDocumentContent(_ document: CodeFileDocument) -> DocumentContent? { + guard let uri = document.languageServerURI, + let language = document.getLanguage().lspLanguage, + let content = document.content?.string else { + return nil + } + return DocumentContent(uri: uri, language: language, content: content) + } + /// Stops tracking a file and notifies the language server /// - Parameter uri: The URI of the document to close. func closeDocument(_ uri: String) async throws { diff --git a/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift b/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift index d10134506..9f00b4f4c 100644 --- a/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift +++ b/CodeEdit/Features/LSP/LanguageServer/LanguageServer.swift @@ -87,7 +87,7 @@ class LanguageServer { /// - languageId: The ID of the language to create the channel for. /// - executionParams: The parameters for executing the local process. /// - Returns: A new connection to the language server. - private static func makeLocalServerConnection( + static func makeLocalServerConnection( languageId: LanguageIdentifier, executionParams: Process.ExecutionParameters ) throws -> JSONRPCServerConnection { @@ -106,7 +106,7 @@ class LanguageServer { } // swiftlint:disable function_body_length - private static func getInitParams(workspacePath: String) -> InitializingServer.InitializeParamsProvider { + static func getInitParams(workspacePath: String) -> InitializingServer.InitializeParamsProvider { let provider: InitializingServer.InitializeParamsProvider = { // Text Document Capabilities let textDocumentCapabilities = TextDocumentClientCapabilities( diff --git a/CodeEdit/Features/LSP/Service/LSPService.swift b/CodeEdit/Features/LSP/Service/LSPService.swift index 9b85f8888..f3f89af04 100644 --- a/CodeEdit/Features/LSP/Service/LSPService.swift +++ b/CodeEdit/Features/LSP/Service/LSPService.swift @@ -187,10 +187,16 @@ final class LSPService: ObservableObject { return } let languageServer: LanguageServer - if let server = await self.languageClients[ClientKey(lspLanguage, workspacePath)] { - languageServer = server - } else { - languageServer = try await self.startServer(for: lspLanguage, workspacePath: workspacePath) + do { + if let server = await self.languageClients[ClientKey(lspLanguage, workspacePath)] { + languageServer = server + } else { + languageServer = try await self.startServer(for: lspLanguage, workspacePath: workspacePath) + } + } catch { + // swiftlint:disable:next line_length + self.logger.error("Failed to find/start server for language: \(lspLanguage.rawValue), workspace: \(workspacePath, privacy: .private)") + return } do { try await languageServer.openDocument(document) @@ -201,7 +207,7 @@ final class LSPService: ObservableObject { } } } - + /// Notify all relevant language clients that a document was closed. /// - Parameter document: The code document that was closed. func closeDocument(_ document: CodeFileDocument) { diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenu.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenu.swift index 9841859c1..01206e590 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenu.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenu.swift @@ -209,7 +209,13 @@ final class ProjectNavigatorMenu: NSMenu { @objc private func newFile() { guard let item else { return } - workspace?.workspaceFileManager?.addFile(fileName: "untitled", toFile: item) + do { + try workspace?.workspaceFileManager?.addFile(fileName: "untitled", toFile: item) + } catch { + let alert = NSAlert(error: error) + alert.addButton(withTitle: "Dismiss") + alert.runModal() + } outlineView.expandItem(item.isFolder ? item : item.parent) } diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorToolbarBottom.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorToolbarBottom.swift index 5b58a21e7..d9d7bb2fd 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorToolbarBottom.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorToolbarBottom.swift @@ -102,7 +102,13 @@ struct ProjectNavigatorToolbarBottom: View { Button("Add File") { let filePathURL = activeTabURL() guard let rootFile = workspace.workspaceFileManager?.getFile(filePathURL.path) else { return } - workspace.workspaceFileManager?.addFile(fileName: "untitled", toFile: rootFile) + do { + try workspace.workspaceFileManager?.addFile(fileName: "untitled", toFile: rootFile) + } catch { + let alert = NSAlert(error: error) + alert.addButton(withTitle: "Dismiss") + alert.runModal() + } } Button("Add Folder") { let filePathURL = activeTabURL() diff --git a/CodeEditTests/Features/LSP/BufferingServerConnection.swift b/CodeEditTests/Features/LSP/BufferingServerConnection.swift index e5a4dbe00..824e9853a 100644 --- a/CodeEditTests/Features/LSP/BufferingServerConnection.swift +++ b/CodeEditTests/Features/LSP/BufferingServerConnection.swift @@ -18,7 +18,7 @@ class BufferingServerConnection: ServerConnection { public var clientNotifications: [ClientNotification] = [] init() { - let (sequence, continuation) = EventSequence.makeStream() + let (sequence, _) = EventSequence.makeStream() self.eventSequence = sequence } @@ -29,15 +29,18 @@ class BufferingServerConnection: ServerConnection { func sendRequest(_ request: ClientRequest) async throws -> Response { clientRequests.append(request) id += 1 + let response: Codable switch request { case .initialize: var capabilities = ServerCapabilities() capabilities.textDocumentSync = .optionA(.init( openClose: true, change: .incremental, willSave: true, willSaveWaitUntil: false, save: .optionA(true) )) - return InitializationResponse(capabilities: .init(), serverInfo: nil) as! Response + response = InitializationResponse(capabilities: .init(), serverInfo: nil) default: - return JSONRPCResponse(id: .numericId(id), result: "buh") as! Response + response = JSONRPCResponse(id: .numericId(0), result: JSONRPCErrors.internalError) } + let data = try JSONEncoder().encode(response) + return try JSONDecoder().decode(Response.self, from: data) } } diff --git a/CodeEditTests/Features/LSP/LanguageServer+DocumentTests.swift b/CodeEditTests/Features/LSP/LanguageServer+DocumentTests.swift index 2c785b338..faafcc270 100644 --- a/CodeEditTests/Features/LSP/LanguageServer+DocumentTests.swift +++ b/CodeEditTests/Features/LSP/LanguageServer+DocumentTests.swift @@ -14,5 +14,133 @@ import LanguageServerProtocol final class LanguageServerDocumentTests: XCTestCase { // Test opening documents in CodeEdit triggers creating a language server, // further opened documents don't create new servers - // Test closing documents + + var tempTestDir: URL! + + override func setUp() { + do { + let tempDir = FileManager.default.temporaryDirectory.appending( + path: "codeedit-lsp-tests" + ) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + tempTestDir = tempDir + } catch { + XCTFail(error.localizedDescription) + } + } + + override func tearDown() { + do { + try FileManager.default.removeItem(at: tempTestDir) + } catch { + XCTFail(error.localizedDescription) + } + } + + func makeTestServer() async throws -> (connection: BufferingServerConnection, server: LanguageServer) { + let bufferingConnection = BufferingServerConnection() + var capabilities = ServerCapabilities() + capabilities.textDocumentSync = .optionA( + TextDocumentSyncOptions( + openClose: true, + change: .incremental, + willSave: true, + willSaveWaitUntil: false, + save: nil + ) + ) + let server = LanguageServer( + languageId: .swift, + binary: .init(execPath: "", args: [], env: nil), + lspInstance: InitializingServer( + server: bufferingConnection, + initializeParamsProvider: LanguageServer.getInitParams(workspacePath: tempTestDir.path()) + ), + serverCapabilities: capabilities, + rootPath: tempTestDir + ) + _ = try await server.lspInstance.initializeIfNeeded() + return (connection: bufferingConnection, server: server) + } + + func makeTestWorkspace() throws -> (WorkspaceDocument, CEWorkspaceFileManager) { + let workspace = WorkspaceDocument() + try workspace.read(from: tempTestDir, ofType: "") + guard let fileManager = workspace.workspaceFileManager else { + XCTFail("No File Manager") + fatalError("No File Manager") // never runs + } + return (workspace, fileManager) + } + + @MainActor + func testOpenFileInWorkspaceNotifiesLSP() async throws { + // Set up test server + let (connection, server) = try await makeTestServer() + + let lspService = ServiceContainer.resolve(.singleton, LSPService.self) + await MainActor.run { lspService?.languageClients[.init(.swift, tempTestDir.path() + "/")] = server } + + // Set up workspace + let (workspace, fileManager) = try makeTestWorkspace() + CodeEditDocumentController.shared.addDocument(workspace) + + // Add a CEWorkspaceFile + try fileManager.addFile(fileName: "example", toFile: fileManager.workspaceItem, useExtension: "swift") + guard let file = fileManager.childrenOfFile(fileManager.workspaceItem)?.first else { + XCTFail("No File") + return + } + + // Create a CodeFileDocument to test with, attach it to the workspace and file + let contentType = try file.url.resourceValues(forKeys: [.contentTypeKey]).contentType + let codeFile = try CodeFileDocument( + for: file.url, + withContentsOf: file.url, + ofType: contentType?.identifier ?? "" + ) + file.fileDocument = codeFile + + // This should trigger a documentDidOpen event + CodeEditDocumentController.shared.addDocument(codeFile) + + let eventCountExpectation = expectation(description: "Pre-close event count") + // Wait off the main actor until we've received all the events + Task.detached { + while connection.clientNotifications.count + connection.clientRequests.count < 3 { + try await Task.sleep(for: .milliseconds(10)) + } + eventCountExpectation.fulfill() + } + + await fulfillment(of: [eventCountExpectation], timeout: 5) + + // This should then trigger a documentDidClose event + codeFile.close() + + let eventCloseExpectation = expectation(description: "Post-close event count") + Task.detached { + while connection.clientNotifications.count + connection.clientRequests.count < 4 { + try await Task.sleep(for: .milliseconds(10)) + } + eventCloseExpectation.fulfill() + } + await fulfillment(of: [eventCloseExpectation], timeout: 5.0) + + XCTAssertEqual( + connection.clientRequests.map { $0.method }, + [ + ClientRequest.Method.initialize, + ] + ) + + XCTAssertEqual( + connection.clientNotifications.map { $0.method }, + [ + ClientNotification.Method.initialized, + ClientNotification.Method.textDocumentDidOpen, + ClientNotification.Method.textDocumentDidClose + ] + ) + } } From 47fb7b276765a7dba94eccd0cfe794049127e849 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 11 Sep 2024 22:35:39 -0500 Subject: [PATCH 04/18] Docs --- .../LanguageServer+DocumentColor.swift | 1 - .../LanguageServer+DocumentSync.swift | 15 ++++++++++----- CodeEdit/Features/LSP/Service/LSPService.swift | 8 ++++++-- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentColor.swift b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentColor.swift index 78817813e..8933067ef 100644 --- a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentColor.swift +++ b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentColor.swift @@ -28,4 +28,3 @@ extension LanguageServer { } } } - diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift index 39e0ef00a..eefc3d452 100644 --- a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift +++ b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift @@ -9,7 +9,11 @@ import Foundation import LanguageServerProtocol extension LanguageServer { + // swiftlint:disable line_length + /// Determines the type of document sync the server supports. + /// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_synchronization_sc fileprivate func serverDocumentSyncSupport() -> TextDocumentSyncKind { + // swiftlint:enable line_length var syncKind: TextDocumentSyncKind = .none switch serverCapabilities.textDocumentSync { case .optionA(let options): @@ -33,6 +37,7 @@ extension LanguageServer { } } + // Avoids a lint error fileprivate struct DocumentContent { let uri: String let language: LanguageIdentifier @@ -43,7 +48,7 @@ extension LanguageServer { /// - Parameter document: The code document to open. func openDocument(_ document: CodeFileDocument) async throws { do { - guard serverSupportsOpenClose(), let content = await getDocumentContent(document) else { + guard serverSupportsOpenClose(), let content = await getIsolatedDocumentContent(document) else { return } logger.debug("Opening Document \(content.uri, privacy: .private)") @@ -63,9 +68,9 @@ extension LanguageServer { } } - /// Small helper function for grabbing a document's content from the main actor. + /// Helper function for grabbing a document's content from the main actor. @MainActor - private func getDocumentContent(_ document: CodeFileDocument) -> DocumentContent? { + private func getIsolatedDocumentContent(_ document: CodeFileDocument) -> DocumentContent? { guard let uri = document.languageServerURI, let language = document.getLanguage().lspLanguage, let content = document.content?.string else { @@ -74,11 +79,11 @@ extension LanguageServer { return DocumentContent(uri: uri, language: language, content: content) } - /// Stops tracking a file and notifies the language server + /// Stops tracking a file and notifies the language server. /// - Parameter uri: The URI of the document to close. func closeDocument(_ uri: String) async throws { do { - guard serverSupportsOpenClose() else { return } + guard serverSupportsOpenClose() && openFiles.document(for: uri) != nil else { return } logger.debug("Closing document \(uri, privacy: .private)") openFiles.removeDocument(for: uri) let params = DidCloseTextDocumentParams(textDocument: TextDocumentIdentifier(uri: uri)) diff --git a/CodeEdit/Features/LSP/Service/LSPService.swift b/CodeEdit/Features/LSP/Service/LSPService.swift index f3f89af04..649b32f40 100644 --- a/CodeEdit/Features/LSP/Service/LSPService.swift +++ b/CodeEdit/Features/LSP/Service/LSPService.swift @@ -153,7 +153,11 @@ final class LSPService: ObservableObject { return languageClients[ClientKey(languageId, workspacePath)] } - /// Given a language, will attempt to start the language server + /// Given a language and workspace path, will attempt to start the language server + /// - Parameters: + /// - languageId: The ID of the language server to start. + /// - workspacePath: The workspace this language server is being used in. + /// - Returns: The new language server. func startServer( for languageId: LanguageIdentifier, workspacePath: String @@ -227,7 +231,7 @@ final class LSPService: ObservableObject { } } } - + /// Close all language clients for a workspace. /// /// This is intentionally synchronous so we can exit from the workspace document's ``WorkspaceDocument/close()`` From 2f59b50e05e8bab4a6f7a3106bc75799812db0fb Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 16 Sep 2024 09:16:57 -0500 Subject: [PATCH 05/18] Begin Sync Work, Need To Fix Status Bar Label --- CodeEdit.xcodeproj/project.pbxproj | 38 +++-- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../CodeFileDocument/CodeFileDocument.swift | 6 + .../LSPContentCoordinator.swift | 55 +++++++ .../Editor/Models/Editor+History.swift | 2 +- CodeEdit/Features/Editor/Models/Editor.swift | 25 ++- .../Editor/Models/EditorInstance.swift | 45 +++-- .../Features/Editor/Views/CodeFileView.swift | 2 +- .../Editor/Views/EditorAreaView.swift | 2 +- .../Extensions/ExtensionDiscovery.swift | 1 - .../LanguageServer+DocumentSync.swift | 113 +++++++------ .../LSP/Service/LSPService+Events.swift | 87 +++++----- .../StatusBarCursorPositionLabel.swift | 154 ++++++++++-------- .../StatusBar/Views/StatusBarView.swift | 4 +- 14 files changed, 308 insertions(+), 230 deletions(-) create mode 100644 CodeEdit/Features/Documents/CodeFileDocument/LSPContentCoordinator.swift diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 418b2a8a1..859de57fb 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 55; + objectVersion = 60; objects = { /* Begin PBXBuildFile section */ @@ -374,6 +374,8 @@ 6C1CC99B2B1E7CBC0002349B /* FindNavigatorIndexBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1CC99A2B1E7CBC0002349B /* FindNavigatorIndexBar.swift */; }; 6C1F3DA22C18C55800F6DEF6 /* ShellIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1F3DA12C18C55800F6DEF6 /* ShellIntegrationTests.swift */; }; 6C23842F2C796B4C003FBDD4 /* GitChangedFileLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C23842E2C796B4C003FBDD4 /* GitChangedFileLabel.swift */; }; + 6C278CC72C93971F0066F6D9 /* LSPContentCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C278CC62C93971F0066F6D9 /* LSPContentCoordinator.swift */; }; + 6C278CCA2C949D4A0066F6D9 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6C278CC92C949D4A0066F6D9 /* CodeEditSourceEditor */; }; 6C2C155829B4F49100EA60A5 /* SplitViewItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C2C155729B4F49100EA60A5 /* SplitViewItem.swift */; }; 6C2C155A29B4F4CC00EA60A5 /* Variadic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C2C155929B4F4CC00EA60A5 /* Variadic.swift */; }; 6C2C155D29B4F4E500EA60A5 /* SplitViewReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C2C155C29B4F4E500EA60A5 /* SplitViewReader.swift */; }; @@ -457,7 +459,6 @@ 6CD26C7B2C8EA8A500ADBA38 /* LSPCache+Data.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD26C792C8EA8A500ADBA38 /* LSPCache+Data.swift */; }; 6CD26C7D2C8EA8F400ADBA38 /* LanguageServer+DocumentSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD26C7C2C8EA8F400ADBA38 /* LanguageServer+DocumentSync.swift */; }; 6CD26C812C8F8A4400ADBA38 /* LanguageIdentifier+CodeLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD26C802C8F8A4400ADBA38 /* LanguageIdentifier+CodeLanguage.swift */; }; - 6CD26C852C8F907800ADBA38 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6CD26C842C8F907800ADBA38 /* CodeEditSourceEditor */; }; 6CD26C872C8F90FD00ADBA38 /* LazyServiceWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD26C862C8F90FD00ADBA38 /* LazyServiceWrapper.swift */; }; 6CD26C8A2C8F91ED00ADBA38 /* LanguageServer+DocumentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD26C892C8F91ED00ADBA38 /* LanguageServer+DocumentTests.swift */; }; 6CD3CA552C8B508200D83DCD /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6CD3CA542C8B508200D83DCD /* CodeEditSourceEditor */; }; @@ -1039,6 +1040,7 @@ 6C1CC99A2B1E7CBC0002349B /* FindNavigatorIndexBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FindNavigatorIndexBar.swift; sourceTree = ""; }; 6C1F3DA12C18C55800F6DEF6 /* ShellIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShellIntegrationTests.swift; sourceTree = ""; }; 6C23842E2C796B4C003FBDD4 /* GitChangedFileLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitChangedFileLabel.swift; sourceTree = ""; }; + 6C278CC62C93971F0066F6D9 /* LSPContentCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LSPContentCoordinator.swift; sourceTree = ""; }; 6C2C155729B4F49100EA60A5 /* SplitViewItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitViewItem.swift; sourceTree = ""; }; 6C2C155929B4F4CC00EA60A5 /* Variadic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Variadic.swift; sourceTree = ""; }; 6C2C155C29B4F4E500EA60A5 /* SplitViewReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitViewReader.swift; sourceTree = ""; }; @@ -1293,7 +1295,7 @@ 6C85BB442C210EFD00EB5DEF /* SwiftUIIntrospect in Frameworks */, 6CB446402B6DFF3A00539ED0 /* CodeEditSourceEditor in Frameworks */, 2816F594280CF50500DD548B /* CodeEditSymbols in Frameworks */, - 6CD26C852C8F907800ADBA38 /* CodeEditSourceEditor in Frameworks */, + 6C278CCA2C949D4A0066F6D9 /* CodeEditSourceEditor in Frameworks */, 30CB64942C16CA9100CC8A9E /* LanguageClient in Frameworks */, 6C6BD6F829CD14D100235D17 /* CodeEditKit in Frameworks */, 6C0824A12C5C0C9700A0751E /* SwiftTerm in Frameworks */, @@ -2978,6 +2980,7 @@ isa = PBXGroup; children = ( 58798249292E78D80085B254 /* CodeFileDocument.swift */, + 6C278CC62C93971F0066F6D9 /* LSPContentCoordinator.swift */, 6C48B5C42C0A2835001E9955 /* FileEncoding.swift */, ); path = CodeFileDocument; @@ -3651,7 +3654,7 @@ 6CE21E862C650D2C0031B056 /* SwiftTerm */, 6C4E37FB2C73E00700AEE7B5 /* SwiftTerm */, 6CD3CA542C8B508200D83DCD /* CodeEditSourceEditor */, - 6CD26C842C8F907800ADBA38 /* CodeEditSourceEditor */, + 6C278CC92C949D4A0066F6D9 /* CodeEditSourceEditor */, ); productName = CodeEdit; productReference = B658FB2C27DA9E0F00EA4DBD /* CodeEdit.app */; @@ -3748,7 +3751,7 @@ 303E88452C276FD100EEA8D9 /* XCRemoteSwiftPackageReference "LanguageClient" */, 303E88462C276FD600EEA8D9 /* XCRemoteSwiftPackageReference "LanguageServerProtocol" */, 6C4E37FA2C73E00700AEE7B5 /* XCRemoteSwiftPackageReference "SwiftTerm" */, - 6CD26C832C8F907800ADBA38 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */, + 6C278CC82C949D4A0066F6D9 /* XCLocalSwiftPackageReference "../CodeEditSourceEditor" */, ); productRefGroup = B658FB2D27DA9E0F00EA4DBD /* Products */; projectDirPath = ""; @@ -4367,6 +4370,7 @@ 6C1CC9982B1E770B0002349B /* AsyncFileIterator.swift in Sources */, 587B9E9029301D8F00AC7927 /* BitBucketTokenRouter.swift in Sources */, B6C6A42E29771A8D00A3D28F /* EditorTabButtonStyle.swift in Sources */, + 6C278CC72C93971F0066F6D9 /* LSPContentCoordinator.swift in Sources */, 58822525292C280D00E83CDE /* StatusBarMenuStyle.swift in Sources */, 6C147C4229A328C10089B630 /* Editor.swift in Sources */, B6C4F2A32B3CA74800B2B140 /* CommitDetailsView.swift in Sources */, @@ -5506,6 +5510,13 @@ }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + 6C278CC82C949D4A0066F6D9 /* XCLocalSwiftPackageReference "../CodeEditSourceEditor" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../CodeEditSourceEditor; + }; +/* End XCLocalSwiftPackageReference section */ + /* Begin XCRemoteSwiftPackageReference section */ 2816F592280CF50500DD548B /* XCRemoteSwiftPackageReference "CodeEditSymbols" */ = { isa = XCRemoteSwiftPackageReference; @@ -5627,14 +5638,6 @@ minimumVersion = 1.2.0; }; }; - 6CD26C832C8F907800ADBA38 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/CodeEditApp/CodeEditSourceEditor"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 0.7.5; - }; - }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -5677,6 +5680,10 @@ package = 6C147C4329A329350089B630 /* XCRemoteSwiftPackageReference "swift-collections" */; productName = OrderedCollections; }; + 6C278CC92C949D4A0066F6D9 /* CodeEditSourceEditor */ = { + isa = XCSwiftPackageProductDependency; + productName = CodeEditSourceEditor; + }; 6C4E37FB2C73E00700AEE7B5 /* SwiftTerm */ = { isa = XCSwiftPackageProductDependency; package = 6C4E37FA2C73E00700AEE7B5 /* XCRemoteSwiftPackageReference "SwiftTerm" */; @@ -5724,11 +5731,6 @@ isa = XCSwiftPackageProductDependency; productName = CodeEditSourceEditor; }; - 6CD26C842C8F907800ADBA38 /* CodeEditSourceEditor */ = { - isa = XCSwiftPackageProductDependency; - package = 6CD26C832C8F907800ADBA38 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */; - productName = CodeEditSourceEditor; - }; 6CD3CA542C8B508200D83DCD /* CodeEditSourceEditor */ = { isa = XCSwiftPackageProductDependency; productName = CodeEditSourceEditor; diff --git a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a97c083c2..6e51cde47 100644 --- a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditSourceEditor", "state" : { - "revision" : "fbabc5933b501adb3940baa26e5a9868342fd6bd", - "version" : "0.7.5" + "revision" : "7d08e741c412b6fd30d5eea8bb6c0580e89553cf", + "version" : "0.8.0" } }, { diff --git a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift index 3b7b415b2..99d098d05 100644 --- a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift +++ b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift @@ -47,6 +47,12 @@ final class CodeFileDocument: NSDocument, ObservableObject { /// See ``CodeEditSourceEditor/CombineCoordinator``. @Published var contentCoordinator: CombineCoordinator = CombineCoordinator() + lazy var lspCoordinator: LSPContentCoordinator = { + let coordinator = LSPContentCoordinator() + coordinator.uri = self.languageServerURI + return coordinator + }() + /// Used to override detected languages. @Published var language: CodeLanguage? diff --git a/CodeEdit/Features/Documents/CodeFileDocument/LSPContentCoordinator.swift b/CodeEdit/Features/Documents/CodeFileDocument/LSPContentCoordinator.swift new file mode 100644 index 000000000..43adda564 --- /dev/null +++ b/CodeEdit/Features/Documents/CodeFileDocument/LSPContentCoordinator.swift @@ -0,0 +1,55 @@ +// +// LSPContentCoordinator.swift +// CodeEdit +// +// Created by Khan Winter on 9/12/24. +// + +import AppKit +import CodeEditSourceEditor +import CodeEditTextView +import LanguageServerProtocol + +extension TextView { + func lspRangeFrom(nsRange: NSRange) -> LSPRange? { + guard let startLine = layoutManager.textLineForOffset(nsRange.location), + let endLine = layoutManager.textLineForOffset(nsRange.max) else { + return nil + } + return LSPRange( + start: Position(line: startLine.index, character: nsRange.location - startLine.range.location), + end: Position(line: endLine.index, character: nsRange.max - endLine.range.location) + ) + } +} + +/// This content coordinator forwards content notifications from the editor's text storage to a language service. +/// +/// This is a text view coordinator so that it can be installed on an open editor. It is kept as a property on +/// ``CodeFileDocument`` since the language server does all it's document management using instances of that type. +class LSPContentCoordinator: TextViewCoordinator, TextViewDelegate { + private var editedRange: LSPRange? + + weak var languageServer: LanguageServer? + var uri: String? + + func prepareCoordinator(controller: TextViewController) { } + + /// We grab the lsp range before the content (and layout) is changed so we get correct line/col info for the + /// language server range. + func textView(_ textView: TextView, willReplaceContentsIn range: NSRange, with string: String) { + self.editedRange = textView.lspRangeFrom(nsRange: range) + } + + func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with string: String) { + guard let uri, + let languageServer = languageServer, + let lspRange = editedRange else { + return + } + self.editedRange = nil + Task.detached { // Detached to get off the main actor ASAP + try await languageServer.documentChanged(uri: uri, replacedContentIn: lspRange, with: string) + } + } +} diff --git a/CodeEdit/Features/Editor/Models/Editor+History.swift b/CodeEdit/Features/Editor/Models/Editor+History.swift index 762711f9a..79412cf86 100644 --- a/CodeEdit/Features/Editor/Models/Editor+History.swift +++ b/CodeEdit/Features/Editor/Models/Editor+History.swift @@ -64,7 +64,7 @@ extension Editor { closeTab(file: temporaryTab.file, fromHistory: true) } temporaryTab = tab - openTab(file: tab.file, fromHistory: true) + openTab(tab: tab, fromHistory: true) } selectedTab = tab } diff --git a/CodeEdit/Features/Editor/Models/Editor.swift b/CodeEdit/Features/Editor/Models/Editor.swift index 801a95569..c0ba95127 100644 --- a/CodeEdit/Features/Editor/Models/Editor.swift +++ b/CodeEdit/Features/Editor/Models/Editor.swift @@ -70,7 +70,7 @@ final class Editor: ObservableObject, Identifiable { ) { self.tabs = [] self.parent = parent - files.forEach { openTab(file: $0) } + files.forEach { openTab(tab: Tab(file: $0)) } self.selectedTab = selectedTab ?? (files.isEmpty ? nil : Tab(file: files.first!)) self.temporaryTab = temporaryTab } @@ -83,7 +83,7 @@ final class Editor: ObservableObject, Identifiable { ) { self.tabs = [] self.parent = parent - files.forEach { openTab(file: $0.file) } + files.forEach { openTab(tab: $0) } self.selectedTab = selectedTab ?? tabs.first self.temporaryTab = temporaryTab } @@ -145,7 +145,7 @@ final class Editor: ObservableObject, Identifiable { /// - file: the file to open. /// - asTemporary: indicates whether the tab should be opened as a temporary tab or a permanent tab. func openTab(file: CEWorkspaceFile, asTemporary: Bool) { - let item = EditorInstance(file: file) + let item = Tab(file: file) // Item is already opened in a tab. guard !tabs.contains(item) || !asTemporary else { selectedTab = item @@ -168,11 +168,11 @@ final class Editor: ObservableObject, Identifiable { temporaryTab = nil case (.none, true): - openTab(file: item.file) + openTab(tab: item) temporaryTab = item case (.none, false): - openTab(file: item.file) + openTab(tab: item) default: break @@ -184,25 +184,24 @@ final class Editor: ObservableObject, Identifiable { /// - file: The tab to open. /// - index: Index where the tab needs to be added. If nil, it is added to the back. /// - fromHistory: Indicates whether the tab has been opened from going back in history. - func openTab(file: CEWorkspaceFile, at index: Int? = nil, fromHistory: Bool = false) { - let item = Tab(file: file) + func openTab(tab: Tab, at index: Int? = nil, fromHistory: Bool = false) { if let index { - tabs.insert(item, at: index) + tabs.insert(tab, at: index) } else { if let selectedTab, let currentIndex = tabs.firstIndex(of: selectedTab) { - tabs.insert(item, at: tabs.index(after: currentIndex)) + tabs.insert(tab, at: tabs.index(after: currentIndex)) } else { - tabs.append(item) + tabs.append(tab) } } - selectedTab = item + selectedTab = tab if !fromHistory { clearFuture() - addToHistory(item) + addToHistory(tab) } do { - try openFile(item: item) + try openFile(item: tab) } catch { print(error) } diff --git a/CodeEdit/Features/Editor/Models/EditorInstance.swift b/CodeEdit/Features/Editor/Models/EditorInstance.swift index f8aeb8ebc..ae71cf062 100644 --- a/CodeEdit/Features/Editor/Models/EditorInstance.swift +++ b/CodeEdit/Features/Editor/Models/EditorInstance.swift @@ -13,39 +13,34 @@ import CodeEditSourceEditor /// A single instance of an editor in a group with a published ``EditorInstance/cursorPositions`` variable to publish /// the user's current location in a file. -class EditorInstance: Hashable { - // Public +/// +/// Use this object instead of a `CEWorkspaceFile` or `CodeFileDocument` when something related to *one* editor needs +/// to happen. For instance, storing the current cursor positions for a single editor. +class EditorInstance: Hashable, ObservableObject { - /// The file presented in this editor instance. + /// The file presented in this editor instance. This is not unique. let file: CEWorkspaceFile - /// A publisher for the user's current location in a file. - var cursorPositions: AnyPublisher<[CursorPosition], Never> { - cursorSubject.eraseToAnyPublisher() - } - - // Public TextViewCoordinator APIs - - var rangeTranslator: RangeTranslator? + @Published var cursorPositions: [CursorPosition] - // Internal Combine subjects - - private let cursorSubject = CurrentValueSubject<[CursorPosition], Never>([]) + lazy var rangeTranslator: RangeTranslator = { + RangeTranslator(parent: self) + }() // MARK: - Init, Hashable, Equatable init(file: CEWorkspaceFile, cursorPositions: [CursorPosition] = []) { self.file = file - self.cursorSubject.send(cursorPositions) - self.rangeTranslator = RangeTranslator(cursorSubject: cursorSubject) + self.cursorPositions = cursorPositions } func hash(into hasher: inout Hasher) { hasher.combine(file) + hasher.combine(cursorPositions) } static func == (lhs: EditorInstance, rhs: EditorInstance) -> Bool { - lhs.file == rhs.file + lhs.file == rhs.file && lhs.cursorPositions == rhs.cursorPositions } // MARK: - RangeTranslator @@ -53,19 +48,19 @@ class EditorInstance: Hashable { /// Translates ranges (eg: from a cursor position) to other information like the number of lines in a range. class RangeTranslator: TextViewCoordinator { private weak var textViewController: TextViewController? - private var cursorSubject: CurrentValueSubject<[CursorPosition], Never> - - init(cursorSubject: CurrentValueSubject<[CursorPosition], Never>) { - self.cursorSubject = cursorSubject - } + private weak var editorInstance: EditorInstance? - func textViewDidChangeSelection(controller: TextViewController, newPositions: [CursorPosition]) { - self.cursorSubject.send(controller.cursorPositions) + fileprivate init(parent: EditorInstance) { + self.editorInstance = parent } func prepareCoordinator(controller: TextViewController) { self.textViewController = controller - self.cursorSubject.send(controller.cursorPositions) + self.editorInstance?.cursorPositions = controller.cursorPositions + } + + func textViewDidChangeSelection(controller: TextViewController, newPositions: [CursorPosition]) { + self.editorInstance?.cursorPositions = newPositions } func destroy() { diff --git a/CodeEdit/Features/Editor/Views/CodeFileView.swift b/CodeEdit/Features/Editor/Views/CodeFileView.swift index e6cccee5c..bac6d1635 100644 --- a/CodeEdit/Features/Editor/Views/CodeFileView.swift +++ b/CodeEdit/Features/Editor/Views/CodeFileView.swift @@ -56,7 +56,7 @@ struct CodeFileView: View { init(codeFile: CodeFileDocument, textViewCoordinators: [TextViewCoordinator] = [], isEditable: Bool = true) { self._codeFile = .init(wrappedValue: codeFile) - self.textViewCoordinators = textViewCoordinators + [codeFile.contentCoordinator] + self.textViewCoordinators = textViewCoordinators + [codeFile.contentCoordinator, codeFile.lspCoordinator] self.isEditable = isEditable if let openOptions = codeFile.openOptions { diff --git a/CodeEdit/Features/Editor/Views/EditorAreaView.swift b/CodeEdit/Features/Editor/Views/EditorAreaView.swift index 11a63f8d0..f0018cd2f 100644 --- a/CodeEdit/Features/Editor/Views/EditorAreaView.swift +++ b/CodeEdit/Features/Editor/Views/EditorAreaView.swift @@ -73,7 +73,7 @@ struct EditorAreaView: View { shouldShowTabBar: shouldShowTabBar ) { [weak editor] newFile in if let file = editor?.selectedTab, let index = editor?.tabs.firstIndex(of: file) { - editor?.openTab(file: newFile, at: index) + editor?.openTab(tab: EditorInstance(file: newFile), at: index) } } .environmentObject(editor) diff --git a/CodeEdit/Features/Extensions/ExtensionDiscovery.swift b/CodeEdit/Features/Extensions/ExtensionDiscovery.swift index 36c28e96d..8418985c7 100644 --- a/CodeEdit/Features/Extensions/ExtensionDiscovery.swift +++ b/CodeEdit/Features/Extensions/ExtensionDiscovery.swift @@ -77,7 +77,6 @@ final class ExtensionDiscovery: ObservableObject { Task { [weak self] in for await availability in AppExtensionIdentity.availabilityUpdates { guard !Task.isCancelled && self != nil else { return } - print(availability) do { if availability.disabledCount > 0 { print("Found \(availability.disabledCount) disabled extensions, trying to activate...") diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift index eefc3d452..fe66a5340 100644 --- a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift +++ b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift @@ -9,40 +9,7 @@ import Foundation import LanguageServerProtocol extension LanguageServer { - // swiftlint:disable line_length - /// Determines the type of document sync the server supports. - /// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_synchronization_sc - fileprivate func serverDocumentSyncSupport() -> TextDocumentSyncKind { - // swiftlint:enable line_length - var syncKind: TextDocumentSyncKind = .none - switch serverCapabilities.textDocumentSync { - case .optionA(let options): - syncKind = options.change ?? .none - case .optionB(let kind): - syncKind = kind - default: - syncKind = .none - } - return syncKind - } - - fileprivate func serverSupportsOpenClose() -> Bool { - switch serverCapabilities.textDocumentSync { - case .optionA(let options): - return options.openClose ?? false - case .optionB: - return true - default: - return true - } - } - - // Avoids a lint error - fileprivate struct DocumentContent { - let uri: String - let language: LanguageIdentifier - let content: String - } + // MARK: - API /// Tells the language server we've opened a document and would like to begin working with it. /// - Parameter document: The code document to open. @@ -59,26 +26,16 @@ extension LanguageServer { uri: content.uri, languageId: content.language, version: 0, - text: content.content + text: content.string ) try await lspInstance.textDocumentDidOpen(DidOpenTextDocumentParams(textDocument: textDocument)) + await setCoordinatorServer(for: document) } catch { logger.warning("addDocument: Error \(error)") throw error } } - /// Helper function for grabbing a document's content from the main actor. - @MainActor - private func getIsolatedDocumentContent(_ document: CodeFileDocument) -> DocumentContent? { - guard let uri = document.languageServerURI, - let language = document.getLanguage().lspLanguage, - let content = document.content?.string else { - return nil - } - return DocumentContent(uri: uri, language: language, content: content) - } - /// Stops tracking a file and notifies the language server. /// - Parameter uri: The URI of the document to close. func closeDocument(_ uri: String) async throws { @@ -109,13 +66,11 @@ extension LanguageServer { logger.debug("Document updated, \(uri, privacy: .private)") switch serverDocumentSyncSupport() { case .full: - guard let file = openFiles.document(for: uri) else { return } - let content = await MainActor.run { - let storage = file.content - return storage?.string + guard let document = openFiles.document(for: uri), + let content = await getIsolatedDocumentContent(document) else { + return } - guard let content else { return } - let changeEvent = TextDocumentContentChangeEvent(range: nil, rangeLength: nil, text: content) + let changeEvent = TextDocumentContentChangeEvent(range: nil, rangeLength: nil, text: content.string) try await lspInstance.textDocumentDidChange( DidChangeTextDocumentParams(uri: uri, version: 0, contentChange: changeEvent) ) @@ -134,4 +89,58 @@ extension LanguageServer { throw error } } + + // MARK: - Helpers + + // swiftlint:disable line_length + /// Determines the type of document sync the server supports. + /// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_synchronization_sc + fileprivate func serverDocumentSyncSupport() -> TextDocumentSyncKind { + // swiftlint:enable line_length + var syncKind: TextDocumentSyncKind = .none + switch serverCapabilities.textDocumentSync { + case .optionA(let options): + syncKind = options.change ?? .none + case .optionB(let kind): + syncKind = kind + default: + syncKind = .none + } + return syncKind + } + + fileprivate func serverSupportsOpenClose() -> Bool { + switch serverCapabilities.textDocumentSync { + case .optionA(let options): + return options.openClose ?? false + case .optionB: + return true + default: + return true + } + } + + // Avoids a lint error + fileprivate struct DocumentContent { + let uri: String + let language: LanguageIdentifier + let string: String + } + + /// Helper function for grabbing a document's content from the main actor. + @MainActor + fileprivate func getIsolatedDocumentContent(_ document: CodeFileDocument) -> DocumentContent? { + guard let uri = document.languageServerURI, + let language = document.getLanguage().lspLanguage, + let content = document.content?.string else { + return nil + } + return DocumentContent(uri: uri, language: language, string: content) + } + + /// Small helper, removed from the main function to make async syntax more straightforward. + @MainActor + fileprivate func setCoordinatorServer(for document: CodeFileDocument) { + document.lspCoordinator.languageServer = self + } } diff --git a/CodeEdit/Features/LSP/Service/LSPService+Events.swift b/CodeEdit/Features/LSP/Service/LSPService+Events.swift index e1b6013c3..c804f6f71 100644 --- a/CodeEdit/Features/LSP/Service/LSPService+Events.swift +++ b/CodeEdit/Features/LSP/Service/LSPService+Events.swift @@ -32,9 +32,10 @@ extension LSPService { } private func handleEvent(_ event: ServerEvent, for key: ClientKey) { + // TODO: Handle Events switch event { case let .request(id, request): - print("Request ID: \(id) for \(key.languageId.rawValue)") +// print("Request ID: \(id) for \(key.languageId.rawValue)") handleRequest(request) case let .notification(notification): handleNotification(notification) @@ -45,49 +46,51 @@ extension LSPService { // swiftlint:disable:next cyclomatic_complexity private func handleRequest(_ request: ServerRequest) { - switch request { - case let .workspaceConfiguration(params, _): - print("workspaceConfiguration: \(params)") - case let .workspaceFolders(handler): - print("workspaceFolders: \(String(describing: handler))") - case let .workspaceApplyEdit(params, _): - print("workspaceApplyEdit: \(params)") - case let .clientRegisterCapability(params, _): - print("clientRegisterCapability: \(params)") - case let .clientUnregisterCapability(params, _): - print("clientUnregisterCapability: \(params)") - case let .workspaceCodeLensRefresh(handler): - print("workspaceCodeLensRefresh: \(String(describing: handler))") - case let .workspaceSemanticTokenRefresh(handler): - print("workspaceSemanticTokenRefresh: \(String(describing: handler))") - case let .windowShowMessageRequest(params, _): - print("windowShowMessageRequest: \(params)") - case let .windowShowDocument(params, _): - print("windowShowDocument: \(params)") - case let .windowWorkDoneProgressCreate(params, _): - print("windowWorkDoneProgressCreate: \(params)") - - default: - print() - } + // TODO: Handle Requests +// switch request { +// case let .workspaceConfiguration(params, _): +// print("workspaceConfiguration: \(params)") +// case let .workspaceFolders(handler): +// print("workspaceFolders: \(String(describing: handler))") +// case let .workspaceApplyEdit(params, _): +// print("workspaceApplyEdit: \(params)") +// case let .clientRegisterCapability(params, _): +// print("clientRegisterCapability: \(params)") +// case let .clientUnregisterCapability(params, _): +// print("clientUnregisterCapability: \(params)") +// case let .workspaceCodeLensRefresh(handler): +// print("workspaceCodeLensRefresh: \(String(describing: handler))") +// case let .workspaceSemanticTokenRefresh(handler): +// print("workspaceSemanticTokenRefresh: \(String(describing: handler))") +// case let .windowShowMessageRequest(params, _): +// print("windowShowMessageRequest: \(params)") +// case let .windowShowDocument(params, _): +// print("windowShowDocument: \(params)") +// case let .windowWorkDoneProgressCreate(params, _): +// print("windowWorkDoneProgressCreate: \(params)") +// +// default: +// print() +// } } private func handleNotification(_ notification: ServerNotification) { - switch notification { - case let .windowLogMessage(params): - print("windowLogMessage \(params.type)\n```\n\(params.message)\n```\n") - case let .windowShowMessage(params): - print("windowShowMessage \(params.type)\n```\n\(params.message)\n```\n") - case let .textDocumentPublishDiagnostics(params): - print("textDocumentPublishDiagnostics: \(params)") - case let .telemetryEvent(params): - print("telemetryEvent: \(params)") - case let .protocolCancelRequest(params): - print("protocolCancelRequest: \(params)") - case let .protocolProgress(params): - print("protocolProgress: \(params)") - case let .protocolLogTrace(params): - print("protocolLogTrace: \(params)") - } + // TODO: Handle Notification +// switch notification { +// case let .windowLogMessage(params): +// print("windowLogMessage \(params.type)\n```\n\(params.message)\n```\n") +// case let .windowShowMessage(params): +// print("windowShowMessage \(params.type)\n```\n\(params.message)\n```\n") +// case let .textDocumentPublishDiagnostics(params): +// print("textDocumentPublishDiagnostics: \(params)") +// case let .telemetryEvent(params): +// print("telemetryEvent: \(params)") +// case let .protocolCancelRequest(params): +// print("protocolCancelRequest: \(params)") +// case let .protocolProgress(params): +// print("protocolProgress: \(params)") +// case let .protocolLogTrace(params): +// print("protocolLogTrace: \(params)") +// } } } diff --git a/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarCursorPositionLabel.swift b/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarCursorPositionLabel.swift index df4cf5228..aeadf518a 100644 --- a/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarCursorPositionLabel.swift +++ b/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarCursorPositionLabel.swift @@ -6,99 +6,36 @@ // import SwiftUI +import Combine import CodeEditSourceEditor struct StatusBarCursorPositionLabel: View { - @Environment(\.controlActiveState) - private var controlActive - @Environment(\.modifierKeys) - private var modifierKeys - - @EnvironmentObject private var statusBarViewModel: StatusBarViewModel @EnvironmentObject private var utilityAreaViewModel: UtilityAreaViewModel @EnvironmentObject private var editorManager: EditorManager @State private var tab: EditorInstance? - @State private var cursorPositions: [CursorPosition]? /// Updates the source of cursor position notifications. func updateSource() { tab = editorManager.activeEditor.selectedTab } - /// Finds the lines contained by a range in the currently selected document. - /// - Parameter range: The range to query. - /// - Returns: The number of lines in the range. - func getLines(_ range: NSRange) -> Int { - return tab?.rangeTranslator?.linesInRange(range) ?? 0 - } - - /// Create a label string for cursor positions. - /// - Parameter cursorPositions: The cursor positions to create the label for. - /// - Returns: A string describing the user's location in a document. - func getLabel(_ cursorPositions: [CursorPosition]) -> String { - if cursorPositions.isEmpty { - return "" - } - - // More than one selection, display the number of selections. - if cursorPositions.count > 1 { - return "\(cursorPositions.count) selected ranges" - } - - // If the selection is more than just a cursor, return the length. - if cursorPositions[0].range.length > 0 { - // When the option key is pressed display the character range. - if modifierKeys.contains(.option) { - return "Char: \(cursorPositions[0].range.location) Len: \(cursorPositions[0].range.length)" - } - - let lineCount = getLines(cursorPositions[0].range) - - if lineCount > 1 { - return "\(lineCount) lines" - } - - return "\(cursorPositions[0].range.length) characters" - } - - // When the option key is pressed display the character offset. - if modifierKeys.contains(.option) { - return "Char: \(cursorPositions[0].range.location) Len: 0" - } - - // When there's a single cursor, display the line and column. - return "Line: \(cursorPositions[0].line) Col: \(cursorPositions[0].column)" - } - var body: some View { Group { if let currentTab = tab { - Group { - if let cursorPositions = cursorPositions { - Text(getLabel(cursorPositions)) - } else { - EmptyView() - } - } - .onReceive(currentTab.cursorPositions) { val in - cursorPositions = val - } + LineLabel(editorInstance: currentTab) } else { - EmptyView() + Text("") + .accessibilityLabel("No Cursor") } } - .font(statusBarViewModel.statusBarFont) - .foregroundColor(foregroundColor) .fixedSize() - .lineLimit(1) + .accessibilityIdentifier("CursorPositionLabel") + .accessibilityAddTraits(.updatesFrequently) .onHover { isHovering($0) } .onAppear { updateSource() } - .onReceive(editorManager.activeEditor.objectWillChange) { _ in - updateSource() - } .onChange(of: editorManager.activeEditor) { _ in updateSource() } @@ -107,7 +44,82 @@ struct StatusBarCursorPositionLabel: View { } } - private var foregroundColor: Color { - controlActive == .inactive ? Color(nsColor: .disabledControlTextColor) : Color(nsColor: .secondaryLabelColor) + struct LineLabel: View { + @Environment(\.modifierKeys) + private var modifierKeys + @Environment(\.controlActiveState) + private var controlActive + + @EnvironmentObject private var statusBarViewModel: StatusBarViewModel + + let editorInstance: EditorInstance + + @State private var cursorPositions: [CursorPosition] = [] + + init(editorInstance: EditorInstance) { + self.editorInstance = editorInstance + } + + var body: some View { + Text(getLabel()) + .font(statusBarViewModel.statusBarFont) + .foregroundColor(foregroundColor) + .lineLimit(1) + .onReceive(editorInstance.$cursorPositions) { newValue in + self.cursorPositions = newValue + } + } + + private var foregroundColor: Color { + if controlActive == .inactive { + Color(nsColor: .disabledControlTextColor) + } else { + Color(nsColor: .secondaryLabelColor) + } + } + + /// Finds the lines contained by a range in the currently selected document. + /// - Parameter range: The range to query. + /// - Returns: The number of lines in the range. + func getLines(_ range: NSRange) -> Int { + return editorInstance.rangeTranslator.linesInRange(range) + } + + /// Create a label string for cursor positions. + /// - Returns: A string describing the user's location in a document. + func getLabel() -> String { + if cursorPositions.isEmpty { + return "" + } + + // More than one selection, display the number of selections. + if cursorPositions.count > 1 { + return "\(cursorPositions.count) selected ranges" + } + + // If the selection is more than just a cursor, return the length. + if cursorPositions[0].range.length > 0 { + // When the option key is pressed display the character range. + if modifierKeys.contains(.option) { + return "Char: \(cursorPositions[0].range.location) Len: \(cursorPositions[0].range.length)" + } + + let lineCount = getLines(cursorPositions[0].range) + + if lineCount > 1 { + return "\(lineCount) lines" + } + + return "\(cursorPositions[0].range.length) characters" + } + + // When the option key is pressed display the character offset. + if modifierKeys.contains(.option) { + return "Char: \(cursorPositions[0].range.location) Len: 0" + } + + // When there's a single cursor, display the line and column. + return "Line: \(cursorPositions[0].line) Col: \(cursorPositions[0].column)" + } } } diff --git a/CodeEdit/Features/StatusBar/Views/StatusBarView.swift b/CodeEdit/Features/StatusBar/Views/StatusBarView.swift index a40f4d953..cb73012d8 100644 --- a/CodeEdit/Features/StatusBar/Views/StatusBarView.swift +++ b/CodeEdit/Features/StatusBar/Views/StatusBarView.swift @@ -38,9 +38,7 @@ struct StatusBarView: View { // StatusBarDivider() Spacer() StatusBarFileInfoView() - HStack(alignment: .center, spacing: 10) { - StatusBarCursorPositionLabel() - } + StatusBarCursorPositionLabel() StatusBarDivider() StatusBarToggleUtilityAreaButton() } From e4fca1a047c27447f6789909beb914e9808ef197 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 16 Sep 2024 09:25:23 -0500 Subject: [PATCH 06/18] Clean Up Documentation --- .../CEWorkspaceFileManager+FileManagement.swift | 3 ++- .../LanguageServer+DocumentSync.swift | 17 ++++++++++------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift index cb21e707e..eac1beda0 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift @@ -46,7 +46,8 @@ extension CEWorkspaceFileManager { /// - file: The file to add the new file to. /// - useExtension: The file extension to use. Leave `nil` to guess using relevant nearby files. /// - Authors: Mattijs Eikelenboom, KaiTheRedNinja. *Moved from 7c27b1e* - /// - Returns: Boolean indicating whether or not the file was created + /// - Throws: Throws a `CocoaError.fileWriteUnknown` with the file url if creating the file fails, and calls + /// ``rebuildFiles(fromItem:deep:)`` which throws other `FileManager` errors. func addFile(fileName: String, toFile file: CEWorkspaceFile, useExtension: String? = nil) throws { // check the folder for other files, and see what the most common file extension is var fileExtension: String diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift index eefc3d452..e78bf7e5b 100644 --- a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift +++ b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift @@ -12,7 +12,7 @@ extension LanguageServer { // swiftlint:disable line_length /// Determines the type of document sync the server supports. /// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_synchronization_sc - fileprivate func serverDocumentSyncSupport() -> TextDocumentSyncKind { + fileprivate func resolveDocumentSyncKind() -> TextDocumentSyncKind { // swiftlint:enable line_length var syncKind: TextDocumentSyncKind = .none switch serverCapabilities.textDocumentSync { @@ -26,7 +26,8 @@ extension LanguageServer { return syncKind } - fileprivate func serverSupportsOpenClose() -> Bool { + /// Determines whether or not the server supports document tracking. + fileprivate func resolveOpenCloseSupport() -> Bool { switch serverCapabilities.textDocumentSync { case .optionA(let options): return options.openClose ?? false @@ -37,7 +38,7 @@ extension LanguageServer { } } - // Avoids a lint error + // Used to avoid a lint error (`large_tuple`) for the return type of `getIsolatedDocumentContent` fileprivate struct DocumentContent { let uri: String let language: LanguageIdentifier @@ -46,9 +47,10 @@ extension LanguageServer { /// Tells the language server we've opened a document and would like to begin working with it. /// - Parameter document: The code document to open. + /// - Throws: Throws errors produced by the language server connection. func openDocument(_ document: CodeFileDocument) async throws { do { - guard serverSupportsOpenClose(), let content = await getIsolatedDocumentContent(document) else { + guard resolveOpenCloseSupport(), let content = await getIsolatedDocumentContent(document) else { return } logger.debug("Opening Document \(content.uri, privacy: .private)") @@ -81,9 +83,10 @@ extension LanguageServer { /// Stops tracking a file and notifies the language server. /// - Parameter uri: The URI of the document to close. + /// - Throws: Throws errors produced by the language server connection. func closeDocument(_ uri: String) async throws { do { - guard serverSupportsOpenClose() && openFiles.document(for: uri) != nil else { return } + guard resolveOpenCloseSupport() && openFiles.document(for: uri) != nil else { return } logger.debug("Closing document \(uri, privacy: .private)") openFiles.removeDocument(for: uri) let params = DidCloseTextDocumentParams(textDocument: TextDocumentIdentifier(uri: uri)) @@ -99,7 +102,7 @@ extension LanguageServer { /// - uri: The URI of the document to update. /// - range: The range being replaced. /// - string: The string being inserted into the replacement range. - /// - Returns: `true` if the document was successfully updated, `false` + /// - Throws: Throws errors produced by the language server connection. func documentChanged( uri: String, replacedContentIn range: LSPRange, @@ -107,7 +110,7 @@ extension LanguageServer { ) async throws { do { logger.debug("Document updated, \(uri, privacy: .private)") - switch serverDocumentSyncSupport() { + switch resolveDocumentSyncKind() { case .full: guard let file = openFiles.document(for: uri) else { return } let content = await MainActor.run { From cc33c64692765fe2bf5d571a003b2c3cbea4676d Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 16 Sep 2024 09:45:14 -0500 Subject: [PATCH 07/18] Fixed Cursor Position Label --- CodeEdit/Features/Editor/Models/Editor.swift | 2 +- CodeEdit/Features/LSP/Service/LSPService+Events.swift | 1 - ...ProjectNavigatorViewController+NSOutlineViewDelegate.swift | 4 +++- .../UtilityArea/TerminalUtility/UtilityAreaTerminalView.swift | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CodeEdit/Features/Editor/Models/Editor.swift b/CodeEdit/Features/Editor/Models/Editor.swift index c0ba95127..1003c8325 100644 --- a/CodeEdit/Features/Editor/Models/Editor.swift +++ b/CodeEdit/Features/Editor/Models/Editor.swift @@ -164,7 +164,7 @@ final class Editor: ObservableObject, Identifiable { temporaryTab = item } - case (.some(let tab), false) where tab == item: + case (.some(let tab), false) where tab.file == item.file: temporaryTab = nil case (.none, true): diff --git a/CodeEdit/Features/LSP/Service/LSPService+Events.swift b/CodeEdit/Features/LSP/Service/LSPService+Events.swift index c804f6f71..15c9703a3 100644 --- a/CodeEdit/Features/LSP/Service/LSPService+Events.swift +++ b/CodeEdit/Features/LSP/Service/LSPService+Events.swift @@ -44,7 +44,6 @@ extension LSPService { } } - // swiftlint:disable:next cyclomatic_complexity private func handleRequest(_ request: ServerRequest) { // TODO: Handle Requests // switch request { diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift index 53d9298a6..beeec7dd6 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift @@ -38,7 +38,9 @@ extension ProjectNavigatorViewController: NSOutlineViewDelegate { if !item.isFolder && shouldSendSelectionUpdate { DispatchQueue.main.async { [weak self] in self?.shouldSendSelectionUpdate = false - self?.workspace?.editorManager?.activeEditor.openTab(file: item, asTemporary: true) + if self?.workspace?.editorManager?.activeEditor.selectedTab?.file != item { + self?.workspace?.editorManager?.activeEditor.openTab(file: item, asTemporary: true) + } self?.shouldSendSelectionUpdate = true } } diff --git a/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalView.swift b/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalView.swift index bce73acc3..9d6aba9d6 100644 --- a/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalView.swift +++ b/CodeEdit/Features/UtilityArea/TerminalUtility/UtilityAreaTerminalView.swift @@ -115,7 +115,7 @@ struct UtilityAreaTerminalView: View { } } ) - .frame(height: constrainedHeight - 1) + .frame(height: max(0, constrainedHeight - 1)) .id(selectedTerminal.id) } } From f7e02538ab882feb3afc05a0523036e9267f7669 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 17 Sep 2024 15:07:39 -0500 Subject: [PATCH 08/18] Fix Subtle FileManager Bug, Fix Duplicate File Reads --- ...EWorkspaceFileManager+FileManagement.swift | 1 - .../Models/CEWorkspaceFileManager.swift | 5 +- .../CodeFileDocument/CodeFileDocument.swift | 13 +++ CodeEdit/Features/Editor/Models/Editor.swift | 1 - .../Editor/Views/EditorAreaView.swift | 25 ++--- .../LSP/Service/LSPService+Events.swift | 104 +++++++++--------- .../Features/LSP/Service/LSPService.swift | 19 +--- .../LSP/LanguageServer+DocumentTests.swift | 7 +- 8 files changed, 85 insertions(+), 90 deletions(-) diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift index eac1beda0..e3469687f 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift @@ -71,7 +71,6 @@ extension CEWorkspaceFileManager { } } - var largestValue = 0 fileExtension = fileExtensions.sorted(by: { $0.value > $1.value }).first?.key ?? "txt" } diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift index 07402f957..4a3a97b83 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift @@ -189,8 +189,9 @@ final class CEWorkspaceFileManager { /// - Parameter file: The parent element. /// - Returns: A child element with an associated parent. func createChild(_ url: URL, forParent file: CEWorkspaceFile) -> CEWorkspaceFile { - let childId = URL(filePath: file.id).appendingPathComponent(url.lastPathComponent).relativePath - let newFileItem = CEWorkspaceFile(id: childId, url: url) + let relativeURL = URL(filePath: file.id).appendingPathComponent(url.lastPathComponent) + let childId = relativeURL.relativePath + let newFileItem = CEWorkspaceFile(id: childId, url: relativeURL) newFileItem.parent = file return newFileItem } diff --git a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift index 3b7b415b2..b5b70f4d6 100644 --- a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift +++ b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift @@ -194,4 +194,17 @@ final class CodeFileDocument: NSDocument, ObservableObject { suffixBuffer: content?.string.getLastLines(5) ) } + + func findWorkspace() -> WorkspaceDocument? { + CodeEditDocumentController.shared.documents.first(where: { doc in + guard let workspace = doc as? WorkspaceDocument, let path = self.languageServerURI else { return false } + // createIfNotFound is safe here because it will still exit if the file and the workspace + // do not share a path prefix + return workspace + .workspaceFileManager? + .getFile(path, createIfNotFound: true)? + .fileDocument? + .isEqual(self) ?? false + }) as? WorkspaceDocument + } } diff --git a/CodeEdit/Features/Editor/Models/Editor.swift b/CodeEdit/Features/Editor/Models/Editor.swift index e8a4e5afb..f22648a6c 100644 --- a/CodeEdit/Features/Editor/Models/Editor.swift +++ b/CodeEdit/Features/Editor/Models/Editor.swift @@ -216,7 +216,6 @@ final class Editor: ObservableObject, Identifiable { let contentType = item.file.resolvedURL.contentType let codeFile = try CodeFileDocument( for: item.file.url, - // TODO: FILE CONTENTS ARE READ MULTIPLE TIMES withContentsOf: item.file.resolvedURL, ofType: contentType?.identifier ?? "" ) diff --git a/CodeEdit/Features/Editor/Views/EditorAreaView.swift b/CodeEdit/Features/Editor/Views/EditorAreaView.swift index a08a0a5d6..9d04ffa93 100644 --- a/CodeEdit/Features/Editor/Views/EditorAreaView.swift +++ b/CodeEdit/Features/Editor/Views/EditorAreaView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import CodeEditTextView struct EditorAreaView: View { @AppSettings(\.general.showEditorPathBar) @@ -25,6 +26,12 @@ struct EditorAreaView: View { @State var codeFile: CodeFileDocument? + init(editor: Editor, focus: FocusState.Binding) { + self.editor = editor + self._focus = focus + self.codeFile = editor.selectedTab?.file.fileDocument + } + var body: some View { var shouldShowTabBar: Bool { return navigationStyle == .openInTabs @@ -54,22 +61,6 @@ struct EditorAreaView: View { .opacity(dimEditorsWithoutFocus && editor != editorManager.activeEditor ? 0.5 : 1) } else { LoadingFileView(selected.file.name) - .task { - do { - let contentType = selected.file.resolvedURL.contentType - let newCodeFile = try CodeFileDocument( - for: selected.file.url, - withContentsOf: selected.file.resolvedURL, - ofType: contentType?.identifier ?? "" - ) - - selected.file.fileDocument = newCodeFile - CodeEditDocumentController.shared.addDocument(newCodeFile) - self.codeFile = newCodeFile - } catch { - print(error.localizedDescription) - } - } } } else { @@ -108,7 +99,7 @@ struct EditorAreaView: View { .background(EffectView(.headerView)) } .focused($focus, equals: editor) - .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("CodeEditor.didBeginEditing"))) { _ in + .onReceive(NotificationCenter.default.publisher(for: TextView.textDidChangeNotification)) { _ in if navigationStyle == .openInTabs { editor.temporaryTab = nil } diff --git a/CodeEdit/Features/LSP/Service/LSPService+Events.swift b/CodeEdit/Features/LSP/Service/LSPService+Events.swift index e1b6013c3..b4baa73bb 100644 --- a/CodeEdit/Features/LSP/Service/LSPService+Events.swift +++ b/CodeEdit/Features/LSP/Service/LSPService+Events.swift @@ -32,62 +32,64 @@ extension LSPService { } private func handleEvent(_ event: ServerEvent, for key: ClientKey) { - switch event { - case let .request(id, request): - print("Request ID: \(id) for \(key.languageId.rawValue)") - handleRequest(request) - case let .notification(notification): - handleNotification(notification) - case let .error(error): - print("Error from EventStream for \(key.languageId.rawValue): \(error)") - } + // TODO: Handle Events +// switch event { +// case let .request(id, request): +// print("Request ID: \(id) for \(key.languageId.rawValue)") +// handleRequest(request) +// case let .notification(notification): +// handleNotification(notification) +// case let .error(error): +// print("Error from EventStream for \(key.languageId.rawValue): \(error)") +// } } - // swiftlint:disable:next cyclomatic_complexity private func handleRequest(_ request: ServerRequest) { - switch request { - case let .workspaceConfiguration(params, _): - print("workspaceConfiguration: \(params)") - case let .workspaceFolders(handler): - print("workspaceFolders: \(String(describing: handler))") - case let .workspaceApplyEdit(params, _): - print("workspaceApplyEdit: \(params)") - case let .clientRegisterCapability(params, _): - print("clientRegisterCapability: \(params)") - case let .clientUnregisterCapability(params, _): - print("clientUnregisterCapability: \(params)") - case let .workspaceCodeLensRefresh(handler): - print("workspaceCodeLensRefresh: \(String(describing: handler))") - case let .workspaceSemanticTokenRefresh(handler): - print("workspaceSemanticTokenRefresh: \(String(describing: handler))") - case let .windowShowMessageRequest(params, _): - print("windowShowMessageRequest: \(params)") - case let .windowShowDocument(params, _): - print("windowShowDocument: \(params)") - case let .windowWorkDoneProgressCreate(params, _): - print("windowWorkDoneProgressCreate: \(params)") - - default: - print() - } + // TODO: Handle Requests +// switch request { +// case let .workspaceConfiguration(params, _): +// print("workspaceConfiguration: \(params)") +// case let .workspaceFolders(handler): +// print("workspaceFolders: \(String(describing: handler))") +// case let .workspaceApplyEdit(params, _): +// print("workspaceApplyEdit: \(params)") +// case let .clientRegisterCapability(params, _): +// print("clientRegisterCapability: \(params)") +// case let .clientUnregisterCapability(params, _): +// print("clientUnregisterCapability: \(params)") +// case let .workspaceCodeLensRefresh(handler): +// print("workspaceCodeLensRefresh: \(String(describing: handler))") +// case let .workspaceSemanticTokenRefresh(handler): +// print("workspaceSemanticTokenRefresh: \(String(describing: handler))") +// case let .windowShowMessageRequest(params, _): +// print("windowShowMessageRequest: \(params)") +// case let .windowShowDocument(params, _): +// print("windowShowDocument: \(params)") +// case let .windowWorkDoneProgressCreate(params, _): +// print("windowWorkDoneProgressCreate: \(params)") +// +// default: +// print() +// } } private func handleNotification(_ notification: ServerNotification) { - switch notification { - case let .windowLogMessage(params): - print("windowLogMessage \(params.type)\n```\n\(params.message)\n```\n") - case let .windowShowMessage(params): - print("windowShowMessage \(params.type)\n```\n\(params.message)\n```\n") - case let .textDocumentPublishDiagnostics(params): - print("textDocumentPublishDiagnostics: \(params)") - case let .telemetryEvent(params): - print("telemetryEvent: \(params)") - case let .protocolCancelRequest(params): - print("protocolCancelRequest: \(params)") - case let .protocolProgress(params): - print("protocolProgress: \(params)") - case let .protocolLogTrace(params): - print("protocolLogTrace: \(params)") - } + // TODO: Handle Notifications +// switch notification { +// case let .windowLogMessage(params): +// print("windowLogMessage \(params.type)\n```\n\(params.message)\n```\n") +// case let .windowShowMessage(params): +// print("windowShowMessage \(params.type)\n```\n\(params.message)\n```\n") +// case let .textDocumentPublishDiagnostics(params): +// print("textDocumentPublishDiagnostics: \(params)") +// case let .telemetryEvent(params): +// print("telemetryEvent: \(params)") +// case let .protocolCancelRequest(params): +// print("protocolCancelRequest: \(params)") +// case let .protocolProgress(params): +// print("protocolProgress: \(params)") +// case let .protocolLogTrace(params): +// print("protocolLogTrace: \(params)") +// } } } diff --git a/CodeEdit/Features/LSP/Service/LSPService.swift b/CodeEdit/Features/LSP/Service/LSPService.swift index 649b32f40..03f2965df 100644 --- a/CodeEdit/Features/LSP/Service/LSPService.swift +++ b/CodeEdit/Features/LSP/Service/LSPService.swift @@ -12,15 +12,6 @@ import LanguageClient import LanguageServerProtocol import CodeEditLanguages -extension CodeFileDocument { - func findWorkspace() -> WorkspaceDocument? { - CodeEditDocumentController.shared.documents.first(where: { doc in - guard let workspace = doc as? WorkspaceDocument, let path = self.languageServerURI else { return false } - return workspace.workspaceFileManager?.getFile(path)?.fileDocument?.isEqual(self) ?? false - }) as? WorkspaceDocument - } -} - /// `LSPService` is a service class responsible for managing the lifecycle and event handling /// of Language Server Protocol (LSP) clients within the CodeEdit application. It handles the initialization, /// communication, and termination of language servers, ensuring that code assistance features @@ -184,12 +175,12 @@ final class LSPService: ObservableObject { /// - Note: Must be invoked after the contents of the file are available. /// - Parameter document: The code document that was opened. func openDocument(_ document: CodeFileDocument) { + guard let workspace = document.findWorkspace(), + let workspacePath = workspace.fileURL?.absoluteURL.path(), + let lspLanguage = document.getLanguage().lspLanguage else { + return + } Task.detached { - guard let workspace = await document.findWorkspace(), - let workspacePath = workspace.fileURL?.absoluteURL.path(), - let lspLanguage = await document.getLanguage().lspLanguage else { - return - } let languageServer: LanguageServer do { if let server = await self.languageClients[ClientKey(lspLanguage, workspacePath)] { diff --git a/CodeEditTests/Features/LSP/LanguageServer+DocumentTests.swift b/CodeEditTests/Features/LSP/LanguageServer+DocumentTests.swift index faafcc270..4b460405f 100644 --- a/CodeEditTests/Features/LSP/LanguageServer+DocumentTests.swift +++ b/CodeEditTests/Features/LSP/LanguageServer+DocumentTests.swift @@ -93,11 +93,10 @@ final class LanguageServerDocumentTests: XCTestCase { } // Create a CodeFileDocument to test with, attach it to the workspace and file - let contentType = try file.url.resourceValues(forKeys: [.contentTypeKey]).contentType let codeFile = try CodeFileDocument( for: file.url, withContentsOf: file.url, - ofType: contentType?.identifier ?? "" + ofType: "public.swift-source" ) file.fileDocument = codeFile @@ -113,7 +112,7 @@ final class LanguageServerDocumentTests: XCTestCase { eventCountExpectation.fulfill() } - await fulfillment(of: [eventCountExpectation], timeout: 5) + await fulfillment(of: [eventCountExpectation], timeout: 2) // This should then trigger a documentDidClose event codeFile.close() @@ -125,7 +124,7 @@ final class LanguageServerDocumentTests: XCTestCase { } eventCloseExpectation.fulfill() } - await fulfillment(of: [eventCloseExpectation], timeout: 5.0) + await fulfillment(of: [eventCloseExpectation], timeout: 2) XCTAssertEqual( connection.clientRequests.map { $0.method }, From 3af037011ab69d1160c04a54e9ed37a405b53c8c Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 17 Sep 2024 15:46:49 -0500 Subject: [PATCH 09/18] Remove code causing a malloc exception, See #1886 --- CodeEdit/Features/Editor/Views/EditorAreaView.swift | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/CodeEdit/Features/Editor/Views/EditorAreaView.swift b/CodeEdit/Features/Editor/Views/EditorAreaView.swift index 9d04ffa93..bc88ad20a 100644 --- a/CodeEdit/Features/Editor/Views/EditorAreaView.swift +++ b/CodeEdit/Features/Editor/Views/EditorAreaView.swift @@ -99,11 +99,12 @@ struct EditorAreaView: View { .background(EffectView(.headerView)) } .focused($focus, equals: editor) - .onReceive(NotificationCenter.default.publisher(for: TextView.textDidChangeNotification)) { _ in - if navigationStyle == .openInTabs { - editor.temporaryTab = nil - } - } + // Fixing this is causing a malloc exception when a file is edited & closed. See #1886 +// .onReceive(NotificationCenter.default.publisher(for: TextView.textDidChangeNotification)) { _ in +// if navigationStyle == .openInTabs { +// editor.temporaryTab = nil +// } +// } .onChange(of: navigationStyle) { newValue in if newValue == .openInPlace && editor.tabs.count == 1 { editor.temporaryTab = editor.tabs[0] From ebcae906366b770c9be0146a8d5deac3fb5b4133 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 17 Sep 2024 15:48:46 -0500 Subject: [PATCH 10/18] Move Coordinator --- CodeEdit.xcodeproj/project.pbxproj | 2 +- .../LanguageServer}/LSPContentCoordinator.swift | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename CodeEdit/Features/{Documents/CodeFileDocument => LSP/LanguageServer}/LSPContentCoordinator.swift (100%) diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 859de57fb..419f83822 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -2980,7 +2980,6 @@ isa = PBXGroup; children = ( 58798249292E78D80085B254 /* CodeFileDocument.swift */, - 6C278CC62C93971F0066F6D9 /* LSPContentCoordinator.swift */, 6C48B5C42C0A2835001E9955 /* FileEncoding.swift */, ); path = CodeFileDocument; @@ -2993,6 +2992,7 @@ 6CD26C6D2C8EA1E600ADBA38 /* LanguageServerFileMap.swift */, 6CD26C782C8EA8A500ADBA38 /* LSPCache.swift */, 6CD26C792C8EA8A500ADBA38 /* LSPCache+Data.swift */, + 6C278CC62C93971F0066F6D9 /* LSPContentCoordinator.swift */, 30B0881E2C12626B0063A882 /* Capabilities */, ); path = LanguageServer; diff --git a/CodeEdit/Features/Documents/CodeFileDocument/LSPContentCoordinator.swift b/CodeEdit/Features/LSP/LanguageServer/LSPContentCoordinator.swift similarity index 100% rename from CodeEdit/Features/Documents/CodeFileDocument/LSPContentCoordinator.swift rename to CodeEdit/Features/LSP/LanguageServer/LSPContentCoordinator.swift From ee18f876f29dfa36f712eb59396fb1cb0044f32e Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 17 Sep 2024 16:08:45 -0500 Subject: [PATCH 11/18] Fix File Opening Bug, Finish Cursor Fix --- .../xcshareddata/swiftpm/Package.resolved | 11 +------ CodeEdit/Features/Editor/Models/Editor.swift | 29 +++++++++++-------- .../Editor/Views/EditorAreaView.swift | 4 ++- .../StatusBarCursorPositionLabel.swift | 8 ++--- 4 files changed, 23 insertions(+), 29 deletions(-) diff --git a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6feda1c49..c1a81a7dc 100644 --- a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "b6e0c892d567c4fb43e4135487752085a69cf403b95ee27c28e9d213dd3bbf5c", + "originHash" : "fed6d2ac4770e7fa9354a624690858c88d25add66264ffe858710496c8ca5e48", "pins" : [ { "identity" : "anycodable", @@ -28,15 +28,6 @@ "version" : "0.1.19" } }, - { - "identity" : "codeeditsourceeditor", - "kind" : "remoteSourceControl", - "location" : "https://github.com/CodeEditApp/CodeEditSourceEditor", - "state" : { - "revision" : "7d08e741c412b6fd30d5eea8bb6c0580e89553cf", - "version" : "0.8.0" - } - }, { "identity" : "codeeditsymbols", "kind" : "remoteSourceControl", diff --git a/CodeEdit/Features/Editor/Models/Editor.swift b/CodeEdit/Features/Editor/Models/Editor.swift index 887771c6b..25f5e3359 100644 --- a/CodeEdit/Features/Editor/Models/Editor.swift +++ b/CodeEdit/Features/Editor/Models/Editor.swift @@ -145,11 +145,11 @@ final class Editor: ObservableObject, Identifiable { /// - file: the file to open. /// - asTemporary: indicates whether the tab should be opened as a temporary tab or a permanent tab. func openTab(file: CEWorkspaceFile, asTemporary: Bool) { - let item = Tab(file: file) + let newTabItem = Tab(file: file) // Item is already opened in a tab. - guard !tabs.contains(item) || !asTemporary else { - selectedTab = item - addToHistory(item) + guard !tabs.contains(newTabItem) || !asTemporary else { + selectedTab = newTabItem + addToHistory(newTabItem) return } @@ -157,22 +157,27 @@ final class Editor: ObservableObject, Identifiable { case (.some(let tab), true): if let index = tabs.firstIndex(of: tab) { clearFuture() - addToHistory(item) + addToHistory(newTabItem) tabs.remove(tab) - tabs.insert(item, at: index) - self.selectedTab = item - temporaryTab = item + tabs.insert(newTabItem, at: index) + self.selectedTab = newTabItem + temporaryTab = newTabItem + do { + try openFile(item: newTabItem) + } catch { + print(error) + } } - case (.some(let tab), false) where tab.file == item.file: + case (.some(let tab), false) where tab.file == newTabItem.file: temporaryTab = nil case (.none, true): - openTab(tab: item) - temporaryTab = item + openTab(tab: newTabItem) + temporaryTab = newTabItem case (.none, false): - openTab(tab: item) + openTab(tab: newTabItem) default: break diff --git a/CodeEdit/Features/Editor/Views/EditorAreaView.swift b/CodeEdit/Features/Editor/Views/EditorAreaView.swift index 32e017c7b..77f143102 100644 --- a/CodeEdit/Features/Editor/Views/EditorAreaView.swift +++ b/CodeEdit/Features/Editor/Views/EditorAreaView.swift @@ -61,8 +61,10 @@ struct EditorAreaView: View { .opacity(dimEditorsWithoutFocus && editor != editorManager.activeEditor ? 0.5 : 1) } else { LoadingFileView(selected.file.name) + .onChange(of: selected.file.fileDocument) { _ in + self.codeFile = selected.file.fileDocument + } } - } else { CEContentUnavailableView("No Editor") .padding(.top, editorInsetAmount) diff --git a/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarCursorPositionLabel.swift b/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarCursorPositionLabel.swift index aeadf518a..bdead53a5 100644 --- a/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarCursorPositionLabel.swift +++ b/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarCursorPositionLabel.swift @@ -25,8 +25,7 @@ struct StatusBarCursorPositionLabel: View { if let currentTab = tab { LineLabel(editorInstance: currentTab) } else { - Text("") - .accessibilityLabel("No Cursor") + Text("").accessibilityLabel("No Selection") } } .fixedSize() @@ -36,10 +35,7 @@ struct StatusBarCursorPositionLabel: View { .onAppear { updateSource() } - .onChange(of: editorManager.activeEditor) { _ in - updateSource() - } - .onChange(of: editorManager.activeEditor.selectedTab) { _ in + .onReceive(editorManager.tabBarTabIdSubject) { _ in updateSource() } } From ecd03c8593ec0281bf734d07496a486e8f9cb744 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 18 Sep 2024 10:12:50 -0500 Subject: [PATCH 12/18] Remove Extra Service --- .../Documents/WorkspaceDocument/WorkspaceDocument.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift index 40f1276f9..6af153fae 100644 --- a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift +++ b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift @@ -13,8 +13,6 @@ import LanguageServerProtocol @objc(WorkspaceDocument) final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate { - @Service var lspService: LSPService - @Published var sortFoldersOnTop: Bool = true private var workspaceState: [String: Any] { From b22aa6d91e60305ea9790df383290aa61fc0bd48 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 18 Sep 2024 10:13:38 -0500 Subject: [PATCH 13/18] Remove Extra `internal` --- CodeEdit/Features/LSP/Service/LSPService.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/CodeEdit/Features/LSP/Service/LSPService.swift b/CodeEdit/Features/LSP/Service/LSPService.swift index 03f2965df..095696bbd 100644 --- a/CodeEdit/Features/LSP/Service/LSPService.swift +++ b/CodeEdit/Features/LSP/Service/LSPService.swift @@ -99,7 +99,7 @@ import CodeEditLanguages /// ``` @MainActor final class LSPService: ObservableObject { - internal let logger: Logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "LSPService") + let logger: Logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "LSPService") struct ClientKey: Hashable, Equatable { let languageId: LanguageIdentifier @@ -112,14 +112,14 @@ final class LSPService: ObservableObject { } /// Holds the active language clients - internal var languageClients: [ClientKey: LanguageServer] = [:] + var languageClients: [ClientKey: LanguageServer] = [:] /// Holds the language server configurations for all the installed language servers - internal var languageConfigs: [LanguageIdentifier: LanguageServerBinary] = [:] + var languageConfigs: [LanguageIdentifier: LanguageServerBinary] = [:] /// Holds all the event listeners for each active language client - internal var eventListeningTasks: [ClientKey: Task] = [:] + var eventListeningTasks: [ClientKey: Task] = [:] @AppSettings(\.developerSettings.lspBinaries) - internal var lspBinaries + var lspBinaries init() { // Load the LSP binaries from the developer menu From 989df27be6135df9b1a12e77150c25a34514b614 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 24 Sep 2024 10:47:10 -0500 Subject: [PATCH 14/18] Throttle Content Updates --- CodeEdit.xcodeproj/project.pbxproj | 63 ++++--- .../xcshareddata/swiftpm/Package.resolved | 22 ++- CodeEdit/AppDelegate.swift | 1 + .../CodeFileDocument/CodeFileDocument.swift | 2 +- .../Features/Editor/Views/CodeFileView.swift | 5 +- .../LanguageServer+DocumentSync.swift | 105 ++++++------ .../LSPContentCoordinator.swift | 58 +++++-- .../LanguageIdentifier+CodeLanguage.swift | 0 .../TextView/TextView+LSPRange.swift | 23 +++ .../LSP/LanguageServer+DocumentTests.swift | 159 +++++++++++++++--- 10 files changed, 328 insertions(+), 110 deletions(-) rename CodeEdit/{Features/LSP/Extensions => Utils/Extensions/LanguageIdentifier}/LanguageIdentifier+CodeLanguage.swift (100%) create mode 100644 CodeEdit/Utils/Extensions/TextView/TextView+LSPRange.swift diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 3849a7fae..efc06b3f4 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 60; + objectVersion = 55; objects = { /* Begin PBXBuildFile section */ @@ -375,7 +375,6 @@ 6C1F3DA22C18C55800F6DEF6 /* ShellIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1F3DA12C18C55800F6DEF6 /* ShellIntegrationTests.swift */; }; 6C23842F2C796B4C003FBDD4 /* GitChangedFileLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C23842E2C796B4C003FBDD4 /* GitChangedFileLabel.swift */; }; 6C278CC72C93971F0066F6D9 /* LSPContentCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C278CC62C93971F0066F6D9 /* LSPContentCoordinator.swift */; }; - 6C278CCA2C949D4A0066F6D9 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6C278CC92C949D4A0066F6D9 /* CodeEditSourceEditor */; }; 6C2C155829B4F49100EA60A5 /* SplitViewItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C2C155729B4F49100EA60A5 /* SplitViewItem.swift */; }; 6C2C155A29B4F4CC00EA60A5 /* Variadic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C2C155929B4F4CC00EA60A5 /* Variadic.swift */; }; 6C2C155D29B4F4E500EA60A5 /* SplitViewReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C2C155C29B4F4E500EA60A5 /* SplitViewReader.swift */; }; @@ -443,6 +442,8 @@ 6CB446402B6DFF3A00539ED0 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6CB4463F2B6DFF3A00539ED0 /* CodeEditSourceEditor */; }; 6CB52DC92AC8DC3E002E75B3 /* CEWorkspaceFileManager+FileManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CB52DC82AC8DC3E002E75B3 /* CEWorkspaceFileManager+FileManagement.swift */; }; 6CB9144B29BEC7F100BC47F2 /* (null) in Sources */ = {isa = PBXBuildFile; }; + 6CB94CFE2C9F1C9A00E8651C /* TextView+LSPRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CB94CFD2C9F1C9A00E8651C /* TextView+LSPRange.swift */; }; + 6CB94D032CA1205100E8651C /* AsyncAlgorithms in Frameworks */ = {isa = PBXBuildFile; productRef = 6CB94D022CA1205100E8651C /* AsyncAlgorithms */; }; 6CBA0D512A1BF524002C6FAA /* SegmentedControlImproved.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CBA0D502A1BF524002C6FAA /* SegmentedControlImproved.swift */; }; 6CBD1BC62978DE53006639D5 /* Font+Caption3.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CBD1BC52978DE53006639D5 /* Font+Caption3.swift */; }; 6CBE1CFB2B71DAA6003AC32E /* Loopable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CBE1CFA2B71DAA6003AC32E /* Loopable.swift */; }; @@ -1100,6 +1101,7 @@ 6CA1AE942B46950000378EAB /* EditorInstance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorInstance.swift; sourceTree = ""; }; 6CABB1A029C5593800340467 /* SearchPanelView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchPanelView.swift; sourceTree = ""; }; 6CB52DC82AC8DC3E002E75B3 /* CEWorkspaceFileManager+FileManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CEWorkspaceFileManager+FileManagement.swift"; sourceTree = ""; }; + 6CB94CFD2C9F1C9A00E8651C /* TextView+LSPRange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TextView+LSPRange.swift"; sourceTree = ""; }; 6CBA0D502A1BF524002C6FAA /* SegmentedControlImproved.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentedControlImproved.swift; sourceTree = ""; }; 6CBD1BC52978DE53006639D5 /* Font+Caption3.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Font+Caption3.swift"; sourceTree = ""; }; 6CBE1CFA2B71DAA6003AC32E /* Loopable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Loopable.swift; sourceTree = ""; }; @@ -1307,6 +1309,7 @@ 6C6BD6F829CD14D100235D17 /* CodeEditKit in Frameworks */, 6C0824A12C5C0C9700A0751E /* SwiftTerm in Frameworks */, 6C81916B29B41DD300B75C92 /* DequeModule in Frameworks */, + 6CB94D032CA1205100E8651C /* AsyncAlgorithms in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1549,7 +1552,6 @@ 30B087FB2C0D53080063A882 /* LSP */ = { isa = PBXGroup; children = ( - 6CD26C822C8F8A5F00ADBA38 /* Extensions */, 6CD26C732C8EA71F00ADBA38 /* LanguageServer */, 6CD26C742C8EA79100ADBA38 /* Service */, 30B087FA2C0D53080063A882 /* LSPUtil.swift */, @@ -2437,9 +2439,11 @@ 5831E3C62933E7E600D5A6D2 /* Color */, 669A504F2C380BFD00304CD8 /* Collection */, 5831E3C82933E80500D5A6D2 /* Date */, + 6CB94D002C9F1CF900E8651C /* LanguageIdentifier */, 6C82D6C429C0129E00495C54 /* NSApplication */, 5831E3D02934036D00D5A6D2 /* NSTableView */, 77A01E922BCA9C0400F0EA38 /* NSWindow */, + 6CB94CFF2C9F1CB600E8651C /* TextView */, 77EF6C042C57DE4B00984B69 /* URL */, 58D01C8B293167DC00C5B6B4 /* String */, 5831E3CB2933E89A00D5A6D2 /* SwiftTerm */, @@ -2963,6 +2967,22 @@ path = WindowCommands; sourceTree = ""; }; + 6CB94CFF2C9F1CB600E8651C /* TextView */ = { + isa = PBXGroup; + children = ( + 6CB94CFD2C9F1C9A00E8651C /* TextView+LSPRange.swift */, + ); + path = TextView; + sourceTree = ""; + }; + 6CB94D002C9F1CF900E8651C /* LanguageIdentifier */ = { + isa = PBXGroup; + children = ( + 6CD26C802C8F8A4400ADBA38 /* LanguageIdentifier+CodeLanguage.swift */, + ); + path = LanguageIdentifier; + sourceTree = ""; + }; 6CBD1BC42978DE3E006639D5 /* Text */ = { isa = PBXGroup; children = ( @@ -3026,14 +3046,6 @@ path = URL; sourceTree = ""; }; - 6CD26C822C8F8A5F00ADBA38 /* Extensions */ = { - isa = PBXGroup; - children = ( - 6CD26C802C8F8A4400ADBA38 /* LanguageIdentifier+CodeLanguage.swift */, - ); - path = Extensions; - sourceTree = ""; - }; 6CD26C882C8F91B600ADBA38 /* LSP */ = { isa = PBXGroup; children = ( @@ -3673,6 +3685,7 @@ 6C4E37FB2C73E00700AEE7B5 /* SwiftTerm */, 6CD3CA542C8B508200D83DCD /* CodeEditSourceEditor */, 6CD26C842C8F907800ADBA38 /* CodeEditSourceEditor */, + 6CB94D022CA1205100E8651C /* AsyncAlgorithms */, ); productName = CodeEdit; productReference = B658FB2C27DA9E0F00EA4DBD /* CodeEdit.app */; @@ -3770,6 +3783,7 @@ 303E88462C276FD600EEA8D9 /* XCRemoteSwiftPackageReference "LanguageServerProtocol" */, 6C4E37FA2C73E00700AEE7B5 /* XCRemoteSwiftPackageReference "SwiftTerm" */, 6CD26C832C8F907800ADBA38 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */, + 6CB94D012CA1205100E8651C /* XCRemoteSwiftPackageReference "swift-async-algorithms" */, ); productRefGroup = B658FB2D27DA9E0F00EA4DBD /* Products */; projectDirPath = ""; @@ -4349,6 +4363,7 @@ 5878DA82291863F900DD95A3 /* AcknowledgementsView.swift in Sources */, 587B9E8529301D8F00AC7927 /* GitHubReview.swift in Sources */, 58D01C9A293167DC00C5B6B4 /* CodeEditKeychain.swift in Sources */, + 6CB94CFE2C9F1C9A00E8651C /* TextView+LSPRange.swift in Sources */, B6966A2E2C3056AD00259C2D /* SourceControlCommands.swift in Sources */, B62AEDAA2A1FCBE5009A9F52 /* AreaTabBar.swift in Sources */, 20D839AB280DEB2900B27357 /* NoSelectionInspectorView.swift in Sources */, @@ -5531,13 +5546,6 @@ }; /* End XCConfigurationList section */ -/* Begin XCLocalSwiftPackageReference section */ - 6C278CC82C949D4A0066F6D9 /* XCLocalSwiftPackageReference "../CodeEditSourceEditor" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = ../CodeEditSourceEditor; - }; -/* End XCLocalSwiftPackageReference section */ - /* Begin XCRemoteSwiftPackageReference section */ 2816F592280CF50500DD548B /* XCRemoteSwiftPackageReference "CodeEditSymbols" */ = { isa = XCRemoteSwiftPackageReference; @@ -5659,12 +5667,20 @@ minimumVersion = 1.2.0; }; }; + 6CB94D012CA1205100E8651C /* XCRemoteSwiftPackageReference "swift-async-algorithms" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-async-algorithms.git"; + requirement = { + kind = exactVersion; + version = 1.0.1; + }; + }; 6CD26C832C8F907800ADBA38 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/CodeEditApp/CodeEditSourceEditor"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 0.8.0; + minimumVersion = 0.8.1; }; }; /* End XCRemoteSwiftPackageReference section */ @@ -5709,10 +5725,6 @@ package = 6C147C4329A329350089B630 /* XCRemoteSwiftPackageReference "swift-collections" */; productName = OrderedCollections; }; - 6C278CC92C949D4A0066F6D9 /* CodeEditSourceEditor */ = { - isa = XCSwiftPackageProductDependency; - productName = CodeEditSourceEditor; - }; 6C4E37FB2C73E00700AEE7B5 /* SwiftTerm */ = { isa = XCSwiftPackageProductDependency; package = 6C4E37FA2C73E00700AEE7B5 /* XCRemoteSwiftPackageReference "SwiftTerm" */; @@ -5756,6 +5768,11 @@ isa = XCSwiftPackageProductDependency; productName = CodeEditSourceEditor; }; + 6CB94D022CA1205100E8651C /* AsyncAlgorithms */ = { + isa = XCSwiftPackageProductDependency; + package = 6CB94D012CA1205100E8651C /* XCRemoteSwiftPackageReference "swift-async-algorithms" */; + productName = AsyncAlgorithms; + }; 6CC17B4E2C432AE000834E2C /* CodeEditSourceEditor */ = { isa = XCSwiftPackageProductDependency; productName = CodeEditSourceEditor; diff --git a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a9a1a81c2..a723cdba1 100644 --- a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "fed6d2ac4770e7fa9354a624690858c88d25add66264ffe858710496c8ca5e48", + "originHash" : "5c4a5d433333474763817b9804d7f1856ab3b416ed87b190a2bd6e86c0c9834c", "pins" : [ { "identity" : "anycodable", @@ -13,7 +13,7 @@ { "identity" : "codeeditkit", "kind" : "remoteSourceControl", - "location" : "https://github.com/CodeEditApp/CodeEditKit", + "location" : "https://github.com/CodeEditApp/CodeEditKit.git", "state" : { "revision" : "ad28213a968586abb0cb21a8a56a3587227895f1", "version" : "0.1.2" @@ -28,6 +28,15 @@ "version" : "0.1.19" } }, + { + "identity" : "codeeditsourceeditor", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CodeEditApp/CodeEditSourceEditor", + "state" : { + "revision" : "033b68d3e3e845984fbc3d405720d5cc6ce61f71", + "version" : "0.8.1" + } + }, { "identity" : "codeeditsymbols", "kind" : "remoteSourceControl", @@ -172,6 +181,15 @@ "version" : "2.3.0" } }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "6ae9a051f76b81cc668305ceed5b0e0a7fd93d20", + "version" : "1.0.1" + } + }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", diff --git a/CodeEdit/AppDelegate.swift b/CodeEdit/AppDelegate.swift index a3ce021f3..c7efe256e 100644 --- a/CodeEdit/AppDelegate.swift +++ b/CodeEdit/AppDelegate.swift @@ -9,6 +9,7 @@ import SwiftUI import CodeEditSymbols import CodeEditSourceEditor + final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { private let updater = SoftwareUpdater() diff --git a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift index 8018705f3..e3afda72c 100644 --- a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift +++ b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift @@ -47,7 +47,7 @@ final class CodeFileDocument: NSDocument, ObservableObject { /// See ``CodeEditSourceEditor/CombineCoordinator``. @Published var contentCoordinator: CombineCoordinator = CombineCoordinator() - lazy var lspCoordinator: LSPContentCoordinator = { + lazy var languageServerCoordinator: LSPContentCoordinator = { let coordinator = LSPContentCoordinator() coordinator.uri = self.languageServerURI return coordinator diff --git a/CodeEdit/Features/Editor/Views/CodeFileView.swift b/CodeEdit/Features/Editor/Views/CodeFileView.swift index bac6d1635..1a0ae19c9 100644 --- a/CodeEdit/Features/Editor/Views/CodeFileView.swift +++ b/CodeEdit/Features/Editor/Views/CodeFileView.swift @@ -56,7 +56,10 @@ struct CodeFileView: View { init(codeFile: CodeFileDocument, textViewCoordinators: [TextViewCoordinator] = [], isEditable: Bool = true) { self._codeFile = .init(wrappedValue: codeFile) - self.textViewCoordinators = textViewCoordinators + [codeFile.contentCoordinator, codeFile.lspCoordinator] + self.textViewCoordinators = textViewCoordinators + [ + codeFile.contentCoordinator, + codeFile.languageServerCoordinator + ] self.isEditable = isEditable if let openOptions = codeFile.openOptions { diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift index 73b13eda8..b61d582c8 100644 --- a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift +++ b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift @@ -9,42 +9,6 @@ import Foundation import LanguageServerProtocol extension LanguageServer { - // swiftlint:disable line_length - /// Determines the type of document sync the server supports. - /// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_synchronization_sc - fileprivate func resolveDocumentSyncKind() -> TextDocumentSyncKind { - // swiftlint:enable line_length - var syncKind: TextDocumentSyncKind = .none - switch serverCapabilities.textDocumentSync { - case .optionA(let options): - syncKind = options.change ?? .none - case .optionB(let kind): - syncKind = kind - default: - syncKind = .none - } - return syncKind - } - - /// Determines whether or not the server supports document tracking. - fileprivate func resolveOpenCloseSupport() -> Bool { - switch serverCapabilities.textDocumentSync { - case .optionA(let options): - return options.openClose ?? false - case .optionB: - return true - default: - return true - } - } - - // Used to avoid a lint error (`large_tuple`) for the return type of `getIsolatedDocumentContent` - fileprivate struct DocumentContent { - let uri: String - let language: LanguageIdentifier - let content: String - } - /// Tells the language server we've opened a document and would like to begin working with it. /// - Parameter document: The code document to open. /// - Throws: Throws errors produced by the language server connection. @@ -61,26 +25,16 @@ extension LanguageServer { uri: content.uri, languageId: content.language, version: 0, - text: content.content + text: content.string ) try await lspInstance.textDocumentDidOpen(DidOpenTextDocumentParams(textDocument: textDocument)) + await updateIsolatedTextCoordinator(for: document) } catch { logger.warning("addDocument: Error \(error)") throw error } } - /// Helper function for grabbing a document's content from the main actor. - @MainActor - private func getIsolatedDocumentContent(_ document: CodeFileDocument) -> DocumentContent? { - guard let uri = document.languageServerURI, - let language = document.getLanguage().lspLanguage, - let content = document.content?.string else { - return nil - } - return DocumentContent(uri: uri, language: language, content: content) - } - /// Stops tracking a file and notifies the language server. /// - Parameter uri: The URI of the document to close. /// - Throws: Throws errors produced by the language server connection. @@ -135,4 +89,59 @@ extension LanguageServer { throw error } } + + // MARK: File Private Helpers + + /// Helper function for grabbing a document's content from the main actor. + @MainActor + private func getIsolatedDocumentContent(_ document: CodeFileDocument) -> DocumentContent? { + guard let uri = document.languageServerURI, + let language = document.getLanguage().lspLanguage, + let content = document.content?.string else { + return nil + } + return DocumentContent(uri: uri, language: language, string: content) + } + + /// Updates the actor-isolated document's text coordinator to map to this server. + @MainActor + fileprivate func updateIsolatedTextCoordinator(for document: CodeFileDocument) { + document.languageServerCoordinator.languageServer = self + } + + // swiftlint:disable line_length + /// Determines the type of document sync the server supports. + /// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_synchronization_sc + fileprivate func resolveDocumentSyncKind() -> TextDocumentSyncKind { + // swiftlint:enable line_length + var syncKind: TextDocumentSyncKind = .none + switch serverCapabilities.textDocumentSync { + case .optionA(let options): // interface TextDocumentSyncOptions + syncKind = options.change ?? .none + case .optionB(let kind): // interface TextDocumentSyncKind + syncKind = kind + default: + syncKind = .none + } + return syncKind + } + + /// Determines whether or not the server supports document tracking. + fileprivate func resolveOpenCloseSupport() -> Bool { + switch serverCapabilities.textDocumentSync { + case .optionA(let options): // interface TextDocumentSyncOptions + return options.openClose ?? false + case .optionB: // interface TextDocumentSyncKind + return true + default: + return true + } + } + + // Used to avoid a lint error (`large_tuple`) for the return type of `getIsolatedDocumentContent` + fileprivate struct DocumentContent { + let uri: String + let language: LanguageIdentifier + let string: String + } } diff --git a/CodeEdit/Features/LSP/LanguageServer/LSPContentCoordinator.swift b/CodeEdit/Features/LSP/LanguageServer/LSPContentCoordinator.swift index 43adda564..947c56ef0 100644 --- a/CodeEdit/Features/LSP/LanguageServer/LSPContentCoordinator.swift +++ b/CodeEdit/Features/LSP/LanguageServer/LSPContentCoordinator.swift @@ -6,33 +6,52 @@ // import AppKit +import AsyncAlgorithms import CodeEditSourceEditor import CodeEditTextView import LanguageServerProtocol -extension TextView { - func lspRangeFrom(nsRange: NSRange) -> LSPRange? { - guard let startLine = layoutManager.textLineForOffset(nsRange.location), - let endLine = layoutManager.textLineForOffset(nsRange.max) else { - return nil - } - return LSPRange( - start: Position(line: startLine.index, character: nsRange.location - startLine.range.location), - end: Position(line: endLine.index, character: nsRange.max - endLine.range.location) - ) - } -} - /// This content coordinator forwards content notifications from the editor's text storage to a language service. /// /// This is a text view coordinator so that it can be installed on an open editor. It is kept as a property on /// ``CodeFileDocument`` since the language server does all it's document management using instances of that type. class LSPContentCoordinator: TextViewCoordinator, TextViewDelegate { + // Required to avoid a large_tuple lint error + private struct SequenceElement: Sendable { + let uri: String + let range: LSPRange + let string: String + } + private var editedRange: LSPRange? + private var sequenceContinuation: AsyncStream.Continuation? weak var languageServer: LanguageServer? var uri: String? + init() { + let stream = AsyncStream { continuation in + self.sequenceContinuation = continuation + } + Task { + for await events in stream._throttle( + for: .milliseconds(100), + reducing: { (accumulation: Array?, event: SequenceElement) in + return (accumulation ?? []) + [event] + } + ) { + print(events) +// Task.detached { +// try await self.languageServer?.documentChanged( +// uri: event.uri, +// replacedContentIn: event.range, +// with: event.string +// ) +// } + } + } + } + func prepareCoordinator(controller: TextViewController) { } /// We grab the lsp range before the content (and layout) is changed so we get correct line/col info for the @@ -48,8 +67,15 @@ class LSPContentCoordinator: TextViewCoordinator, TextViewDelegate { return } self.editedRange = nil - Task.detached { // Detached to get off the main actor ASAP - try await languageServer.documentChanged(uri: uri, replacedContentIn: lspRange, with: string) - } + self.sequenceContinuation?.yield(SequenceElement(uri: uri, range: lspRange, string: string)) + } + + func destroy() { + sequenceContinuation?.finish() + sequenceContinuation = nil + } + + deinit { + destroy() } } diff --git a/CodeEdit/Features/LSP/Extensions/LanguageIdentifier+CodeLanguage.swift b/CodeEdit/Utils/Extensions/LanguageIdentifier/LanguageIdentifier+CodeLanguage.swift similarity index 100% rename from CodeEdit/Features/LSP/Extensions/LanguageIdentifier+CodeLanguage.swift rename to CodeEdit/Utils/Extensions/LanguageIdentifier/LanguageIdentifier+CodeLanguage.swift diff --git a/CodeEdit/Utils/Extensions/TextView/TextView+LSPRange.swift b/CodeEdit/Utils/Extensions/TextView/TextView+LSPRange.swift new file mode 100644 index 000000000..4d7d3858c --- /dev/null +++ b/CodeEdit/Utils/Extensions/TextView/TextView+LSPRange.swift @@ -0,0 +1,23 @@ +// +// TextView+LSPRange.swift +// CodeEdit +// +// Created by Khan Winter on 9/21/24. +// + +import AppKit +import CodeEditTextView +import LanguageServerProtocol + +extension TextView { + func lspRangeFrom(nsRange: NSRange) -> LSPRange? { + guard let startLine = layoutManager.textLineForOffset(nsRange.location), + let endLine = layoutManager.textLineForOffset(nsRange.max) else { + return nil + } + return LSPRange( + start: Position(line: startLine.index, character: nsRange.location - startLine.range.location), + end: Position(line: endLine.index, character: nsRange.max - endLine.range.location) + ) + } +} diff --git a/CodeEditTests/Features/LSP/LanguageServer+DocumentTests.swift b/CodeEditTests/Features/LSP/LanguageServer+DocumentTests.swift index 4b460405f..e9c9bd190 100644 --- a/CodeEditTests/Features/LSP/LanguageServer+DocumentTests.swift +++ b/CodeEditTests/Features/LSP/LanguageServer+DocumentTests.swift @@ -6,6 +6,7 @@ // import XCTest +import CodeEditTextView import LanguageClient import LanguageServerProtocol @@ -22,6 +23,10 @@ final class LanguageServerDocumentTests: XCTestCase { let tempDir = FileManager.default.temporaryDirectory.appending( path: "codeedit-lsp-tests" ) + // Clean up first. + if FileManager.default.fileExists(atPath: tempDir.absoluteURL.path()) { + try FileManager.default.removeItem(at: tempDir) + } try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) tempTestDir = tempDir } catch { @@ -73,8 +78,19 @@ final class LanguageServerDocumentTests: XCTestCase { return (workspace, fileManager) } + func waitForClientEventCount(_ count: Int, connection: BufferingServerConnection, description: String) async { + let expectation = expectation(description: description) + Task.detached { + while connection.clientNotifications.count + connection.clientRequests.count < count { + try await Task.sleep(for: .milliseconds(10)) + } + expectation.fulfill() + } + await fulfillment(of: [expectation], timeout: 2) + } + @MainActor - func testOpenFileInWorkspaceNotifiesLSP() async throws { + func testOpenCloseFileNotifications() async throws { // Set up test server let (connection, server) = try await makeTestServer() @@ -103,28 +119,12 @@ final class LanguageServerDocumentTests: XCTestCase { // This should trigger a documentDidOpen event CodeEditDocumentController.shared.addDocument(codeFile) - let eventCountExpectation = expectation(description: "Pre-close event count") - // Wait off the main actor until we've received all the events - Task.detached { - while connection.clientNotifications.count + connection.clientRequests.count < 3 { - try await Task.sleep(for: .milliseconds(10)) - } - eventCountExpectation.fulfill() - } - - await fulfillment(of: [eventCountExpectation], timeout: 2) + await waitForClientEventCount(3, connection: connection, description: "Pre-close event count") // This should then trigger a documentDidClose event codeFile.close() - let eventCloseExpectation = expectation(description: "Post-close event count") - Task.detached { - while connection.clientNotifications.count + connection.clientRequests.count < 4 { - try await Task.sleep(for: .milliseconds(10)) - } - eventCloseExpectation.fulfill() - } - await fulfillment(of: [eventCloseExpectation], timeout: 2) + await waitForClientEventCount(4, connection: connection, description: "Post-close event count") XCTAssertEqual( connection.clientRequests.map { $0.method }, @@ -142,4 +142,125 @@ final class LanguageServerDocumentTests: XCTestCase { ] ) } + + @MainActor + func testDocumentEditNotificationsFullChanges() async throws { + // Set up a workspace in the temp directory + let (workspace, fileManager) = try makeTestWorkspace() + + // Make our example file + try fileManager.addFile(fileName: "example", toFile: fileManager.workspaceItem, useExtension: "swift") + guard let file = fileManager.childrenOfFile(fileManager.workspaceItem)?.first else { + XCTFail("No File") + return + } + + // Need to test both definitions for server capabilities + let syncOptions: [TwoTypeOption] = [ + .optionA(.init(change: .full)), + .optionB(.full) + ] + + for option in syncOptions { + // Set up test server + let (connection, server) = try await makeTestServer() + + // Create a CodeFileDocument to test with, attach it to the workspace and file + let codeFile = try CodeFileDocument( + for: file.url, + withContentsOf: file.url, + ofType: "public.swift-source" + ) + + // Set up full content changes + server.serverCapabilities = ServerCapabilities() + server.serverCapabilities.textDocumentSync = option + server.openFiles.addDocument(codeFile) + codeFile.languageServerCoordinator.languageServer = server + codeFile.content?.replaceString(in: .zero, with: #"func testFunction() -> String { "Hello " }"#) + + let textView = TextView(string: "") + textView.setTextStorage(codeFile.content!) + textView.delegate = codeFile.languageServerCoordinator + textView.replaceCharacters(in: NSRange(location: 39, length: 0), with: "Worlld") + textView.replaceCharacters(in: NSRange(location: 39, length: 6), with: "") + textView.replaceCharacters(in: NSRange(location: 39, length: 0), with: "World") + + await waitForClientEventCount(3, connection: connection, description: "Edited notification count") + + // Make sure our text view is intact + XCTAssertEqual(textView.string, #"func testFunction() -> String { "Hello World" }"#) + XCTAssertEqual( + connection.clientNotifications.map { $0.method }, + [ + ClientNotification.Method.initialized, + ClientNotification.Method.textDocumentDidChange, + ClientNotification.Method.textDocumentDidChange, + ClientNotification.Method.textDocumentDidChange + ] + ) + + let expectedContentChanges: [String] = [ + #"func testFunction() -> String { "Hello Worlld" }"#, + #"func testFunction() -> String { "Hello " }"#, + #"func testFunction() -> String { "Hello World" }"# + ] + + var foundChangeContents: [String] = [] + + for notification in connection.clientNotifications { + switch notification { + case let .textDocumentDidChange(params): + foundChangeContents.append(contentsOf: params.contentChanges.map { event in + event.text + }) + default: + continue + } + } + + XCTAssertEqual(expectedContentChanges, foundChangeContents) + } + } + + @MainActor + func testDocumentEditNotificationsIncrementalChanges() async throws { + // Set up test server + let (connection, server) = try await makeTestServer() + + // Set up a workspace in the temp directory + let (workspace, fileManager) = try makeTestWorkspace() + + // Make our example file + try fileManager.addFile(fileName: "example", toFile: fileManager.workspaceItem, useExtension: "swift") + guard let file = fileManager.childrenOfFile(fileManager.workspaceItem)?.first else { + XCTFail("No File") + return + } + + // Create a CodeFileDocument to test with, attach it to the workspace and file + let codeFile = try CodeFileDocument( + for: file.url, + withContentsOf: file.url, + ofType: "public.swift-source" + ) + + server.openFiles.addDocument(codeFile) + codeFile.languageServerCoordinator.languageServer = server + + let textView = TextView(string: #"func testFunction() -> String { "Hello " }"#) + textView.delegate = codeFile.languageServerCoordinator + textView.replaceCharacters(in: NSRange(location: 39, length: 0), with: "Worlld") + textView.replaceCharacters(in: NSRange(location: 39, length: 6), with: "0") + textView.replaceCharacters(in: NSRange(location: 39, length: 0), with: "World") + + // Make sure our text view is intact + XCTAssertEqual(textView.string, #"func testFunction() -> String { "Hello World" }"#) + XCTAssertEqual( + connection.clientNotifications, + [ + + ] + ) + } } From 94f1b1ae767b7c54a1a6cdf2e768f8b4655888f1 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 26 Sep 2024 11:18:08 -0500 Subject: [PATCH 15/18] Throttle Edit Notifications, Tests --- .../LanguageServer+DocumentSync.swift | 35 +++-- .../LSPContentCoordinator.swift | 42 +++--- .../LSP/LanguageServer+DocumentTests.swift | 122 +++++++++++------- 3 files changed, 126 insertions(+), 73 deletions(-) diff --git a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift index b61d582c8..dcf12fa83 100644 --- a/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift +++ b/CodeEdit/Features/LSP/LanguageServer/Capabilities/LanguageServer+DocumentSync.swift @@ -51,17 +51,30 @@ extension LanguageServer { } } + /// Represents a single document edit event. + public struct DocumentChange: Sendable { + let range: LSPRange + let string: String + + init(replacingContentsIn range: LSPRange, with string: String) { + self.range = range + self.string = string + } + } + /// Updates the document with the specified URI with new text and increments its version. + /// + /// This API accepts an array of changes to allow for grouping change notifications. + /// This is advantageous for full document changes as we reduce the number of times we send the entire document. + /// It also lowers some communication overhead when sending lots of changes very quickly due to sending them all in + /// one request. + /// /// - Parameters: /// - uri: The URI of the document to update. - /// - range: The range being replaced. - /// - string: The string being inserted into the replacement range. + /// - changes: An array of accumulated changes. It's suggested to throttle change notifications and send them + /// in groups. /// - Throws: Throws errors produced by the language server connection. - func documentChanged( - uri: String, - replacedContentIn range: LSPRange, - with string: String - ) async throws { + func documentChanged(uri: String, changes: [DocumentChange]) async throws { do { logger.debug("Document updated, \(uri, privacy: .private)") switch resolveDocumentSyncKind() { @@ -76,10 +89,12 @@ extension LanguageServer { ) case .incremental: let fileVersion = openFiles.incrementVersion(for: uri) - // rangeLength is depreciated in the LSP spec. - let changeEvent = TextDocumentContentChangeEvent(range: range, rangeLength: nil, text: string) + let changeEvents = changes.map { + // rangeLength is depreciated in the LSP spec. + TextDocumentContentChangeEvent(range: $0.range, rangeLength: nil, text: $0.string) + } try await lspInstance.textDocumentDidChange( - DidChangeTextDocumentParams(uri: uri, version: fileVersion, contentChange: changeEvent) + DidChangeTextDocumentParams(uri: uri, version: fileVersion, contentChanges: changeEvents) ) case .none: return diff --git a/CodeEdit/Features/LSP/LanguageServer/LSPContentCoordinator.swift b/CodeEdit/Features/LSP/LanguageServer/LSPContentCoordinator.swift index 947c56ef0..90194f72f 100644 --- a/CodeEdit/Features/LSP/LanguageServer/LSPContentCoordinator.swift +++ b/CodeEdit/Features/LSP/LanguageServer/LSPContentCoordinator.swift @@ -24,35 +24,42 @@ class LSPContentCoordinator: TextViewCoordinator, TextViewDelegate { } private var editedRange: LSPRange? + private var stream: AsyncStream? private var sequenceContinuation: AsyncStream.Continuation? + private var task: Task? weak var languageServer: LanguageServer? var uri: String? init() { - let stream = AsyncStream { continuation in + self.stream = AsyncStream { continuation in self.sequenceContinuation = continuation } - Task { - for await events in stream._throttle( - for: .milliseconds(100), - reducing: { (accumulation: Array?, event: SequenceElement) in - return (accumulation ?? []) + [event] + } + + func setUpUpdatesTask() { + task?.cancel() + guard let stream else { return } + task = Task { [weak self] in + // Check for editing events every 250 ms + for await events in stream.chunked(by: .repeating(every: .milliseconds(250), clock: .continuous)) { + guard !Task.isCancelled, self != nil else { return } + guard !events.isEmpty, let uri = events.first?.uri else { continue } + Task.detached { [weak self] in + try await self?.languageServer?.documentChanged( + uri: uri, + changes: events.map { + LanguageServer.DocumentChange(replacingContentsIn: $0.range, with: $0.string) + } + ) } - ) { - print(events) -// Task.detached { -// try await self.languageServer?.documentChanged( -// uri: event.uri, -// replacedContentIn: event.range, -// with: event.string -// ) -// } } } } - func prepareCoordinator(controller: TextViewController) { } + func prepareCoordinator(controller: TextViewController) { + setUpUpdatesTask() + } /// We grab the lsp range before the content (and layout) is changed so we get correct line/col info for the /// language server range. @@ -62,7 +69,6 @@ class LSPContentCoordinator: TextViewCoordinator, TextViewDelegate { func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with string: String) { guard let uri, - let languageServer = languageServer, let lspRange = editedRange else { return } @@ -71,6 +77,8 @@ class LSPContentCoordinator: TextViewCoordinator, TextViewDelegate { } func destroy() { + task?.cancel() + task = nil sequenceContinuation?.finish() sequenceContinuation = nil } diff --git a/CodeEditTests/Features/LSP/LanguageServer+DocumentTests.swift b/CodeEditTests/Features/LSP/LanguageServer+DocumentTests.swift index e9c9bd190..5f9aad9aa 100644 --- a/CodeEditTests/Features/LSP/LanguageServer+DocumentTests.swift +++ b/CodeEditTests/Features/LSP/LanguageServer+DocumentTests.swift @@ -7,6 +7,7 @@ import XCTest import CodeEditTextView +import CodeEditSourceEditor import LanguageClient import LanguageServerProtocol @@ -19,6 +20,7 @@ final class LanguageServerDocumentTests: XCTestCase { var tempTestDir: URL! override func setUp() { + continueAfterFailure = false do { let tempDir = FileManager.default.temporaryDirectory.appending( path: "codeedit-lsp-tests" @@ -143,10 +145,28 @@ final class LanguageServerDocumentTests: XCTestCase { ) } + /// Assert the changed contents received by the buffered connection + func assertExpectedContentChanges(connection: BufferingServerConnection, changes: [String]) { + var foundChangeContents: [String] = [] + + for notification in connection.clientNotifications { + switch notification { + case let .textDocumentDidChange(params): + foundChangeContents.append(contentsOf: params.contentChanges.map { event in + event.text + }) + default: + continue + } + } + + XCTAssertEqual(changes, foundChangeContents) + } + @MainActor func testDocumentEditNotificationsFullChanges() async throws { // Set up a workspace in the temp directory - let (workspace, fileManager) = try makeTestWorkspace() + let (_, fileManager) = try makeTestWorkspace() // Make our example file try fileManager.addFile(fileName: "example", toFile: fileManager.workspaceItem, useExtension: "swift") @@ -177,6 +197,7 @@ final class LanguageServerDocumentTests: XCTestCase { server.serverCapabilities.textDocumentSync = option server.openFiles.addDocument(codeFile) codeFile.languageServerCoordinator.languageServer = server + codeFile.languageServerCoordinator.setUpUpdatesTask() codeFile.content?.replaceString(in: .zero, with: #"func testFunction() -> String { "Hello " }"#) let textView = TextView(string: "") @@ -191,35 +212,18 @@ final class LanguageServerDocumentTests: XCTestCase { // Make sure our text view is intact XCTAssertEqual(textView.string, #"func testFunction() -> String { "Hello World" }"#) XCTAssertEqual( - connection.clientNotifications.map { $0.method }, [ ClientNotification.Method.initialized, - ClientNotification.Method.textDocumentDidChange, - ClientNotification.Method.textDocumentDidChange, ClientNotification.Method.textDocumentDidChange - ] + ], + connection.clientNotifications.map { $0.method } ) - let expectedContentChanges: [String] = [ - #"func testFunction() -> String { "Hello Worlld" }"#, - #"func testFunction() -> String { "Hello " }"#, - #"func testFunction() -> String { "Hello World" }"# - ] - - var foundChangeContents: [String] = [] - - for notification in connection.clientNotifications { - switch notification { - case let .textDocumentDidChange(params): - foundChangeContents.append(contentsOf: params.contentChanges.map { event in - event.text - }) - default: - continue - } - } - - XCTAssertEqual(expectedContentChanges, foundChangeContents) + // Expect only one change due to throttling. + assertExpectedContentChanges( + connection: connection, + changes: [#"func testFunction() -> String { "Hello World" }"#] + ) } } @@ -229,7 +233,7 @@ final class LanguageServerDocumentTests: XCTestCase { let (connection, server) = try await makeTestServer() // Set up a workspace in the temp directory - let (workspace, fileManager) = try makeTestWorkspace() + let (_, fileManager) = try makeTestWorkspace() // Make our example file try fileManager.addFile(fileName: "example", toFile: fileManager.workspaceItem, useExtension: "swift") @@ -238,29 +242,55 @@ final class LanguageServerDocumentTests: XCTestCase { return } - // Create a CodeFileDocument to test with, attach it to the workspace and file - let codeFile = try CodeFileDocument( - for: file.url, - withContentsOf: file.url, - ofType: "public.swift-source" - ) + let syncOptions: [TwoTypeOption] = [ + .optionA(.init(change: .incremental)), + .optionB(.incremental) + ] + + for option in syncOptions { + // Set up test server + let (connection, server) = try await makeTestServer() + + // Create a CodeFileDocument to test with, attach it to the workspace and file + let codeFile = try CodeFileDocument( + for: file.url, + withContentsOf: file.url, + ofType: "public.swift-source" + ) - server.openFiles.addDocument(codeFile) - codeFile.languageServerCoordinator.languageServer = server + // Set up full content changes + server.serverCapabilities = ServerCapabilities() + server.serverCapabilities.textDocumentSync = option + server.openFiles.addDocument(codeFile) + codeFile.languageServerCoordinator.languageServer = server + codeFile.languageServerCoordinator.setUpUpdatesTask() + codeFile.content?.replaceString(in: .zero, with: #"func testFunction() -> String { "Hello " }"#) - let textView = TextView(string: #"func testFunction() -> String { "Hello " }"#) - textView.delegate = codeFile.languageServerCoordinator - textView.replaceCharacters(in: NSRange(location: 39, length: 0), with: "Worlld") - textView.replaceCharacters(in: NSRange(location: 39, length: 6), with: "0") - textView.replaceCharacters(in: NSRange(location: 39, length: 0), with: "World") + let textView = TextView(string: "") + textView.setTextStorage(codeFile.content!) + textView.delegate = codeFile.languageServerCoordinator + textView.replaceCharacters(in: NSRange(location: 39, length: 0), with: "Worlld") + textView.replaceCharacters(in: NSRange(location: 39, length: 6), with: "") + textView.replaceCharacters(in: NSRange(location: 39, length: 0), with: "World") - // Make sure our text view is intact - XCTAssertEqual(textView.string, #"func testFunction() -> String { "Hello World" }"#) - XCTAssertEqual( - connection.clientNotifications, - [ + // Throttling means we should receive one edited notification + init notification + init request + await waitForClientEventCount(3, connection: connection, description: "Edited notification count") - ] - ) + // Make sure our text view is intact + XCTAssertEqual(textView.string, #"func testFunction() -> String { "Hello World" }"#) + XCTAssertEqual( + [ + ClientNotification.Method.initialized, + ClientNotification.Method.textDocumentDidChange + ], + connection.clientNotifications.map { $0.method } + ) + + // Expect three content changes. + assertExpectedContentChanges( + connection: connection, + changes: ["Worlld", "", "World"] + ) + } } } From 185dc29acce5adcfb61ce24e08d78cdde337eb77 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 27 Sep 2024 13:50:59 -0500 Subject: [PATCH 16/18] Lint --- CodeEdit/AppDelegate.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/CodeEdit/AppDelegate.swift b/CodeEdit/AppDelegate.swift index c7efe256e..a3ce021f3 100644 --- a/CodeEdit/AppDelegate.swift +++ b/CodeEdit/AppDelegate.swift @@ -9,7 +9,6 @@ import SwiftUI import CodeEditSymbols import CodeEditSourceEditor - final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { private let updater = SoftwareUpdater() From 249f9154b850bb5decca251f3b01d7a46cf7f2b2 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 27 Sep 2024 14:02:34 -0500 Subject: [PATCH 17/18] Documentation --- .../LSPContentCoordinator.swift | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/CodeEdit/Features/LSP/LanguageServer/LSPContentCoordinator.swift b/CodeEdit/Features/LSP/LanguageServer/LSPContentCoordinator.swift index 90194f72f..9c405f64e 100644 --- a/CodeEdit/Features/LSP/LanguageServer/LSPContentCoordinator.swift +++ b/CodeEdit/Features/LSP/LanguageServer/LSPContentCoordinator.swift @@ -15,6 +15,10 @@ import LanguageServerProtocol /// /// This is a text view coordinator so that it can be installed on an open editor. It is kept as a property on /// ``CodeFileDocument`` since the language server does all it's document management using instances of that type. +/// +/// Language servers expect edits to be sent in chunks (and it helps reduce processing overhead). To do this, this class +/// keeps an async stream around for the duration of its lifetime. The stream is sent edit notifications, which are then +/// chunked into 250ms timed groups before being sent to the ``LanguageServer``. class LSPContentCoordinator: TextViewCoordinator, TextViewDelegate { // Required to avoid a large_tuple lint error private struct SequenceElement: Sendable { @@ -40,19 +44,18 @@ class LSPContentCoordinator: TextViewCoordinator, TextViewDelegate { func setUpUpdatesTask() { task?.cancel() guard let stream else { return } - task = Task { [weak self] in - // Check for editing events every 250 ms + task = Task.detached { [weak self] in + // Send edit events every 250ms for await events in stream.chunked(by: .repeating(every: .milliseconds(250), clock: .continuous)) { guard !Task.isCancelled, self != nil else { return } guard !events.isEmpty, let uri = events.first?.uri else { continue } - Task.detached { [weak self] in - try await self?.languageServer?.documentChanged( - uri: uri, - changes: events.map { - LanguageServer.DocumentChange(replacingContentsIn: $0.range, with: $0.string) - } - ) - } + // Errors thrown here are already logged, not much else to do right now. + try? await self?.languageServer?.documentChanged( + uri: uri, + changes: events.map { + LanguageServer.DocumentChange(replacingContentsIn: $0.range, with: $0.string) + } + ) } } } From 7a2f0df9fdafcd6471ccc8dd8ac5e162a6da6747 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 30 Sep 2024 09:23:51 -0500 Subject: [PATCH 18/18] Revert Editor Model Changes --- .../Editor/Models/Editor+History.swift | 2 +- CodeEdit/Features/Editor/Models/Editor.swift | 48 +++++++++---------- .../Editor/Models/EditorInstance.swift | 45 +++++++++-------- .../Editor/Views/EditorAreaView.swift | 3 +- .../StatusBarCursorPositionLabel.swift | 4 +- 5 files changed, 52 insertions(+), 50 deletions(-) diff --git a/CodeEdit/Features/Editor/Models/Editor+History.swift b/CodeEdit/Features/Editor/Models/Editor+History.swift index 79412cf86..762711f9a 100644 --- a/CodeEdit/Features/Editor/Models/Editor+History.swift +++ b/CodeEdit/Features/Editor/Models/Editor+History.swift @@ -64,7 +64,7 @@ extension Editor { closeTab(file: temporaryTab.file, fromHistory: true) } temporaryTab = tab - openTab(tab: tab, fromHistory: true) + openTab(file: tab.file, fromHistory: true) } selectedTab = tab } diff --git a/CodeEdit/Features/Editor/Models/Editor.swift b/CodeEdit/Features/Editor/Models/Editor.swift index 25f5e3359..f22648a6c 100644 --- a/CodeEdit/Features/Editor/Models/Editor.swift +++ b/CodeEdit/Features/Editor/Models/Editor.swift @@ -70,7 +70,7 @@ final class Editor: ObservableObject, Identifiable { ) { self.tabs = [] self.parent = parent - files.forEach { openTab(tab: Tab(file: $0)) } + files.forEach { openTab(file: $0) } self.selectedTab = selectedTab ?? (files.isEmpty ? nil : Tab(file: files.first!)) self.temporaryTab = temporaryTab } @@ -83,7 +83,7 @@ final class Editor: ObservableObject, Identifiable { ) { self.tabs = [] self.parent = parent - files.forEach { openTab(tab: $0) } + files.forEach { openTab(file: $0.file) } self.selectedTab = selectedTab ?? tabs.first self.temporaryTab = temporaryTab } @@ -145,11 +145,11 @@ final class Editor: ObservableObject, Identifiable { /// - file: the file to open. /// - asTemporary: indicates whether the tab should be opened as a temporary tab or a permanent tab. func openTab(file: CEWorkspaceFile, asTemporary: Bool) { - let newTabItem = Tab(file: file) + let item = EditorInstance(file: file) // Item is already opened in a tab. - guard !tabs.contains(newTabItem) || !asTemporary else { - selectedTab = newTabItem - addToHistory(newTabItem) + guard !tabs.contains(item) || !asTemporary else { + selectedTab = item + addToHistory(item) return } @@ -157,27 +157,22 @@ final class Editor: ObservableObject, Identifiable { case (.some(let tab), true): if let index = tabs.firstIndex(of: tab) { clearFuture() - addToHistory(newTabItem) + addToHistory(item) tabs.remove(tab) - tabs.insert(newTabItem, at: index) - self.selectedTab = newTabItem - temporaryTab = newTabItem - do { - try openFile(item: newTabItem) - } catch { - print(error) - } + tabs.insert(item, at: index) + self.selectedTab = item + temporaryTab = item } - case (.some(let tab), false) where tab.file == newTabItem.file: + case (.some(let tab), false) where tab == item: temporaryTab = nil case (.none, true): - openTab(tab: newTabItem) - temporaryTab = newTabItem + openTab(file: item.file) + temporaryTab = item case (.none, false): - openTab(tab: newTabItem) + openTab(file: item.file) default: break @@ -189,24 +184,25 @@ final class Editor: ObservableObject, Identifiable { /// - file: The tab to open. /// - index: Index where the tab needs to be added. If nil, it is added to the back. /// - fromHistory: Indicates whether the tab has been opened from going back in history. - func openTab(tab: Tab, at index: Int? = nil, fromHistory: Bool = false) { + func openTab(file: CEWorkspaceFile, at index: Int? = nil, fromHistory: Bool = false) { + let item = Tab(file: file) if let index { - tabs.insert(tab, at: index) + tabs.insert(item, at: index) } else { if let selectedTab, let currentIndex = tabs.firstIndex(of: selectedTab) { - tabs.insert(tab, at: tabs.index(after: currentIndex)) + tabs.insert(item, at: tabs.index(after: currentIndex)) } else { - tabs.append(tab) + tabs.append(item) } } - selectedTab = tab + selectedTab = item if !fromHistory { clearFuture() - addToHistory(tab) + addToHistory(item) } do { - try openFile(item: tab) + try openFile(item: item) } catch { print(error) } diff --git a/CodeEdit/Features/Editor/Models/EditorInstance.swift b/CodeEdit/Features/Editor/Models/EditorInstance.swift index ae71cf062..f8aeb8ebc 100644 --- a/CodeEdit/Features/Editor/Models/EditorInstance.swift +++ b/CodeEdit/Features/Editor/Models/EditorInstance.swift @@ -13,34 +13,39 @@ import CodeEditSourceEditor /// A single instance of an editor in a group with a published ``EditorInstance/cursorPositions`` variable to publish /// the user's current location in a file. -/// -/// Use this object instead of a `CEWorkspaceFile` or `CodeFileDocument` when something related to *one* editor needs -/// to happen. For instance, storing the current cursor positions for a single editor. -class EditorInstance: Hashable, ObservableObject { +class EditorInstance: Hashable { + // Public - /// The file presented in this editor instance. This is not unique. + /// The file presented in this editor instance. let file: CEWorkspaceFile - @Published var cursorPositions: [CursorPosition] + /// A publisher for the user's current location in a file. + var cursorPositions: AnyPublisher<[CursorPosition], Never> { + cursorSubject.eraseToAnyPublisher() + } + + // Public TextViewCoordinator APIs + + var rangeTranslator: RangeTranslator? - lazy var rangeTranslator: RangeTranslator = { - RangeTranslator(parent: self) - }() + // Internal Combine subjects + + private let cursorSubject = CurrentValueSubject<[CursorPosition], Never>([]) // MARK: - Init, Hashable, Equatable init(file: CEWorkspaceFile, cursorPositions: [CursorPosition] = []) { self.file = file - self.cursorPositions = cursorPositions + self.cursorSubject.send(cursorPositions) + self.rangeTranslator = RangeTranslator(cursorSubject: cursorSubject) } func hash(into hasher: inout Hasher) { hasher.combine(file) - hasher.combine(cursorPositions) } static func == (lhs: EditorInstance, rhs: EditorInstance) -> Bool { - lhs.file == rhs.file && lhs.cursorPositions == rhs.cursorPositions + lhs.file == rhs.file } // MARK: - RangeTranslator @@ -48,19 +53,19 @@ class EditorInstance: Hashable, ObservableObject { /// Translates ranges (eg: from a cursor position) to other information like the number of lines in a range. class RangeTranslator: TextViewCoordinator { private weak var textViewController: TextViewController? - private weak var editorInstance: EditorInstance? + private var cursorSubject: CurrentValueSubject<[CursorPosition], Never> - fileprivate init(parent: EditorInstance) { - self.editorInstance = parent + init(cursorSubject: CurrentValueSubject<[CursorPosition], Never>) { + self.cursorSubject = cursorSubject } - func prepareCoordinator(controller: TextViewController) { - self.textViewController = controller - self.editorInstance?.cursorPositions = controller.cursorPositions + func textViewDidChangeSelection(controller: TextViewController, newPositions: [CursorPosition]) { + self.cursorSubject.send(controller.cursorPositions) } - func textViewDidChangeSelection(controller: TextViewController, newPositions: [CursorPosition]) { - self.editorInstance?.cursorPositions = newPositions + func prepareCoordinator(controller: TextViewController) { + self.textViewController = controller + self.cursorSubject.send(controller.cursorPositions) } func destroy() { diff --git a/CodeEdit/Features/Editor/Views/EditorAreaView.swift b/CodeEdit/Features/Editor/Views/EditorAreaView.swift index 3bd6704af..bc88ad20a 100644 --- a/CodeEdit/Features/Editor/Views/EditorAreaView.swift +++ b/CodeEdit/Features/Editor/Views/EditorAreaView.swift @@ -62,6 +62,7 @@ struct EditorAreaView: View { } else { LoadingFileView(selected.file.name) } + } else { CEContentUnavailableView("No Editor") .padding(.top, editorInsetAmount) @@ -86,7 +87,7 @@ struct EditorAreaView: View { shouldShowTabBar: shouldShowTabBar ) { [weak editor] newFile in if let file = editor?.selectedTab, let index = editor?.tabs.firstIndex(of: file) { - editor?.openTab(tab: EditorInstance(file: newFile), at: index) + editor?.openTab(file: newFile, at: index) } } .environmentObject(editor) diff --git a/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarCursorPositionLabel.swift b/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarCursorPositionLabel.swift index bdead53a5..a47e85176 100644 --- a/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarCursorPositionLabel.swift +++ b/CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarCursorPositionLabel.swift @@ -61,7 +61,7 @@ struct StatusBarCursorPositionLabel: View { .font(statusBarViewModel.statusBarFont) .foregroundColor(foregroundColor) .lineLimit(1) - .onReceive(editorInstance.$cursorPositions) { newValue in + .onReceive(editorInstance.cursorPositions) { newValue in self.cursorPositions = newValue } } @@ -78,7 +78,7 @@ struct StatusBarCursorPositionLabel: View { /// - Parameter range: The range to query. /// - Returns: The number of lines in the range. func getLines(_ range: NSRange) -> Int { - return editorInstance.rangeTranslator.linesInRange(range) + return editorInstance.rangeTranslator?.linesInRange(range) ?? 0 } /// Create a label string for cursor positions.