/
githubmirror
/
roslyn
Обзор
Документация
Войти
/
githubmirror
/
roslyn
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsEntryPointDiscovery.cs
386 строк
18 KB
Jan Jones
Allow MSBuildWorkspace to open file-based apps (#84139)
30 июл 2026, 11:03
Не верифицирован
30 июл 2026, 11:03
7e7ebef
Код
Авторство
О чём код?
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. using System.Buffers; using System.Collections.Immutable; using System.Composition; using System.IO.Enumeration; using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.CodeAnalysis.Collections; using Microsoft.CodeAnalysis.ErrorReporting; using Microsoft.CodeAnalysis.FileBasedPrograms; using Microsoft.CodeAnalysis.Host; using Microsoft.CodeAnalysis.Host.Mef; using Microsoft.CodeAnalysis.LanguageServer.Handler; using Microsoft.CodeAnalysis.LanguageServer.HostWorkspace; using Microsoft.CodeAnalysis.Options; using Microsoft.CodeAnalysis.PooledObjects; using Microsoft.CodeAnalysis.Shared.TestHooks; using Microsoft.CodeAnalysis.Shared.Utilities; using Microsoft.Extensions.Logging; using Roslyn.LanguageServer.Protocol; using Roslyn.Utilities; namespace Microsoft.CodeAnalysis.LanguageServer.FileBasedPrograms; [Shared] [ExportLspServiceFactory(typeof(FileBasedProgramsEntryPointDiscovery), ProtocolConstants.RoslynLspLanguagesContract)] [method: ImportingConstructor] [method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] internal sealed class FileBasedProgramsEntryPointDiscoveryFactory(IGlobalOptionService globalOptionService, IAsynchronousOperationListenerProvider listenerProvider) : ILspServiceFactory { public ILspService CreateILspService(LspServices lspServices, WellKnownLspServerKinds serverKind) { return new FileBasedProgramsEntryPointDiscovery( globalOptionService, listenerProvider.GetListener(FeatureAttribute.Workspace), lspServices.GetRequiredService<IHostWorkspaceProvider>().Workspace.Services.GetRequiredService<IFileBasedProgramService>(), lspServices.GetRequiredService<ILoggerFactory>(), lspServices); } } internal sealed partial class FileBasedProgramsEntryPointDiscovery( IGlobalOptionService globalOptionService, IAsynchronousOperationListener listener, IFileBasedProgramService fileBasedProgramService, ILoggerFactory loggerFactory, LspServices lspServices) : ILspService, IOnInitialized { private static readonly StringComparer s_pathComparer = StringComparer.OrdinalIgnoreCase; /// <summary>Directories which are ignored per convention.</summary> /// <remarks>Directories whose name starts with '.' (e.g. '.git', '.vs') are also ignored.</remarks> private static readonly SearchValues<string> s_ignoredDirectories = SearchValues.Create([ "artifacts", "bin", "obj", "node_modules" ], StringComparison.OrdinalIgnoreCase); private readonly ILogger _logger = loggerFactory.CreateLogger<FileBasedProgramsEntryPointDiscovery>(); private ImmutableArray<string> _workspaceFolders; public Task OnInitializedAsync(ClientCapabilities clientCapabilities, RequestContext context, CancellationToken cancellationToken) { var initializeManager = context.GetRequiredService<IInitializeManager>(); _workspaceFolders = initializeManager.GetRequiredWorkspaceFolderPaths(); Task.Run(async () => { try { using var token = listener.BeginAsyncOperation(nameof(FindAndLoadEntryPointsAsync)); await FindAndLoadEntryPointsAsync(); } catch (Exception ex) when (FatalError.ReportAndCatch(ex)) { throw ExceptionUtilities.Unreachable(); } }, cancellationToken); return Task.CompletedTask; } internal async Task FindAndLoadEntryPointsAsync() { Contract.ThrowIfTrue(_workspaceFolders.IsDefault, $"{nameof(OnInitializedAsync)} must be called before {nameof(FindAndLoadEntryPointsAsync)}."); if (_workspaceFolders.IsEmpty) { _logger.LogTrace("No workspace folders to search for file-based apps."); return; } if (!globalOptionService.GetOption(LanguageServerProjectSystemOptionsStorage.EnableFileBasedPrograms)) { _logger.LogTrace(@"""dotnet.projects.enableFileBasedPrograms"" is false. Not discovering entry points."); return; } if (!globalOptionService.GetOption(FileBasedAppsOptionsStorage.EnableAutomaticDiscovery)) { _logger.LogTrace(@"""dotnet.fileBasedApps.enableAutomaticDiscovery"" is false. Not discovering entry points."); return; } var fileBasedProgramsProjectSystem = (FileBasedProgramsProjectSystem?)lspServices.GetService<ILspMiscellaneousFilesWorkspaceProvider>(); Contract.ThrowIfNull(fileBasedProgramsProjectSystem); // Note: the overwhelmingly common case is when there is just one workspace folder. // For simplicity we orient our search around one workspace folder at a time. foreach (var workspaceFolder in _workspaceFolders) { foreach (var fileBasedAppPath in FindEntryPoints(workspaceFolder)) { await fileBasedProgramsProjectSystem.TryBeginLoadingFileBasedAppAsync(fileBasedAppPath); } } // Discovery pass done. Find and delete old caches. IOUtilities.PerformIO(() => { using var enumerator = new OldCacheEnumerator(fileBasedProgramService); while (enumerator.MoveNext()) { IOUtilities.PerformIO(() => Directory.Delete(enumerator.Current, recursive: true)); } }); } private sealed class OldCacheEnumerator(IFileBasedProgramService fileBasedProgramService) : FileSystemEnumerator<string>( directory: fileBasedProgramService.GetDiscoveryCacheRootDirectory(), options: new() { RecurseSubdirectories = false }) { // Yield cache directories that have not been modified in 30 days (indicates they are stale and should be deleted) private readonly DateTimeOffset _includeCachesEarlierThanUtc = DateTimeOffset.UtcNow - TimeSpan.FromDays(30); protected override string TransformEntry(ref FileSystemEntry entry) => entry.ToFullPath(); protected override bool ShouldIncludeEntry(ref FileSystemEntry entry) { return entry.IsDirectory && entry.LastWriteTimeUtc < _includeCachesEarlierThanUtc; } } internal ImmutableArray<string> FindEntryPoints(string workspaceFolder) { var stopwatch = SharedStopwatch.StartNew(); var cacheDirectory = fileBasedProgramService.GetDiscoveryCacheDirectory(workspaceFolder); var cacheFilePath = Path.Join(cacheDirectory, "cache.json"); Cache? cache = null; try { if (File.Exists(cacheFilePath)) { using var cacheFile = File.OpenRead(cacheFilePath); cache = JsonSerializer.Deserialize(cacheFile, CacheSerializerContext.Default.Cache); } // Drop malformed caches if (cache != null && (!cache.WorkspacePath.Equals(workspaceFolder, StringComparison.OrdinalIgnoreCase) || cache.FileBasedAppFullPaths.IsDefault || cache.DirectoriesContainingCsproj.IsDefault)) { cache = null; } } catch (Exception ex) { _logger.LogDebug("Could not read cache file: {ex.Message}", ex.Message); } cache ??= new Cache(workspaceFolder, DateTimeOffset.MinValue, FileBasedAppFullPaths: [], DirectoriesContainingCsproj: []); // Note: file system timestamps can have a coarser resolution than DateTimeOffset. // This means that using APIs like `DateTimeOffset.UtcNow`, then writing a file, then sampling its LastWriteTimeUtc, // can result in the UtcNow that we accessed earlier, having a "later" value than the LastWriteTimeUtc. // // To deal with this accurately, we write a timestamp file to the filesystem, then get a walkStartTimeUtc from its LastWriteTimeUtc. // We assume that file writes in the workspace folder which occur after this write, will have equal or later timestamps. // Timestamps we encounter which compare equal to the walkStartTimeUtc timestamp, must be treated as possibly being newer than the walkStartTimeUtc timestamp. var walkStartTimeUtc = IOUtilities.PerformIO(() => { var sentinelPath = Path.Join(cacheDirectory, $".walk-timestamp-{Guid.NewGuid()}"); File.WriteAllBytes(sentinelPath, []); var lastWriteTime = File.GetLastWriteTimeUtc(sentinelPath); File.Delete(sentinelPath); return lastWriteTime; }, defaultValue: cache.LastWalkTimeUtc); var newFileBasedAppsBuilder = ArrayBuilder<string>.GetInstance(cache.FileBasedAppFullPaths.Length); var directoriesContainingCsprojBuilder = ArrayBuilder<string>.GetInstance(cache.DirectoriesContainingCsproj.Length); var visitor = new WorkspaceFolderVisitor(cache, newFileBasedAppsBuilder, directoriesContainingCsprojBuilder, _logger); visitor.Visit(); var elapsedMilliseconds = Math.Round(stopwatch.Elapsed.TotalMilliseconds); _logger.LogInformation("Finished discovery in '{workspaceFolder}' in {elapsedMilliseconds} milliseconds", workspaceFolder, elapsedMilliseconds); // Ensure items go into the cache file in a stable order. // This is useful for manual inspection and allows use of 'BinarySearch' to match directories against the cache. newFileBasedAppsBuilder.Sort(); directoriesContainingCsprojBuilder.Sort(); var newCache = new Cache(workspaceFolder, walkStartTimeUtc, newFileBasedAppsBuilder.ToImmutableAndFree(), directoriesContainingCsprojBuilder.ToImmutableAndFree()); try { Directory.CreateDirectory(cacheDirectory); var cacheStagingFilePath = Path.Join(cacheDirectory, "cache.staging.json"); using (var stagingFile = File.Create(cacheStagingFilePath)) { JsonSerializer.Serialize(stagingFile, newCache, CacheSerializerContext.Default.Cache); } File.Replace(cacheStagingFilePath, cacheFilePath, destinationBackupFileName: null); } catch (Exception ex) when (FatalError.ReportAndCatch(ex)) { } return newCache.FileBasedAppFullPaths; } /// <summary>Check if discovery should consider this a file-based app.</summary> private static bool IsFileBasedApp(string fullPath) { using var fileStream = File.OpenRead(fullPath); var toRead = (int)Math.Min(5, fileStream.Length); InlineArray5<byte> bytes = default; Span<byte> bytesSpan = bytes; fileStream.ReadExactly(bytesSpan[..toRead]); // Discovery only considers a file to be file-based app, if it starts with either "#!", or UTF-8 BOM followed by "#!". return bytesSpan is [(byte)'#', (byte)'!', ..] or [0xEF, 0xBB, 0xBF, (byte)'#', (byte)'!']; } private enum CsFileKind { None, // Denotes a file that is irrelevant for discovery. Shouldn't appear on a valid 'CsFileInfo' instance. Directory, Cs, Csproj, } private readonly struct CsFileInfo(CsFileKind kind, string path, DateTimeOffset createdOrModifiedTimeUtc) { public CsFileKind Kind { get; } = kind; public string Path { get; } = path; public DateTimeOffset CreatedOrModifiedTimeUtc { get; } = createdOrModifiedTimeUtc; } private class DirectoryEnumerator(string directory) : FileSystemEnumerator<CsFileInfo>(directory, new EnumerationOptions { RecurseSubdirectories = false, IgnoreInaccessible = true }) { private CsFileKind GetKind(ref FileSystemEntry entry) { if (entry.IsDirectory) return CsFileKind.Directory; var extension = Path.GetExtension(entry.FileName); if (extension.Equals(".cs", StringComparison.OrdinalIgnoreCase)) return CsFileKind.Cs; if (extension.Equals(".csproj", StringComparison.OrdinalIgnoreCase)) return CsFileKind.Csproj; return CsFileKind.None; } protected override CsFileInfo TransformEntry(ref FileSystemEntry entry) { var kind = GetKind(ref entry); Contract.ThrowIfTrue(kind == CsFileKind.None); return new CsFileInfo(kind, entry.ToFullPath(), Max(entry.CreationTimeUtc, entry.LastWriteTimeUtc)); } protected override bool ShouldIncludeEntry(ref FileSystemEntry entry) { return GetKind(ref entry) != CsFileKind.None; } protected override bool ShouldRecurseIntoEntry(ref FileSystemEntry entry) { throw ExceptionUtilities.Unreachable(); } } private class WorkspaceFolderVisitor(Cache cache, ArrayBuilder<string> entryPointsBuilder, ArrayBuilder<string> directoriesContainingCsprojBuilder, ILogger logger) { internal void Visit() // Note: passing `DateTimeOffset.MinValue` here will force `VisitDirectory` to stat the directory again to get its created/modified times out. => VisitDirectory(cache.WorkspacePath, DateTimeOffset.MinValue); private void VisitDirectory(string directory, DateTimeOffset createdOrModifiedTimeUtc) { var directoryName = Path.GetFileName(directory.AsSpan()); if (directoryName.StartsWith('.') || directoryName.ContainsAny(s_ignoredDirectories)) return; if (createdOrModifiedTimeUtc < cache.LastWalkTimeUtc) { // On NTFS, the directory timestamps we observe when enumerating can be stale when files are added/deleted from a directory. // If we find the timestamps were old enough (i.e. we entered this block), // we still need to `new DirectoryInfo()` again and force the timestamps to update if needed. DateTimeOffset newCreatedOrModifiedTimeUtc; try { var directoryInfo = new DirectoryInfo(directory); newCreatedOrModifiedTimeUtc = Max(directoryInfo.CreationTimeUtc, directoryInfo.LastWriteTimeUtc); } catch (Exception ex) when (IOUtilities.IsNormalIOException(ex)) { logger.LogWarning("Skipping directory '{Directory}' during file-based app discovery due to I/O exception: {ExceptionMessage}", directory, ex.Message); return; } if (newCreatedOrModifiedTimeUtc < cache.LastWalkTimeUtc && cache.DirectoriesContainingCsproj.BinarySearch(directory, s_pathComparer) >= 0) { // Our info about this directory is up to date, and we know it contains a csproj, so bail out before enumerating its files. directoriesContainingCsprojBuilder.Add(directory); return; } createdOrModifiedTimeUtc = Max(createdOrModifiedTimeUtc, newCreatedOrModifiedTimeUtc); } // The DirectoryEnumerator uses IgnoreInaccessible = true, so it will silently skip // directories/files which we don't have permission to access during enumeration. using var currentDirectoryItems = TemporaryArray<CsFileInfo>.Empty; using var enumerator = new DirectoryEnumerator(directory); while (enumerator.MoveNext()) { var fileInfo = enumerator.Current; if (fileInfo.Kind == CsFileKind.Csproj) { // Found a csproj. Return without visiting any of the files. directoriesContainingCsprojBuilder.Add(directory); return; } currentDirectoryItems.Add(fileInfo); } // Did not find a csproj. Continue searching this subtree for entry points. foreach (var fileInfo in currentDirectoryItems) { // When a subdirectory is moved in to a parent directory between two discovery passes, the timestamps of the subdirectory's files are not updated. // Only the "modified" timestamp of the parent directory, and the "created" timestamp of the subdirectory, are updated. // This means: even if a .cs file we encounter within a "new" subdirectory has old timestamps, we don't know whether we've seen it before or not, so we need to crack it. if (fileInfo.Kind == CsFileKind.Directory) VisitDirectory(fileInfo.Path, Max(createdOrModifiedTimeUtc, fileInfo.CreatedOrModifiedTimeUtc)); else if (fileInfo.Kind == CsFileKind.Cs) VisitCsFile(fileInfo.Path, Max(createdOrModifiedTimeUtc, fileInfo.CreatedOrModifiedTimeUtc)); else throw ExceptionUtilities.Unreachable(); } } private void VisitCsFile(string file, DateTimeOffset createdOrModifiedTimeUtc) { if (createdOrModifiedTimeUtc < cache.LastWalkTimeUtc) { if (cache.FileBasedAppFullPaths.BinarySearch(file) >= 0) { logger.LogInformation("Discovered file-based app (cache hit): {csFilePath}", file); entryPointsBuilder.Add(file); } return; } if (IOUtilities.PerformIO(() => IsFileBasedApp(file))) { logger.LogInformation("Discovered file-based app (cache miss): {csFilePath}", file); entryPointsBuilder.Add(file); } } } /// <summary>Get the later of two DateTimeOffsets.</summary> private static DateTimeOffset Max(DateTimeOffset lhs, DateTimeOffset rhs) => lhs < rhs ? rhs : lhs; internal sealed record Cache(string WorkspacePath, DateTimeOffset LastWalkTimeUtc, ImmutableArray<string> FileBasedAppFullPaths, ImmutableArray<string> DirectoriesContainingCsproj); [JsonSerializable(typeof(Cache))] internal sealed partial class CacheSerializerContext : JsonSerializerContext; }