Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Experimental support HTML formatting in Razor LSP #2445

Merged
3 commits merged into from
Sep 1, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.LanguageServer.Common;
using OmniSharp.Extensions.LanguageServer.Protocol;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Text;
using OmniSharp.Extensions.LanguageServer.Protocol.Models;
using OmniSharp.Extensions.LanguageServer.Protocol.Server;
using Range = OmniSharp.Extensions.LanguageServer.Protocol.Models.Range;
Expand All @@ -19,10 +23,12 @@ internal class CSharpFormatter
private readonly RazorDocumentMappingService _documentMappingService;
private readonly FilePathNormalizer _filePathNormalizer;
private readonly IClientLanguageServer _server;
private readonly ProjectSnapshotManagerAccessor _projectSnapshotManagerAccessor;

public CSharpFormatter(
RazorDocumentMappingService documentMappingService,
IClientLanguageServer languageServer,
ProjectSnapshotManagerAccessor projectSnapshotManagerAccessor,
FilePathNormalizer filePathNormalizer)
{
if (documentMappingService is null)
Expand All @@ -35,13 +41,19 @@ public CSharpFormatter(
throw new ArgumentNullException(nameof(languageServer));
}

if (projectSnapshotManagerAccessor is null)
{
throw new ArgumentNullException(nameof(projectSnapshotManagerAccessor));
}

if (filePathNormalizer is null)
{
throw new ArgumentNullException(nameof(filePathNormalizer));
}

_documentMappingService = documentMappingService;
_server = languageServer;
_projectSnapshotManagerAccessor = projectSnapshotManagerAccessor;
_filePathNormalizer = filePathNormalizer;
}

Expand All @@ -50,26 +62,26 @@ public async Task<TextEdit[]> FormatAsync(
Range range,
DocumentUri uri,
FormattingOptions options,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
bool formatOnClient = false)
NTaylorMullen marked this conversation as resolved.
Show resolved Hide resolved
{
if (!_documentMappingService.TryMapToProjectedDocumentRange(codeDocument, range, out var projectedRange))
Range projectedRange = null;
NTaylorMullen marked this conversation as resolved.
Show resolved Hide resolved
if (range != null && !_documentMappingService.TryMapToProjectedDocumentRange(codeDocument, range, out projectedRange))
{
return Array.Empty<TextEdit>();
}

var @params = new RazorDocumentRangeFormattingParams()
TextEdit[] edits;
if (formatOnClient)
{
Kind = RazorLanguageKind.CSharp,
ProjectedRange = projectedRange,
HostDocumentFilePath = _filePathNormalizer.Normalize(uri.GetAbsoluteOrUNCPath()),
Options = options
};

var response = _server.SendRequest(LanguageServerConstants.RazorRangeFormattingEndpoint, @params);
var result = await response.Returning<RazorDocumentRangeFormattingResponse>(cancellationToken);

var mappedEdits = MapEditsToHostDocument(codeDocument, result.Edits);
edits = await FormatOnClientAsync(codeDocument, projectedRange, uri, options, cancellationToken);
}
else
{
edits = await FormatOnServerAsync(codeDocument, projectedRange, uri, options, cancellationToken);
}

var mappedEdits = MapEditsToHostDocument(codeDocument, edits);
return mappedEdits;
}

Expand All @@ -90,5 +102,50 @@ private TextEdit[] MapEditsToHostDocument(RazorCodeDocument codeDocument, TextEd

return actualEdits.ToArray();
}

private async Task<TextEdit[]> FormatOnClientAsync(
RazorCodeDocument codeDocument,
Range projectedRange,
DocumentUri uri,
FormattingOptions options,
CancellationToken cancellationToken)
{
var @params = new RazorDocumentRangeFormattingParams()
{
Kind = RazorLanguageKind.CSharp,
ProjectedRange = projectedRange,
HostDocumentFilePath = _filePathNormalizer.Normalize(uri.GetAbsoluteOrUNCPath()),
Options = options
};

var response = _server.SendRequest(LanguageServerConstants.RazorRangeFormattingEndpoint, @params);
var result = await response.Returning<RazorDocumentRangeFormattingResponse>(cancellationToken);

return result.Edits;
}

