// Copyright (c) Microsoft. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.Editor.Shared.Utilities;
using Microsoft.CodeAnalysis.ErrorLogger;
using Microsoft.CodeAnalysis.Options;
using Microsoft.CodeAnalysis.Shared.Utilities;
using Microsoft.VisualStudio.CodingConventions;
namespace Microsoft.CodeAnalysis.Editor.Options
{
// NOTE: this type depends Microsoft.VisualStudio.CodingConventions, so for now it's living in EditorFeatures.Wpf as that assembly
// isn't yet available outside of Visual Studio.
internal sealed partial class EditorConfigDocumentOptionsProvider : IDocumentOptionsProvider
{
private const int EventDelayInMillisecond = 50;
private readonly object _gate = new object();
///
/// The map of cached contexts for currently open documents. Should only be accessed if holding a monitor lock
/// on .
///
private readonly Dictionary> _openDocumentContexts = new Dictionary>();
private readonly Workspace _workspace;
private readonly ICodingConventionsManager _codingConventionsManager;
private readonly IErrorLoggerService _errorLogger;
private ResettableDelay _resettableDelay;
internal EditorConfigDocumentOptionsProvider(Workspace workspace, ICodingConventionsManager codingConventionsManager)
{
_workspace = workspace;
_codingConventionsManager = codingConventionsManager;
_errorLogger = workspace.Services.GetService();
_resettableDelay = ResettableDelay.CompletedDelay;
workspace.DocumentOpened += Workspace_DocumentOpened;
workspace.DocumentClosed += Workspace_DocumentClosed;
}
private void Workspace_DocumentClosed(object sender, DocumentEventArgs e)
{
lock (_gate)
{
if (_openDocumentContexts.TryGetValue(e.Document.Id, out var contextTask))
{
_openDocumentContexts.Remove(e.Document.Id);
// Ensure we dispose the context, which we'll do asynchronously
contextTask.ContinueWith(
t =>
{
var context = t.Result;
context.CodingConventionsChangedAsync -= OnCodingConventionsChangedAsync;
context.Dispose();
},
CancellationToken.None,
TaskContinuationOptions.OnlyOnRanToCompletion,
TaskScheduler.Default);
}
}
}
private void Workspace_DocumentOpened(object sender, DocumentEventArgs e)
{
lock (_gate)
{
var contextTask = Task.Run(async () =>
{
var context = await GetConventionContextAsync(e.Document.FilePath, CancellationToken.None).ConfigureAwait(false);
context.CodingConventionsChangedAsync += OnCodingConventionsChangedAsync;
return context;
});
_openDocumentContexts.Add(e.Document.Id, contextTask);
}
}
public async Task GetOptionsForDocumentAsync(Document document, CancellationToken cancellationToken)
{
Task contextTask;
lock (_gate)
{
_openDocumentContexts.TryGetValue(document.Id, out contextTask);
}
if (contextTask != null)
{
// The file is open, let's reuse our cached data for that file. That task might be running, but we don't want to await
// it as awaiting it wouldn't respect the cancellation of our caller. By creating a trivial continuation like this
// that uses eager cancellation, if the cancellationToken is cancelled our await will end early.
var cancellableContextTask = contextTask.ContinueWith(
t => t.Result,
cancellationToken,
TaskContinuationOptions.OnlyOnRanToCompletion | TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
var context = await cancellableContextTask.ConfigureAwait(false);
return new DocumentOptions(context.CurrentConventions, _errorLogger);
}
else
{
var path = document.FilePath;
// The file might not actually have a path yet, if it's a file being proposed by a code action. We'll guess a file path to use
if (path == null)
{
if (document.Name != null && document.Project.FilePath != null)
{
path = Path.Combine(Path.GetDirectoryName(document.Project.FilePath), document.Name);
}
else
{
// Really no idea where this is going, so bail
return null;
}
}
// We don't have anything cached, so we'll just get it now lazily and not hold onto it. The workspace layer will ensure
// that we maintain snapshot rules for the document options. We'll also run it on the thread pool
// as in some builds the ICodingConventionsManager captures the thread pool.
var conventionsAsync = Task.Run(() => GetConventionContextAsync(path, cancellationToken));
using (var context = await conventionsAsync.ConfigureAwait(false))
{
return new DocumentOptions(context.CurrentConventions, _errorLogger);
}
}
}
private Task GetConventionContextAsync(string path, CancellationToken cancellationToken)
{
return IOUtilities.PerformIOAsync(
() => _codingConventionsManager.GetConventionContextAsync(path, cancellationToken),
defaultValue: EmptyCodingConventionContext.Instance);
}
private Task OnCodingConventionsChangedAsync(object sender, CodingConventionsChangedEventArgs arg)
{
// this is a temporary workaround. once we finish the work to put editorconfig file as a part of roslyn solution snapshot,
// that system will automatically pick up option changes and update snapshot. and it will work regardless
// whether a file is opened in editor or not.
//
// but until then, we need to explicitly touch workspace to update snapshot. and
// only works for open files. it is not easy to track option changes for closed files with current model.
// related tracking issue - https://github.com/dotnet/roslyn/issues/26250
lock (_gate)
{
if (!_resettableDelay.Task.IsCompleted)
{
_resettableDelay.Reset();
}
else
{
// since this event gets raised for all documents that are affected by 1 editconfig file,
// and since for now we make that event as whole solution changed event, we don't need to update
// snapshot for each events. aggregate all events to 1.
var delay = new ResettableDelay(EventDelayInMillisecond);
delay.Task.ContinueWith(_ => _workspace.OnOptionChanged(),
CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
_resettableDelay = delay;
}
}
return Task.CompletedTask;
}
}
}