diff --git a/Assets/SEE/GraphProviders/GraphProviderFactory.cs b/Assets/SEE/GraphProviders/GraphProviderFactory.cs index f51512116e..701243d204 100644 --- a/Assets/SEE/GraphProviders/GraphProviderFactory.cs +++ b/Assets/SEE/GraphProviders/GraphProviderFactory.cs @@ -27,6 +27,7 @@ internal static GraphProvider NewInstance(GraphProviderKind kind) GraphProviderKind.Pipeline => new PipelineGraphProvider(), GraphProviderKind.JaCoCo => new JaCoCoGraphProvider(), GraphProviderKind.MergeDiff => new MergeDiffGraphProvider(), + GraphProviderKind.LSP => new LSPGraphProvider(), _ => throw new NotImplementedException($"Not implemented for {kind}") }; } diff --git a/Assets/SEE/GraphProviders/GraphProviderKind.cs b/Assets/SEE/GraphProviders/GraphProviderKind.cs index aac3aa08dc..2e48565a47 100644 --- a/Assets/SEE/GraphProviders/GraphProviderKind.cs +++ b/Assets/SEE/GraphProviders/GraphProviderKind.cs @@ -36,6 +36,10 @@ public enum GraphProviderKind /// /// For . /// - MergeDiff + MergeDiff, + /// + /// For . + /// + LSP } } diff --git a/Assets/SEE/GraphProviders/LSPGraphProvider.cs b/Assets/SEE/GraphProviders/LSPGraphProvider.cs new file mode 100644 index 0000000000..1cc567866b --- /dev/null +++ b/Assets/SEE/GraphProviders/LSPGraphProvider.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Cysharp.Threading.Tasks; +using OmniSharp.Extensions.LanguageServer.Protocol.Document; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using SEE.DataModel.DG; +using SEE.Game.City; +using SEE.GO; +using SEE.Tools.LSP; +using SEE.UI.RuntimeConfigMenu; +using SEE.Utils.Config; +using SEE.Utils.Paths; +using Sirenix.OdinInspector; +using UnityEngine; +using Debug = UnityEngine.Debug; + +namespace SEE.GraphProviders +{ + /// + /// A graph provider that uses a language server to create a graph. + /// + public class LSPGraphProvider : GraphProvider + { + /// + /// The path to the software project for which the graph shall be generated. + /// + [Tooltip("Path to the project to be analyzed."), RuntimeTab(GraphProviderFoldoutGroup), HideReferenceObjectPicker] + public DirectoryPath ProjectPath = new(); + + /// + /// The language server to be used for the analysis. + /// + [Tooltip("The language server to be used for the analysis."), + RuntimeTab(GraphProviderFoldoutGroup), + HideReferenceObjectPicker, ValueDropdown(nameof(ServerDropdown), ExpandAllMenuItems = true)] + public LSPServer Server = LSPServer.Pyright; + + /// + /// If true, the communication between the language server and SEE will be logged. + /// + [Tooltip("If true, the communication between the language server and SEE will be logged."), RuntimeTab(GraphProviderFoldoutGroup)] + [InfoBox("@\"Logfiles can be found in \" + System.IO.Path.GetTempPath()", InfoMessageType.Info, nameof(LogLSP))] + public bool LogLSP; + + /// + /// Returns the available language servers as a dropdown list, grouped by language. + /// + /// The available language servers as a dropdown list. + private IEnumerable> ServerDropdown() + { + return LSPLanguage.All.Select(language => (language, LSPServer.All.Where(server => server.Languages.Contains(language)))) + .SelectMany(pair => pair.Item2.Select(server => new ValueDropdownItem($"{pair.language.Name}/{server.Name}", server))); + } + + public override GraphProviderKind GetKind() + { + return GraphProviderKind.LSP; + } + + public override async UniTask ProvideAsync(Graph graph, AbstractSEECity city) + { + if (string.IsNullOrEmpty(ProjectPath.Path)) + { + throw new ArgumentException("Empty project path.\n"); + } + if (!Directory.Exists(ProjectPath.Path)) + { + throw new ArgumentException($"Directory {ProjectPath.Path} does not exist.\n"); + } + if (city == null) + { + throw new ArgumentException("The given city is null.\n"); + } + + LSPHandler handler = city.gameObject.AddOrGetComponent(); + handler.enabled = true; + handler.Server = Server; + handler.ProjectPath = ProjectPath.Path; + handler.LogLSP = LogLSP; + if (Application.isPlaying) + { + await handler.WaitUntilReadyAsync(); + } + else + { + // Since OnEnable is not called in the editor, we have to initialize the handler manually. + await handler.InitializeAsync(); + } + + SymbolInformationOrDocumentSymbolContainer result = await handler.Client.RequestDocumentSymbol(new DocumentSymbolParams + { + // TODO: Use root path to query all relevant filetypes. + TextDocument = new TextDocumentIdentifier(Path.Combine(ProjectPath.Path, "src/token/mod.rs")) + }); + foreach (SymbolInformationOrDocumentSymbol symbol in result) + { + if (symbol.IsDocumentSymbolInformation) + { + Debug.LogError("This language server emits SymbolInformation, which is deprecated and not " + + "supported by SEE. Please choose a language server that is capable of returning " + + "hierarchic DocumentSymbols.\n"); + break; + } + + // TODO: Use algorithm 1 from master's thesis. + } + + // We shut down the LSP server for now. If it is needed again, it can still be restarted. + if (Application.isPlaying) + { + handler.enabled = false; + } + else + { + await handler.ShutdownAsync(); + } + return null; + } + + + #region Config I/O + + /// + /// The label for in the configuration file. + /// + private const string pathLabel = "path"; + + /// + /// The label for in the configuration file. + /// + private const string serverLabel = "server"; + + /// + /// The label for in the configuration file. + /// + private const string logLSPLabel = "logLSP"; + + protected override void SaveAttributes(ConfigWriter writer) + { + ProjectPath.Save(writer, pathLabel); + writer.Save(Server.Name, serverLabel); + writer.Save(LogLSP, logLSPLabel); + } + + protected override void RestoreAttributes(Dictionary attributes) + { + ProjectPath.Restore(attributes, pathLabel); + Server = LSPServer.GetByName((string)attributes[serverLabel]); + LogLSP = (bool)attributes[logLSPLabel]; + } + + #endregion + } +} diff --git a/Assets/SEE/GraphProviders/LSPGraphProvider.cs.meta b/Assets/SEE/GraphProviders/LSPGraphProvider.cs.meta new file mode 100644 index 0000000000..fb64352c4e --- /dev/null +++ b/Assets/SEE/GraphProviders/LSPGraphProvider.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 33fdae5bd80b4a2d969f3ffe997cd904 +timeCreated: 1707409534 \ No newline at end of file diff --git a/Assets/SEE/SEE.asmdef b/Assets/SEE/SEE.asmdef index e365b8441b..581d536320 100644 --- a/Assets/SEE/SEE.asmdef +++ b/Assets/SEE/SEE.asmdef @@ -55,7 +55,12 @@ "Newtonsoft.Json.dll", "NetworkCommsDotNet.dll", "LibGit2Sharp.dll", - "CsvHelper.dll" + "CsvHelper.dll", + "OmniSharp.Extensions.LanguageClient.dll", + "OmniSharp.Extensions.LanguageProtocol.dll", + "OmniSharp.Extensions.LanguageServer.Shared.dll", + "OmniSharp.Extensions.JsonRpc.dll", + "System.IO.Pipelines.dll" ], "autoReferenced": false, "defineConstraints": [], diff --git a/Assets/SEE/Tools/LSP.meta b/Assets/SEE/Tools/LSP.meta new file mode 100644 index 0000000000..468c476f6f --- /dev/null +++ b/Assets/SEE/Tools/LSP.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 3e06e7cebf7a49ebabb200dc61e2687b +timeCreated: 1709240001 \ No newline at end of file diff --git a/Assets/SEE/Tools/LSP/LSPHandler.cs b/Assets/SEE/Tools/LSP/LSPHandler.cs new file mode 100644 index 0000000000..41608eb55e --- /dev/null +++ b/Assets/SEE/Tools/LSP/LSPHandler.cs @@ -0,0 +1,231 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Threading; +using Cysharp.Threading.Tasks; +using OmniSharp.Extensions.JsonRpc.Server; +using OmniSharp.Extensions.LanguageServer.Client; +using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; +using OmniSharp.Extensions.LanguageServer.Protocol.General; +using SEE.UI; +using SEE.Utils; +using UnityEngine; + +namespace SEE.Tools.LSP +{ + /// + /// Handles the language server process. + /// + /// This class is responsible for starting and stopping the language server, and is intended + /// to be the primary interface for other classes to communicate with the language server. + /// + public class LSPHandler : MonoBehaviour + { + /// + /// The language server to be used. + /// + private LSPServer server; + + /// + /// The language server to be used. + /// This property has to be set—and can only be set—before the language server is started. + /// + public LSPServer Server + { + get => server; + set + { + if (IsReady) + { + throw new InvalidOperationException("Cannot change the LSP server while it is still running.\n"); + } + server = value; + } + } + + /// + /// The path to the project to be analyzed. + /// + private string projectPath; + + /// + /// The path to the project to be analyzed. + /// + public string ProjectPath + { + get => projectPath; + set + { + if (IsReady) + { + // TODO: Is this true or do we just have to call some LSP method? + throw new InvalidOperationException("Cannot change the project path while the LSP server is still running.\n"); + } + projectPath = value; + } + } + + /// + /// Whether to log the communication between the language server and SEE to a temporary file. + /// + public bool LogLSP { get; set; } + + /// + /// The language client that is used to communicate with the language server. + /// + public LanguageClient Client { get; private set; } + + /// + /// The process that runs the language server. + /// + private Process lspProcess; + + /// + /// The cancellation token source used for asynchronous operations. + /// + private CancellationTokenSource cancellationTokenSource = new(); + + /// + /// The cancellation token used for asynchronous operations. + /// + private CancellationToken cancellationToken => cancellationTokenSource.Token; + + /// + /// A semaphore to ensure that nothing interferes with the language server while it is starting or stopping. + /// + private readonly SemaphoreSlim semaphore = new(1, 1); + + /// + /// Whether the language server is ready to process requests. + /// + public bool IsReady { get; private set; } + + private void OnEnable() + { + InitializeAsync().Forget(); + } + + private void OnDisable() + { + ShutdownAsync().Forget(); + } + + /// + /// Waits asynchronously until the language server is ready to process requests. + /// + public async UniTask WaitUntilReadyAsync() + { + await UniTask.WaitUntil(() => IsReady, cancellationToken: cancellationToken); + } + + /// + /// Initializes the language server such that it is ready to process requests. + /// + public async UniTask InitializeAsync() + { + if (Server == null) + { + throw new InvalidOperationException("LSP server must be set before initializing the handler.\n"); + } + await semaphore.WaitAsync(cancellationToken); + if (IsReady) + { + // LSP server is already running + semaphore.Release(); + return; + } + + IDisposable spinner = LoadingSpinner.Show("Initializing language server..."); + try + { + // TODO: Check for executable (at relevant locations?) first, and if not there, direct users + // for info on how to install it. + ProcessStartInfo startInfo = new(fileName: Server.ServerExecutable, arguments: Server.Parameters) + { + RedirectStandardInput = true, + RedirectStandardOutput = true, + UseShellExecute = false, + }; + lspProcess = Process.Start(startInfo); + if (lspProcess == null) + { + throw new InvalidOperationException("Failed to start the language server.\n"); + } + + Stream outputLog = Stream.Null; + Stream inputLog = Stream.Null; + if (LogLSP) + { + string tempDir = Path.GetTempPath(); + outputLog = new FileStream(Path.Combine(tempDir, "outputLogLsp.txt"), FileMode.Create, FileAccess.Write, FileShare.Read); + inputLog = new FileStream(Path.Combine(tempDir, "inputLogLsp.txt"), FileMode.Create, FileAccess.Write, FileShare.Read); + } + + TeeStream teedInputStream = new(lspProcess.StandardOutput.BaseStream, outputLog); + TeeStream teedOutputStream = new(lspProcess.StandardInput.BaseStream, inputLog); + + // TODO: Add other capabilities here + DocumentSymbolCapability symbolCapabilities = new() + { + HierarchicalDocumentSymbolSupport = true + }; + Client = LanguageClient.Create(options => options.WithInput(teedInputStream) + .WithOutput(teedOutputStream) + // TODO: Path + .WithRootPath(ProjectPath) + // Log output + .WithCapability(symbolCapabilities)); + await Client.Initialize(cancellationToken); + // FIXME: We need to wait a certain amount until the files are indexed. + // Use progress notifications for this instead of this hack. + await UniTask.Delay(TimeSpan.FromSeconds(5), cancellationToken: cancellationToken); + IsReady = true; + } + finally + { + semaphore.Release(); + spinner.Dispose(); + } + } + + /// + /// Shuts down the language server and exits its process. + /// + /// After this method is called, the language server is no longer + /// ready to process requests until it is initialized again. + /// + public async UniTask ShutdownAsync() + { + cancellationTokenSource.Cancel(); + cancellationTokenSource.Dispose(); + cancellationTokenSource = new CancellationTokenSource(); + + await semaphore.WaitAsync(cancellationToken); + if (!IsReady) + { + // LSP server is not running. + return; + } + + IDisposable spinner = LoadingSpinner.Show("Shutting down language server..."); + try + { + await Client.Shutdown(); + } + catch (InvalidParametersException) + { + // Some language servers (e.g., rust-analyzer) have trouble with OmniSharp's empty map. + // They throw an InvalidParameterException, which we can ignore for now. + } + finally + { + // In case Client.SendExit() fails, we release the semaphore and resources first to avoid a deadlock. + IsReady = false; + semaphore.Release(); + spinner.Dispose(); + + Client.SendExit(); + } + } + } +} diff --git a/Assets/SEE/Tools/LSP/LSPHandler.cs.meta b/Assets/SEE/Tools/LSP/LSPHandler.cs.meta new file mode 100644 index 0000000000..ac5a342773 --- /dev/null +++ b/Assets/SEE/Tools/LSP/LSPHandler.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: f337030777824e80b6870782ad1cf4cc +timeCreated: 1709240076 \ No newline at end of file diff --git a/Assets/SEE/Tools/LSP/LSPLanguage.cs b/Assets/SEE/Tools/LSP/LSPLanguage.cs new file mode 100644 index 0000000000..319b50b4fe --- /dev/null +++ b/Assets/SEE/Tools/LSP/LSPLanguage.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; + +namespace SEE.Tools.LSP +{ + /// + /// A programming language supported by a language server. + /// + /// + /// + public record LSPLanguage + { + /// + /// The name of the language. + /// + public string Name { get; } + + /// + /// The file extensions associated with this language. + /// + public ISet Extensions { get; } + + /// + /// Constructor. + /// + /// The name of the language. + /// The file extensions associated with this language. + public LSPLanguage(string name, ISet extensions) + { + Name = name; + Extensions = extensions; + All.Add(this); + } + + public static readonly IList All = new List(); + public static readonly LSPLanguage Rust = new("Rust", new HashSet { "rs" }); + public static readonly LSPLanguage Python = new("Python", new HashSet { "py" }); + } +} diff --git a/Assets/SEE/Tools/LSP/LSPLanguage.cs.meta b/Assets/SEE/Tools/LSP/LSPLanguage.cs.meta new file mode 100644 index 0000000000..697a87e73a --- /dev/null +++ b/Assets/SEE/Tools/LSP/LSPLanguage.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 191787a9ab2b4aa2b87298dabc45201e +timeCreated: 1709415716 \ No newline at end of file diff --git a/Assets/SEE/Tools/LSP/LSPServer.cs b/Assets/SEE/Tools/LSP/LSPServer.cs new file mode 100644 index 0000000000..f9e82f4060 --- /dev/null +++ b/Assets/SEE/Tools/LSP/LSPServer.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; +using System.Linq; + +namespace SEE.Tools.LSP +{ + /// + /// Represents a language server. + /// + /// This record contains all information necessary to start a language server. + /// + /// + public record LSPServer + { + /// + /// The name of the language server. + /// + public string Name { get; } + + /// + /// The languages supported by this language server. + /// + public IList Languages { get; } + + /// + /// The name of the executable of the language server. + /// + public string ServerExecutable { get; } + + /// + /// The parameters with which the should be invoked. + /// + public string Parameters { get; } + + /// + /// Constructor. + /// + /// The name of the language server. + /// The languages supported by this language server. + /// The name of the executable of the language server. + /// The parameters with which the should be invoked. + private LSPServer(string name, IList languages, string serverExecutable, string parameters = "") + { + Name = name; + Languages = languages; + ServerExecutable = serverExecutable; + Parameters = parameters; + All.Add(this); + } + + public override string ToString() + { + return Name; + } + + public static readonly IList All = new List(); + + public static readonly LSPServer RustAnalyzer = new("Rust Analyzer", + new List { LSPLanguage.Rust }, + "rust-analyzer"); + + public static readonly LSPServer Pyright = new("Pyright", + new List { LSPLanguage.Python }, + "pyright-langserver", "--stdio"); + + /// + /// Returns the language server with the given . + /// + /// The name of the language server to be returned. + /// The language server with the given . + public static LSPServer GetByName(string name) + { + return All.First(server => server.Name == name); + } + } +} diff --git a/Assets/SEE/Tools/LSP/LSPServer.cs.meta b/Assets/SEE/Tools/LSP/LSPServer.cs.meta new file mode 100644 index 0000000000..d78bac36af --- /dev/null +++ b/Assets/SEE/Tools/LSP/LSPServer.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 1139437ee46d464d87b0d8e215ad8948 +timeCreated: 1709415508 \ No newline at end of file diff --git a/Assets/SEE/Utils/TeeStream.cs b/Assets/SEE/Utils/TeeStream.cs new file mode 100644 index 0000000000..b726a639de --- /dev/null +++ b/Assets/SEE/Utils/TeeStream.cs @@ -0,0 +1,81 @@ +using System; +using System.IO; + +namespace SEE.Utils +{ + /// + /// A stream that writes to another stream in parallel. + /// This is analogous to the Unix `tee` command. + /// + /// + /// The code for this class has been created with the help of GPT-4. + /// + public class TeeStream : Stream + { + /// + /// The primary stream, which is the stream to read from. + /// + private readonly Stream _primaryStream; + + /// + /// The secondary stream to write the to. + /// + private readonly Stream _secondaryStream; + + /// + /// Creates a new instance of the class. + /// + /// The primary stream to read from. + /// The secondary stream to write to. + /// + /// If or is null. + /// + public TeeStream(Stream primaryStream, Stream secondaryStream) + { + _primaryStream = primaryStream ?? throw new ArgumentNullException(nameof(primaryStream)); + _secondaryStream = secondaryStream ?? throw new ArgumentNullException(nameof(secondaryStream)); + } + + public override bool CanRead => _primaryStream.CanRead; + public override bool CanSeek => _primaryStream.CanSeek; + public override bool CanWrite => _primaryStream.CanWrite; + public override long Length => _primaryStream.Length; + + public override long Position + { + get => _primaryStream.Position; + set => _primaryStream.Position = value; + } + + public override void Flush() + { + _primaryStream.Flush(); + _secondaryStream.Flush(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + int bytesRead = _primaryStream.Read(buffer, offset, count); + _secondaryStream.Write(buffer, offset, bytesRead); + _secondaryStream.Flush(); // Make sure everything is immediately logged + return bytesRead; + } + + public override long Seek(long offset, SeekOrigin origin) + { + return _primaryStream.Seek(offset, origin); + } + + public override void SetLength(long value) + { + _primaryStream.SetLength(value); + } + + public override void Write(byte[] buffer, int offset, int count) + { + _primaryStream.Write(buffer, offset, count); + _secondaryStream.Write(buffer, offset, count); + } + } + +} diff --git a/Assets/SEE/Utils/TeeStream.cs.meta b/Assets/SEE/Utils/TeeStream.cs.meta new file mode 100644 index 0000000000..59eec9d74e --- /dev/null +++ b/Assets/SEE/Utils/TeeStream.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 7ed9acf9951049118be0e8f892f6a8b1 +timeCreated: 1709238615 \ No newline at end of file