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