From 37fdfa7df2e6a1fac741ea8bd90a09b4c21ee9ae Mon Sep 17 00:00:00 2001 From: Matt Parker <61717342+MattParkerDev@users.noreply.github.com> Date: Wed, 22 Oct 2025 18:33:51 +1000 Subject: [PATCH] better code completion apply --- .../Features/Analysis/IdeCompletionService.cs | 17 ++++++++ .../Features/Analysis/RoslynAnalysis.cs | 31 ++++++++++++++ .../FileWatching/FileChangedService.cs | 15 +++++-- .../SolutionDiscovery/SharpIdeFile.cs | 3 +- src/SharpIDE.Godot/DiAutoload.cs | 1 + .../Features/CodeEditor/SharpIdeCodeEdit.cs | 41 +++++++++++++++---- .../SearchInFiles/SearchResultComponent.cs | 2 +- 7 files changed, 97 insertions(+), 13 deletions(-) create mode 100644 src/SharpIDE.Application/Features/Analysis/IdeCompletionService.cs diff --git a/src/SharpIDE.Application/Features/Analysis/IdeCompletionService.cs b/src/SharpIDE.Application/Features/Analysis/IdeCompletionService.cs new file mode 100644 index 0000000..4bd5f6f --- /dev/null +++ b/src/SharpIDE.Application/Features/Analysis/IdeCompletionService.cs @@ -0,0 +1,17 @@ +using Microsoft.CodeAnalysis.Completion; +using SharpIDE.Application.Features.FileWatching; +using SharpIDE.Application.Features.SolutionDiscovery; + +namespace SharpIDE.Application.Features.Analysis; + +public class IdeCompletionService(RoslynAnalysis roslynAnalysis, FileChangedService fileChangedService) +{ + private readonly RoslynAnalysis _roslynAnalysis = roslynAnalysis; + private readonly FileChangedService _fileChangedService = fileChangedService; + + public async Task ApplyCompletion(SharpIdeFile file, CompletionItem completionItem) + { + var (updatedDocumentText, newLinePosition) = await _roslynAnalysis.GetCompletionApplyChanges(file, completionItem); + await _fileChangedService.SharpIdeFileChanged(file, updatedDocumentText, FileChangeType.CompletionChange, newLinePosition); + } +} diff --git a/src/SharpIDE.Application/Features/Analysis/RoslynAnalysis.cs b/src/SharpIDE.Application/Features/Analysis/RoslynAnalysis.cs index 7fd381e..a9ec65e 100644 --- a/src/SharpIDE.Application/Features/Analysis/RoslynAnalysis.cs +++ b/src/SharpIDE.Application/Features/Analysis/RoslynAnalysis.cs @@ -575,6 +575,37 @@ public class RoslynAnalysis return completions; } + public async Task<(string updatedText, SharpIdeFileLinePosition sharpIdeFileLinePosition)> GetCompletionApplyChanges(SharpIdeFile file, CompletionItem completionItem, CancellationToken cancellationToken = default) + { + var documentId = _workspace!.CurrentSolution.GetDocumentIdsWithFilePath(file.Path).Single(); + var document = _workspace.CurrentSolution.GetRequiredDocument(documentId); + var completionService = CompletionService.GetService(document) ?? throw new InvalidOperationException("Completion service is not available for the document."); + + var completionChange = await completionService.GetChangeAsync(document, completionItem, commitCharacter: '.', cancellationToken: cancellationToken); + var sourceText = await document.GetTextAsync(cancellationToken); + var newText = sourceText.WithChanges(completionChange.TextChange); + var newCaretPosition = completionChange.NewPosition ?? NewCaretPosition(); + var linePosition = newText.Lines.GetLinePosition(newCaretPosition); + var sharpIdeFileLinePosition = new SharpIdeFileLinePosition + { + Line = linePosition.Line, + Column = linePosition.Character + }; + + return (newText.ToString(), sharpIdeFileLinePosition); + + int NewCaretPosition() + { + var caretPosition = completionChange.TextChange.Span.Start + completionChange.TextChange.NewText!.Length; + // if change ends with (), place caret between the parentheses + if (completionChange.TextChange.NewText!.EndsWith("()")) + { + caretPosition -= 1; + } + return caretPosition; + } + } + /// Returns the list of files that would be modified by applying the code action. Does not apply the changes to the workspace sln public async Task> GetCodeActionApplyChanges(CodeAction codeAction) { diff --git a/src/SharpIDE.Application/Features/FileWatching/FileChangedService.cs b/src/SharpIDE.Application/Features/FileWatching/FileChangedService.cs index 417c8ad..8388b1f 100644 --- a/src/SharpIDE.Application/Features/FileWatching/FileChangedService.cs +++ b/src/SharpIDE.Application/Features/FileWatching/FileChangedService.cs @@ -13,7 +13,8 @@ public enum FileChangeType IdeSaveToDisk, // Apply to disk IdeUnsavedChange, // Apply only in memory ExternalChange, // Apply to disk, as well as in memory - CodeActionChange // Apply to disk, as well as in memory + CodeActionChange, // Apply to disk, as well as in memory + CompletionChange // Apply only in memory, as well as notify tabs of new content } public class FileChangedService(RoslynAnalysis roslynAnalysis, IdeOpenTabsFileManager openTabsFileManager) @@ -59,7 +60,7 @@ public class FileChangedService(RoslynAnalysis roslynAnalysis, IdeOpenTabsFileMa } // All file changes should go via this service - public async Task SharpIdeFileChanged(SharpIdeFile file, string newContents, FileChangeType changeType) + public async Task SharpIdeFileChanged(SharpIdeFile file, string newContents, FileChangeType changeType, SharpIdeFileLinePosition? linePosition = null) { if (changeType is FileChangeType.ExternalChange) { @@ -67,13 +68,19 @@ public class FileChangedService(RoslynAnalysis roslynAnalysis, IdeOpenTabsFileMa // Update any open tabs // update in memory await _openTabsFileManager.UpdateFileTextInMemory(file, newContents); - file.FileContentsChangedExternally.InvokeParallelFireAndForget(); + file.FileContentsChangedExternally.InvokeParallelFireAndForget(linePosition); } else if (changeType is FileChangeType.CodeActionChange) { // update in memory, tabs and save to disk await _openTabsFileManager.UpdateInMemoryIfOpenAndSaveAsync(file, newContents); - file.FileContentsChangedExternally.InvokeParallelFireAndForget(); + file.FileContentsChangedExternally.InvokeParallelFireAndForget(linePosition); + } + else if (changeType is FileChangeType.CompletionChange) + { + // update in memory, tabs + await _openTabsFileManager.UpdateFileTextInMemory(file, newContents); + file.FileContentsChangedExternally.InvokeParallelFireAndForget(linePosition); } else if (changeType is FileChangeType.IdeSaveToDisk) { diff --git a/src/SharpIDE.Application/Features/SolutionDiscovery/SharpIdeFile.cs b/src/SharpIDE.Application/Features/SolutionDiscovery/SharpIdeFile.cs index c150bdb..1b32d4a 100644 --- a/src/SharpIDE.Application/Features/SolutionDiscovery/SharpIdeFile.cs +++ b/src/SharpIDE.Application/Features/SolutionDiscovery/SharpIdeFile.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using R3; +using SharpIDE.Application.Features.Analysis; using SharpIDE.Application.Features.Events; using SharpIDE.Application.Features.SolutionDiscovery.VsPersistence; @@ -19,7 +20,7 @@ public class SharpIdeFile : ISharpIdeNode, IChildSharpIdeNode, IFileOrFolder public required ReactiveProperty IsDirty { get; init; } public required bool SuppressDiskChangeEvents { get; set; } // probably has concurrency issues public required DateTimeOffset? LastIdeWriteTime { get; set; } - public EventWrapper FileContentsChangedExternally { get; } = new(() => Task.CompletedTask); + public EventWrapper FileContentsChangedExternally { get; } = new((_) => Task.CompletedTask); [SetsRequiredMembers] internal SharpIdeFile(string fullPath, string name, IExpandableSharpIdeNode parent, ConcurrentBag allFiles) diff --git a/src/SharpIDE.Godot/DiAutoload.cs b/src/SharpIDE.Godot/DiAutoload.cs index cf918be..6bd2a7d 100644 --- a/src/SharpIDE.Godot/DiAutoload.cs +++ b/src/SharpIDE.Godot/DiAutoload.cs @@ -26,6 +26,7 @@ public partial class DiAutoload : Node services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/SharpIDE.Godot/Features/CodeEditor/SharpIdeCodeEdit.cs b/src/SharpIDE.Godot/Features/CodeEditor/SharpIdeCodeEdit.cs index b9b0b6b..edc7426 100644 --- a/src/SharpIDE.Godot/Features/CodeEditor/SharpIdeCodeEdit.cs +++ b/src/SharpIDE.Godot/Features/CodeEditor/SharpIdeCodeEdit.cs @@ -1,8 +1,8 @@ using System.Collections.Immutable; using Godot; using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Classification; using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.Completion; using Microsoft.CodeAnalysis.Text; using Roslyn.Utilities; using SharpIDE.Application; @@ -14,6 +14,7 @@ using SharpIDE.Application.Features.FileWatching; using SharpIDE.Application.Features.Run; using SharpIDE.Application.Features.SolutionDiscovery; using SharpIDE.Application.Features.SolutionDiscovery.VsPersistence; +using SharpIDE.Godot.Features.Problems; using SharpIDE.RazorAccess; using Task = System.Threading.Tasks.Task; using Timer = Godot.Timer; @@ -47,6 +48,7 @@ public partial class SharpIdeCodeEdit : CodeEdit [Inject] private readonly RoslynAnalysis _roslynAnalysis = null!; [Inject] private readonly CodeActionService _codeActionService = null!; [Inject] private readonly FileChangedService _fileChangedService = null!; + [Inject] private readonly IdeCompletionService _ideCompletionService = null!; public override void _Ready() { @@ -299,12 +301,12 @@ public partial class SharpIdeCodeEdit : CodeEdit }); } - private async Task OnFileChangedExternally() + private async Task OnFileChangedExternally(SharpIdeFileLinePosition? linePosition) { var fileContents = await _openTabsFileManager.GetFileTextAsync(_currentFile); Callable.From(() => { - var currentCaretPosition = GetCaretPosition(); + (int line, int col) currentCaretPosition = linePosition is null ? GetCaretPosition() : (linePosition.Value.Line, linePosition.Value.Column); var vScroll = GetVScroll(); BeginComplexOperation(); SetText(fileContents); @@ -317,8 +319,8 @@ public partial class SharpIdeCodeEdit : CodeEdit public void SetFileLinePosition(SharpIdeFileLinePosition fileLinePosition) { - var line = fileLinePosition.Line - 1; - var column = fileLinePosition.Column - 1; + var line = fileLinePosition.Line; + var column = fileLinePosition.Column; SetCaretLine(line); SetCaretColumn(column); CenterViewportToCaret(); @@ -498,6 +500,20 @@ public partial class SharpIdeCodeEdit : CodeEdit }); } + public override void _ConfirmCodeCompletion(bool replace) + { + GD.Print("Code completion confirmed"); + var selectedIndex = GetCodeCompletionSelectedIndex(); + var selectedText = GetCodeCompletionOption(selectedIndex); + if (selectedText is null) return; + var completionItem = selectedText["default_value"].As>().Item; + _ = Task.GodotRun(async () => + { + await _ideCompletionService.ApplyCompletion(_currentFile, completionItem); + }); + CancelCodeCompletion(); + } + private void OnCodeCompletionRequested() { var (caretLine, caretColumn) = GetCaretPosition(); @@ -510,9 +526,20 @@ public partial class SharpIdeCodeEdit : CodeEdit var completions = await _roslynAnalysis.GetCodeCompletionsForDocumentAtPosition(_currentFile, linePos); await this.InvokeAsync(() => { - foreach (var completionItem in completions.ItemsList) + foreach (var (index, completionItem) in completions.ItemsList.Take(100).Index()) { - AddCodeCompletionOption(CodeCompletionKind.Class, completionItem.DisplayText, completionItem.DisplayText); + var symbolKindString = CollectionExtensions.GetValueOrDefault(completionItem.Properties, "SymbolKind"); + var symbolKind = symbolKindString is null ? null : (SymbolKind?)int.Parse(symbolKindString); + var godotCompletionType = symbolKind switch + { + SymbolKind.Method => CodeCompletionKind.Function, + SymbolKind.NamedType => CodeCompletionKind.Class, + SymbolKind.Local => CodeCompletionKind.Variable, + SymbolKind.Property => CodeCompletionKind.Member, + SymbolKind.Field => CodeCompletionKind.Member, + _ => CodeCompletionKind.PlainText + }; + AddCodeCompletionOption(godotCompletionType, completionItem.DisplayText, completionItem.DisplayText, value: new RefCountedContainer(completionItem)); } // partially working - displays menu only when caret is what CodeEdit determines as valid UpdateCodeCompletionOptions(true); diff --git a/src/SharpIDE.Godot/Features/Search/SearchInFiles/SearchResultComponent.cs b/src/SharpIDE.Godot/Features/Search/SearchInFiles/SearchResultComponent.cs index cb15b36..dbc3088 100644 --- a/src/SharpIDE.Godot/Features/Search/SearchInFiles/SearchResultComponent.cs +++ b/src/SharpIDE.Godot/Features/Search/SearchInFiles/SearchResultComponent.cs @@ -26,7 +26,7 @@ public partial class SearchResultComponent : MarginContainer private void OnButtonPressed() { - var fileLinePosition = new SharpIdeFileLinePosition { Line = Result.Line, Column = Result.StartColumn }; + var fileLinePosition = new SharpIdeFileLinePosition { Line = Result.Line - 1, Column = Result.StartColumn - 1 }; GodotGlobalEvents.Instance.FileExternallySelected.InvokeParallelFireAndForget(Result.File, fileLinePosition); ParentSearchWindow.Hide(); }