private async Task<TextEdit[]> FormatOnServerAsync(
RazorCodeDocument codeDocument,
Range projectedRange,
DocumentUri uri,
FormattingOptions options,
CancellationToken cancellationToken)
{
var workspace = _projectSnapshotManagerAccessor.Instance.Workspace;
var cSharpOptions = workspace.Options
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we enforcing csharp vs cSharp?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. This may have been a copy-paste from somewhere. I like csharp because cSharp kind of takes longer to type 😆. But I am willing to stick to whatever is most used.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

csharp +1

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 on csharp. Looks cleaner in my opinion.

.WithChangedOption(CodeAnalysis.Formatting.FormattingOptions.TabSize, LanguageNames.CSharp, (int)options.TabSize)
.WithChangedOption(CodeAnalysis.Formatting.FormattingOptions.UseTabs, LanguageNames.CSharp, !options.InsertSpaces);

var csharpDocument = codeDocument.GetCSharpDocument();
var syntaxTree = CSharpSyntaxTree.ParseText(csharpDocument.GeneratedCode);
var sourceText = SourceText.From(csharpDocument.GeneratedCode);
var root = await syntaxTree.GetRootAsync();
var spanToFormat = projectedRange.AsTextSpan(sourceText);

var changes = CodeAnalysis.Formatting.Formatter.GetFormattedTextChanges(root, spanToFormat, workspace, options: cSharpOptions);

var edits = changes.Select(c => c.AsTextEdit(sourceText)).ToArray();
return edits;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.Language.Legacy;
using Microsoft.AspNetCore.Razor.Language.Syntax;
using Microsoft.AspNetCore.Razor.LanguageServer.Common;
using Microsoft.Extensions.Logging;
using OmniSharp.Extensions.LanguageServer.Protocol.Models;
using OmniSharp.Extensions.LanguageServer.Protocol.Server;
using TextSpan = Microsoft.CodeAnalysis.Text.TextSpan;

namespace Microsoft.AspNetCore.Razor.LanguageServer.Formatting
{
internal class CSharpFormattingPass : FormattingPassBase
{
private readonly ILogger _logger;

public CSharpFormattingPass(
RazorDocumentMappingService documentMappingService,
FilePathNormalizer filePathNormalizer,
IClientLanguageServer server,
ProjectSnapshotManagerAccessor projectSnapshotManagerAccessor,
ILoggerFactory loggerFactory)
: base(documentMappingService, filePathNormalizer, server, projectSnapshotManagerAccessor)
{
if (loggerFactory is null)
{
throw new ArgumentNullException(nameof(loggerFactory));
}

_logger = loggerFactory.CreateLogger<CSharpFormattingPass>();
}

// Run after the HTML formatter pass.
public override int Order => DefaultOrder - 5;

public async override Task<FormattingResult> ExecuteAsync(FormattingContext context, FormattingResult result, CancellationToken cancellationToken)
{
if (context.IsFormatOnType || result.Kind != RazorLanguageKind.Razor)
NTaylorMullen marked this conversation as resolved.
Show resolved Hide resolved
{
// We don't want to handle OnTypeFormatting here.
return result;
}

// Apply previous edits if any.
var originalText = context.SourceText;
var changedText = originalText;
var changedContext = context;
if (result.Edits.Length > 0)
{
var changes = result.Edits.Select(e => e.AsTextChange(originalText)).ToArray();
changedText = changedText.WithChanges(changes);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given we're just applying these as-is. Does every pass do this? Wondering if this should be part of the orchestration

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about passing in the changedContext to every successive pass (which would remove the need for all of these) but any pass after the first one would lose the guarantee that the SourceText they see will match the one on the client. So they can't use the client to format after the first pass. As of now, only the first pass (HtmlFormattingPass) uses the client to format so I can technically do this but I would rather do this cleanup when I know for sure I won't need to client after the first pass.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ahhh I see

changedContext = await context.WithTextAsync(changedText);
}

cancellationToken.ThrowIfCancellationRequested();

// Apply original C# edits
var csharpEdits = await FormatCSharpAsync(changedContext, cancellationToken);
if (csharpEdits.Count > 0)
{
var csharpChanges = csharpEdits.Select(c => c.AsTextChange(changedText));
changedText = changedText.WithChanges(csharpChanges);
changedContext = await changedContext.WithTextAsync(changedText);
}

cancellationToken.ThrowIfCancellationRequested();

// Now, for each affected line in the edited version of the document, remove x amount of spaces
// at the front to account for extra indentation applied by the C# formatter.
// This should be based on context.
// For instance, lines inside @code/@functions block should be reduced one level
// and lines inside @{} should be reduced by two levels.
var indentationChanges = AdjustCSharpIndentation(changedContext, startLine: 0, endLine: changedText.Lines.Count - 1);

if (indentationChanges.Count > 0)
{
// Apply the edits that modify indentation.
changedText = changedText.WithChanges(indentationChanges);
changedContext = await changedContext.WithTextAsync(changedText);
}

// We make an optimistic attempt at fixing corner cases.
changedText = CleanupDocument(changedContext);

var finalChanges = SourceTextDiffer.GetMinimalTextChanges(originalText, changedText, lineDiffOnly: false);
var finalEdits = finalChanges.Select(f => f.AsTextEdit(originalText)).ToArray();

return new FormattingResult(finalEdits);
}

private async Task<List<TextEdit>> FormatCSharpAsync(FormattingContext context, CancellationToken cancellationToken)
{
var sourceText = context.SourceText;
var csharpEdits = new List<TextEdit>();
foreach (var mapping in context.CodeDocument.GetCSharpDocument().SourceMappings)
{
var span = new TextSpan(mapping.OriginalSpan.AbsoluteIndex, mapping.OriginalSpan.Length);
var range = span.AsRange(sourceText);
if (!ShouldFormat(context, range.Start))
{
// We don't want to format this range.
continue;
}

// These should already be remapped.
var edits = await CSharpFormatter.FormatAsync(context.CodeDocument, range, context.Uri, context.Options, cancellationToken);
csharpEdits.AddRange(edits.Where(e => range.Contains(e.Range)));
}

return csharpEdits;
}

private static bool ShouldFormat(FormattingContext context, Position position)
{
// We should be called with start positions of various C# SourceMappings.
if (position.Character == 0)
{
// The mapping starts at 0. It can't be anything special but pure C#. Let's format it.
return true;
}

var sourceText = context.SourceText;
var absoluteIndex = sourceText.Lines[(int)position.Line].Start + (int)position.Character;
var syntaxTree = context.CodeDocument.GetSyntaxTree();
var change = new SourceChange(absoluteIndex, 0, string.Empty);
var owner = syntaxTree.Root.LocateOwner(change);
if (owner == null)
{
// Can't determine owner of this position. Optimistically allow formatting.
return true;
}

if (IsInHtmlTag() ||
IsInSingleLineDirective() ||
IsImplicitOrExplicitExpression())
{
return false;
}

return true;

bool IsInHtmlTag()
{
// E.g, (| is position)
//
// `<p csharpattr="|Variable">` - true
//
return owner.AncestorsAndSelf().Any(
n => n is MarkupStartTagSyntax || n is MarkupTagHelperStartTagSyntax || n is MarkupEndTagSyntax || n is MarkupTagHelperEndTagSyntax);
}

bool IsInSingleLineDirective()
{
// E.g, (| is position)
//
// `@inject |SomeType SomeName` - true
//
// Note: @using directives don't have a descriptor associated with them, hence the extra null check.
//
return owner.AncestorsAndSelf().Any(
n => n is RazorDirectiveSyntax directive && (directive.DirectiveDescriptor == null || directive.DirectiveDescriptor.Kind == DirectiveKind.SingleLine));
}

bool IsImplicitOrExplicitExpression()
ajaybhargavb marked this conversation as resolved.
Show resolved Hide resolved
{
// E.g, (| is position)
//
// `@|foo` - true
// `@(|foo)` - true
//
return owner.AncestorsAndSelf().Any(n => n is CSharpImplicitExpressionSyntax || n is CSharpExplicitExpressionSyntax);
}
}
}
}
Loading