Editor/HotCodeEditor/Navigation/RoslynProject.cs
using Sandbox;
using Editor;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
/// <summary>
/// A Roslyn compilation of the code around a file, for go to definition and autocomplete.
/// s&box doesn't hand out its compilation, so we build our own the same way it does: same
/// sources, parse options and generated global usings, referencing every assembly the editor
/// has loaded plus the compiled output of the project's other compilers.
///
/// Compilations are cached per source folder and only re-parse files that changed, so
/// asking again after a keystroke is cheap.
///
/// Usage: <see cref="TakeSnapshot"/> on the main thread (it reads s&box state), then
/// <see cref="GetCompilation"/> on any thread.
/// </summary>
public static class RoslynProject
{
/// <summary>
/// Everything needed from s&box, captured on the main thread.
/// </summary>
public class Snapshot
{
public string FilePath;
public string SourceRoot;
public string AssemblyName;
public CSharpParseOptions ParseOptions;
public string GeneratedCode;
public List<(string Name, byte[] Image, string XmlDocs)> CompiledReferences = new();
public List<string> IgnoreFolders = new();
public Dictionary<string, string> OpenBuffers = new( StringComparer.OrdinalIgnoreCase );
}
/// <summary>
/// Global usings s&box adds, for when the file isn't part of a loaded compiler.
/// </summary>
const string FallbackGlobalUsings =
"global using System;\n" +
"global using System.Collections.Generic;\n" +
"global using System.Linq;\n" +
"global using System.Threading.Tasks;\n" +
"global using Sandbox;\n" +
"global using static Sandbox.Internal.GlobalGameNamespace;\n";
const string FallbackEditorUsings =
"global using Editor;\n" +
"global using static Sandbox.Internal.GlobalToolsNamespace;\n";
const string GeneratedPath = "<generated>";
// Loading metadata is the slow part, so file references are kept between lookups
private static readonly ConcurrentDictionary<string, (DateTime Stamp, MetadataReference Reference)> FileReferences = new( StringComparer.OrdinalIgnoreCase );
public static Snapshot TakeSnapshot( string filePath, string projectRoot, IEnumerable<(string Path, string Text)> openBuffers )
{
var snapshot = new Snapshot { FilePath = Path.GetFullPath( filePath ) };
foreach ( var (path, text) in openBuffers )
snapshot.OpenBuffers[Path.GetFullPath( path )] = text;
// Which s&box compiler builds this file? The one whose source folder holds it most specifically.
var compilers = AllCompilers().ToList();
Compiler owner = null;
string ownerRoot = null;
foreach ( var compiler in compilers )
{
var root = SourceRootOf( compiler );
if ( root is null || !ProjectFiles.IsUnder( snapshot.FilePath, root ) ) continue;
if ( ownerRoot is null || root.Length > ownerRoot.Length )
{
owner = compiler;
ownerRoot = root;
}
}
if ( owner is not null )
{
var config = owner.GetConfiguration();
snapshot.SourceRoot = ownerRoot;
snapshot.AssemblyName = owner.AssemblyName;
snapshot.ParseOptions = config.GetParseOptions().WithDocumentationMode( DocumentationMode.Parse );
snapshot.GeneratedCode = owner.GeneratedCode?.ToString();
snapshot.IgnoreFolders = config.IgnoreFolders?.ToList() ?? new();
}
else
{
// Not compiled by s&box right now (e.g. a project that isn't loaded): guess the
// source folder from the project layout and use s&box's usual global usings
var relative = Path.GetRelativePath( projectRoot, snapshot.FilePath );
var top = relative.Split( Path.DirectorySeparatorChar )[0];
snapshot.SourceRoot = top == relative ? projectRoot : Path.Combine( projectRoot, top );
snapshot.ParseOptions = new CSharpParseOptions( LanguageVersion.Preview, DocumentationMode.Parse );
var isEditor = top.Equals( "Editor", StringComparison.OrdinalIgnoreCase );
snapshot.GeneratedCode = FallbackGlobalUsings + (isEditor ? FallbackEditorUsings : "");
}
// Other compilers' output stands in for their source (e.g. the game's code, seen from the editor project)
foreach ( var compiler in compilers )
{
if ( compiler == owner ) continue;
var image = compiler.Output?.AssemblyData;
if ( image is { Length: > 0 } ) snapshot.CompiledReferences.Add( (compiler.AssemblyName, image, compiler.Output.XmlDocumentation) );
}
return snapshot;
}
private static IEnumerable<Compiler> AllCompilers()
{
var seen = new HashSet<Compiler>();
if ( Project.CompileGroup is { } group )
foreach ( var c in group.Compilers )
if ( c is not null && seen.Add( c ) ) yield return c;
foreach ( var project in EditorUtility.Projects.GetAll() )
{
var editorCompiler = project.EditorCompiler;
if ( editorCompiler is not null && seen.Add( editorCompiler ) ) yield return editorCompiler;
var compiler = project.Compiler;
if ( compiler is not null && seen.Add( compiler ) ) yield return compiler;
}
}
private static string SourceRootOf( Compiler compiler )
{
try
{
var root = compiler.FileSystem?.GetFullPath( "/" );
return string.IsNullOrEmpty( root ) || !Directory.Exists( root ) ? null : Path.GetFullPath( root ).TrimEnd( '\\', '/' );
}
catch ( Exception )
{
return null;
}
}
// --- CACHED COMPILATIONS ---
private class CachedFile
{
public DateTime Stamp;
public string Buffer;
public SyntaxTree Tree;
}
private class Cache
{
public string AssemblyName;
public CSharpParseOptions ParseOptions;
public string GeneratedCode;
public List<byte[]> CompiledImages;
public CSharpCompilation Compilation;
public readonly Dictionary<string, CachedFile> Files = new( StringComparer.OrdinalIgnoreCase );
}
private static readonly object CacheLock = new();
private static readonly Dictionary<string, Cache> Caches = new( StringComparer.OrdinalIgnoreCase );
/// <summary>
/// The compilation for the snapshot's source folder, brought up to date, and the syntax tree of
/// the snapshot's file (null if that file isn't part of it). Safe to call off the main thread.
/// </summary>
public static (CSharpCompilation Compilation, SyntaxTree Tree) GetCompilation( Snapshot snapshot )
{
lock ( CacheLock )
{
if ( !Caches.TryGetValue( snapshot.SourceRoot, out var cache ) || !SameSetup( cache, snapshot ) )
{
cache = Create( snapshot );
Caches[snapshot.SourceRoot] = cache;
}
else if ( !cache.CompiledImages.SequenceEqual( snapshot.CompiledReferences.Select( x => x.Image ) ) )
{
// Another compiler rebuilt (hotload): swap in its new output
cache.CompiledImages = snapshot.CompiledReferences.Select( x => x.Image ).ToList();
cache.Compilation = cache.Compilation.WithReferences( References( snapshot ) );
}
Update( cache, snapshot );
cache.Files.TryGetValue( snapshot.FilePath, out var target );
return (cache.Compilation, target?.Tree);
}
}
private static bool SameSetup( Cache cache, Snapshot snapshot ) =>
cache.AssemblyName == snapshot.AssemblyName
&& cache.GeneratedCode == snapshot.GeneratedCode
&& Equals( cache.ParseOptions, snapshot.ParseOptions );
private static Cache Create( Snapshot snapshot )
{
var cache = new Cache
{
AssemblyName = snapshot.AssemblyName,
ParseOptions = snapshot.ParseOptions,
GeneratedCode = snapshot.GeneratedCode,
CompiledImages = snapshot.CompiledReferences.Select( x => x.Image ).ToList()
};
var trees = new List<SyntaxTree>();
if ( !string.IsNullOrWhiteSpace( snapshot.GeneratedCode ) )
trees.Add( CSharpSyntaxTree.ParseText( snapshot.GeneratedCode, snapshot.ParseOptions, GeneratedPath ) );
cache.Compilation = CSharpCompilation.Create(
snapshot.AssemblyName ?? "HotCode.Navigation",
trees,
References( snapshot ),
new CSharpCompilationOptions( OutputKind.DynamicallyLinkedLibrary, allowUnsafe: true, nullableContextOptions: NullableContextOptions.Disable ) );
return cache;
}
/// <summary>
/// Re-parses files that changed on disk or in an open tab, adds new ones and drops deleted ones.
/// </summary>
private static void Update( Cache cache, Snapshot snapshot )
{
var current = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
var added = new List<SyntaxTree>();
var removed = new List<SyntaxTree>();
foreach ( var file in SourceFiles( snapshot ) )
{
current.Add( file );
snapshot.OpenBuffers.TryGetValue( file, out var buffer );
DateTime stamp;
try { stamp = File.GetLastWriteTimeUtc( file ); }
catch ( Exception e ) when ( e is IOException or UnauthorizedAccessException ) { continue; }
cache.Files.TryGetValue( file, out var cached );
var unchanged = cached is not null && (buffer is not null
? cached.Buffer is not null && (ReferenceEquals( cached.Buffer, buffer ) || cached.Buffer == buffer)
: cached.Buffer is null && cached.Stamp == stamp);
if ( unchanged ) continue;
string text = buffer;
if ( text is null )
{
try { text = File.ReadAllText( file ); }
catch ( Exception e ) when ( e is IOException or UnauthorizedAccessException ) { continue; }
}
var tree = CSharpSyntaxTree.ParseText( TextBuffer.Normalize( text ), snapshot.ParseOptions, file );
if ( cached is not null ) removed.Add( cached.Tree );
added.Add( tree );
cache.Files[file] = new CachedFile { Stamp = stamp, Buffer = buffer, Tree = tree };
}
foreach ( var gone in cache.Files.Keys.Where( k => !current.Contains( k ) ).ToList() )
{
removed.Add( cache.Files[gone].Tree );
cache.Files.Remove( gone );
}
if ( removed.Count > 0 ) cache.Compilation = cache.Compilation.RemoveSyntaxTrees( removed );
if ( added.Count > 0 ) cache.Compilation = cache.Compilation.AddSyntaxTrees( added );
}
private static IEnumerable<string> SourceFiles( Snapshot snapshot )
{
List<string> files;
try
{
files = Directory.EnumerateFiles( snapshot.SourceRoot, "*.cs", SearchOption.AllDirectories ).ToList();
}
catch ( Exception e ) when ( e is IOException or UnauthorizedAccessException )
{
yield break;
}
foreach ( var file in files )
{
var relative = Path.GetRelativePath( snapshot.SourceRoot, file );
if ( CodeFileNode.IsInHiddenFolder( relative ) ) continue;
if ( snapshot.IgnoreFolders.Any( f => relative.StartsWith( f.Trim( '/', '\\' ) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase ) ) ) continue;
yield return Path.GetFullPath( file );
}
}
private static List<MetadataReference> References( Snapshot snapshot )
{
var references = new List<MetadataReference>();
var names = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
// Never reference the assembly we're compiling from source, or every type would be ambiguous
if ( snapshot.AssemblyName is not null ) names.Add( snapshot.AssemblyName );
foreach ( var (name, image, xmlDocs) in snapshot.CompiledReferences )
{
if ( name is null || !names.Add( name ) ) continue;
var docs = string.IsNullOrEmpty( xmlDocs ) ? null : XmlDocs.FromText( xmlDocs );
references.Add( MetadataReference.CreateFromImage( image, documentation: docs ) );
}
foreach ( var assembly in AppDomain.CurrentDomain.GetAssemblies() )
{
if ( assembly.IsDynamic ) continue;
string location;
try { location = assembly.Location; }
catch ( NotSupportedException ) { continue; }
if ( string.IsNullOrEmpty( location ) || !File.Exists( location ) ) continue;
if ( !names.Add( assembly.GetName().Name ?? location ) ) continue;
references.Add( FileReference( location ) );
}
return references;
}
private static MetadataReference FileReference( string path )
{
var stamp = File.GetLastWriteTimeUtc( path );
if ( FileReferences.TryGetValue( path, out var cached ) && cached.Stamp == stamp )
return cached.Reference;
// s&box (and .NET) ship Foo.xml next to Foo.dll; with it, hints and hovers can show the docs
var xml = Path.ChangeExtension( path, ".xml" );
var docs = File.Exists( xml ) ? XmlDocs.FromFile( xml ) : null;
var reference = MetadataReference.CreateFromFile( path, documentation: docs );
FileReferences[path] = (stamp, reference);
return reference;
}
}