File: Formatting\UsingDirectiveHelper.cs
Web Access
Project: src\src\Razor\src\Razor\src\Microsoft.CodeAnalysis.Razor.Workspaces\Microsoft.CodeAnalysis.Razor.Workspaces.csproj (Microsoft.CodeAnalysis.Razor.Workspaces)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Mvc.Razor.Extensions;
using Microsoft.AspNetCore.Razor;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.Language.Components;
using Microsoft.AspNetCore.Razor.Language.Extensions;
using Microsoft.AspNetCore.Razor.Language.Legacy;
using Microsoft.AspNetCore.Razor.Language.Syntax;
using Microsoft.AspNetCore.Razor.PooledObjects;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Razor.DocumentMapping;
using Microsoft.CodeAnalysis.Text;
using RazorSyntaxNode = Microsoft.AspNetCore.Razor.Language.Syntax.SyntaxNode;
using RoslynSyntaxNode = Microsoft.CodeAnalysis.SyntaxNode;
 
namespace Microsoft.CodeAnalysis.Razor.Formatting;
 
internal static class UsingDirectiveHelper
{
    private static readonly Regex s_addUsingVSCodeAction = new Regex("@?using ([^;]+);?$", RegexOptions.Compiled, TimeSpan.FromSeconds(1));
 
    private readonly record struct RazorUsingDirective(RazorUsingDirectiveSyntax Node, AddImportChunkGenerator Statement);
 
    /// <summary>
    /// Extracts the namespace from a C# add using statement provided by Visual Studio
    /// </summary>
    /// <param name="csharpAddUsing">Add using statement of the form `using System.X;`</param>
    /// <param name="namespace">Extract namespace `System.X`</param>
    /// <param name="prefix">The prefix to show, before the namespace, if any</param>
    /// <returns></returns>
    public static bool TryExtractNamespace(string csharpAddUsing, out string @namespace, out string prefix)
    {
        // We must remove any leading/trailing new lines from the add using edit
        csharpAddUsing = csharpAddUsing.Trim();
        var regexMatchedTextEdit = s_addUsingVSCodeAction.Match(csharpAddUsing);
        if (!regexMatchedTextEdit.Success ||
 
            // Two Regex matching groups are expected
            // 1. `using namespace;`
            // 2. `namespace`
            regexMatchedTextEdit.Groups.Count != 2)
        {
            // Text edit in an unexpected format
            @namespace = string.Empty;
            prefix = string.Empty;
            return false;
        }
 
        @namespace = regexMatchedTextEdit.Groups[1].Value;
        prefix = csharpAddUsing[..regexMatchedTextEdit.Index];
        return true;
    }
 
    public static TextEdit CreateAddUsingTextEdit(string @namespace, RazorCodeDocument codeDocument)
    {
        /* The heuristic is as follows:
         *
         * - If no @using, @namespace, or @page directives are present, insert the statements at the top of the
         *   file in alphabetical order.
         * - If a @namespace or @page are present, the statements are inserted after the last line-wise in
         *   alphabetical order.
         * - If @using directives are present and alphabetized with System directives at the top, the statements
         *   will be placed in the correct locations according to that ordering.
         * - Otherwise it's kind of undefined; it's only geared to insert based on alphabetization.
         *
         * This is generally sufficient for our current situation (inserting a single @using statement to include a
         * component), however it has holes if we eventually use it for other purposes. If we want to deal with
         * that now I can come up with a more sophisticated heuristic (something along the lines of checking if
         * there's already an ordering, etc.).
         */
 
        using var usingDirectives = new PooledArrayBuilder<RazorUsingDirective>();
        CollectUsingDirectives(codeDocument, ref usingDirectives.AsRef());
        if (usingDirectives.Count > 0)
        {
            return GetInsertUsingTextEdit(codeDocument, @namespace, in usingDirectives);
        }
 
        return GetInsertUsingTextEdit(codeDocument, @namespace);
    }
 
    public static ImmutableArray<string> FindUsingDirectiveStrings(RoslynSyntaxNode csharpSyntaxRoot, SourceText csharpSourceText)
    {
        return csharpSyntaxRoot
            .DescendantNodes(static n => n is BaseNamespaceDeclarationSyntax or CompilationUnitSyntax)
            .OfType<UsingDirectiveSyntax>()
            .Where(static u => u.Name is not null) // If the Name is null then this isn't a using directive, it's probably an alias for a tuple type
            .SelectAsArray(u => GetNamespaceFromDirective(u, csharpSourceText));
 
        static string GetNamespaceFromDirective(UsingDirectiveSyntax usingDirectiveSyntax, SourceText sourceText)
        {
            var nameSyntax = usingDirectiveSyntax.Name.AssumeNotNull();
 
            var end = nameSyntax.Span.End;
 
            // FullSpan to get the end of the trivia before the next
            // token. Testing shows that the trailing whitespace is always given
            // as trivia to the using keyword.
            var start = usingDirectiveSyntax.UsingKeyword.FullSpan.End;
 
            return sourceText.ToString(TextSpan.FromBounds(start, end));
        }
    }
 
    /// <summary>
    /// Generates a <see cref="TextEdit"/> to insert a new using directive into the Razor code document, at the right spot among existing using directives.
    /// </summary>
    private static TextEdit GetInsertUsingTextEdit(
        RazorCodeDocument codeDocument,
        string newUsingNamespace,
        ref readonly PooledArrayBuilder<RazorUsingDirective> existingUsingDirectives)
    {
        Debug.Assert(existingUsingDirectives.Count > 0);
 
        var newText = $"@using {newUsingNamespace}{Environment.NewLine}";
 
        foreach (var usingDirective in existingUsingDirectives)
        {
            // Skip System directives; if they're at the top we don't want to insert before them
            var usingDirectiveNamespace = usingDirective.Statement.ParsedNamespace;
            if (usingDirectiveNamespace.StartsWith("System", StringComparison.Ordinal))
            {
                continue;
            }
 
            if (string.CompareOrdinal(newUsingNamespace, usingDirectiveNamespace) < 0)
            {
                var usingDirectiveLineIndex = codeDocument.Source.Text.GetLinePosition(usingDirective.Node.Span.Start).Line;
                return LspFactory.CreateTextEdit(line: usingDirectiveLineIndex, character: 0, newText);
            }
        }
 
        // If we haven't actually found a place to insert the using directive, do so at the end
        var endIndex = existingUsingDirectives[^1].Node.Span.End;
        var lineIndex = GetLineIndexOrEnd(codeDocument, endIndex - 1) + 1;
        return LspFactory.CreateTextEdit(line: lineIndex, character: 0, newText);
    }
 
    /// <summary>
    /// Generates a <see cref="TextEdit"/> to insert a new using directive into the Razor code document, at the top of the file.
    /// </summary>
    private static TextEdit GetInsertUsingTextEdit(
        RazorCodeDocument codeDocument,
        string newUsingNamespace)
    {
        var insertPosition = (0, 0);
 
        // If we don't have usings, insert after the last namespace or page directive, which ever comes later
        var root = codeDocument.GetRequiredSyntaxRoot();
        var lastNamespaceOrPageDirective = root
            .DescendantNodes()
            .LastOrDefault(IsNamespaceOrPageDirective);
 
        if (lastNamespaceOrPageDirective != null)
        {
            var lineIndex = GetLineIndexOrEnd(codeDocument, lastNamespaceOrPageDirective.Span.End - 1) + 1;
            insertPosition = (lineIndex, 0);
        }
 
        return LspFactory.CreateTextEdit(insertPosition, newText: $"@using {newUsingNamespace}{Environment.NewLine}");
    }
 
    private static int GetLineIndexOrEnd(RazorCodeDocument codeDocument, int endIndex)
    {
        if (endIndex < codeDocument.Source.Text.Length)
        {
            return codeDocument.Source.Text.GetLinePosition(endIndex).Line;
        }
        else
        {
            return codeDocument.Source.Text.Lines.Count;
        }
    }
 
    private static void CollectUsingDirectives(RazorCodeDocument codeDocument, ref PooledArrayBuilder<RazorUsingDirective> directives)
    {
        var root = codeDocument.GetRequiredSyntaxRoot();
        foreach (var node in root.DescendantNodes())
        {
            if (node is RazorUsingDirectiveSyntax directiveNode)
            {
                foreach (var child in directiveNode.DescendantNodes())
                {
                    if (child.GetChunkGenerator() is AddImportChunkGenerator { IsStatic: false } usingStatement)
                    {
                        directives.Add(new RazorUsingDirective(directiveNode, usingStatement));
                    }
                }
            }
        }
    }
 
    private static bool IsNamespaceOrPageDirective(RazorSyntaxNode node)
    {
        if (node is RazorDirectiveSyntax directiveNode)
        {
            return directiveNode.IsDirective(ComponentPageDirective.Directive) ||
                   directiveNode.IsDirective(NamespaceDirective.Directive) ||
                   directiveNode.IsDirective(PageDirective.Directive);
        }
 
        return false;
    }
 
    /// <summary>
    /// Determines whether the using directives in the document need sorting or consolidating.
    /// </summary>
    public static bool NeedsSortOrConsolidate(RazorCodeDocument codeDocument)
    {
        var syntaxTree = codeDocument.GetRequiredTagHelperRewrittenSyntaxTree();
 
        var usingDirectives = syntaxTree.GetUsingDirectives();
        if (usingDirectives.Length <= 1)
        {
            return false;
        }
 
        var sourceText = codeDocument.Source.Text;
 
        // Check if namespaces are already in sorted order
        string? previousNamespace = null;
        foreach (var directive in usingDirectives)
        {
            if (RazorSyntaxFacts.TryGetNamespaceFromDirective(directive, out var ns))
            {
                if (previousNamespace is not null && UsingsStringComparer.Instance.Compare(previousNamespace, ns) > 0)
                {
                    return true;
                }
 
                previousNamespace = ns;
            }
        }
 
        // Check if usings are in multiple groups (non-consecutive lines)
        for (var i = 1; i < usingDirectives.Length; i++)
        {
            var prevLine = sourceText.GetLinePosition(usingDirectives[i - 1].Span.End).Line;
            var currentLine = sourceText.GetLinePosition(usingDirectives[i].Span.Start).Line;
 
            if (currentLine > prevLine + 1)
            {
                return true;
            }
        }
 
        return false;
    }
 
    /// <summary>
    /// Returns the text edits to sort and consolidate the using directives in the document
    /// into one group at the location of the first group. Each directive's original source
    /// text is preserved (including semicolons or other trailing content on the directive node).
    /// Any non-directive content that follows a using directive on the same line is left in place.
    /// If <paramref name="directivesToKeep"/> is provided, only those directives appear in the
    /// sorted output; all others are removed. If null, all directives are kept.
    /// </summary>
    public static ImmutableArray<TextEdit> GetSortAndConsolidateEdits(
        RazorCodeDocument codeDocument,
        ImmutableArray<RazorUsingDirectiveSyntax>? directivesToKeep = null)
    {
        var syntaxTree = codeDocument.GetRequiredTagHelperRewrittenSyntaxTree();
        var usingDirectives = syntaxTree.GetUsingDirectives();
        var sourceText = codeDocument.Source.Text;
 
        // Sort the directive nodes by namespace and build consolidated text from original source
        var sorted = (directivesToKeep ?? usingDirectives).Sort(UsingsNodeComparer.Instance);
 
        using var _ = StringBuilderPool.GetPooledObject(out var builder);
        foreach (var directive in sorted)
        {
            // Append the full directive text, so (optional) semi-colons are preserved
            builder.AppendLine(sourceText.ToString(directive.Span));
        }
 
        using var editBuilder = new PooledArrayBuilder<TextEdit>();
 
        // Remove every using directive. If the directive is the only content on its line,
        // remove the entire line (including the newline). Otherwise, only remove the
        // directive span so any trailing content on the line is preserved.
        foreach (var directive in usingDirectives)
        {
            editBuilder.Add(GetRemoveDirectiveEdit(sourceText, directive.Span));
        }
 
        // Insert all sorted usings at the position of the first directive's line
        var firstDirectiveLine = sourceText.GetLinePosition(usingDirectives[0].Span.Start).Line;
        var insertPosition = sourceText.Lines[firstDirectiveLine].Start;
        var insertRange = sourceText.GetRange(insertPosition, insertPosition);
        editBuilder.Add(LspFactory.CreateTextEdit(insertRange, builder.ToString()));
 
        return editBuilder.ToImmutableAndClear();
    }
 
    /// <summary>
    /// Creates a text edit to remove a directive from its line. If the directive is the only
    /// non-whitespace content on the line, the entire line including the line break is removed.
    /// Otherwise, only the directive span is removed.
    /// </summary>
    public static TextEdit GetRemoveDirectiveEdit(SourceText sourceText, TextSpan directiveSpan)
    {
        var directiveLineNumber = sourceText.GetLinePosition(directiveSpan.Start).Line;
        var lineSpan = sourceText.Lines[directiveLineNumber].SpanIncludingLineBreak;
 
        // If the non-whitespace content of the line and the directive match, then the directive is the only thing on the line
        // so we remove the whole line. Otherwise, just remove the directive.
        var removeSpan = sourceText.NonWhitespaceContentEquals(sourceText, lineSpan.Start, lineSpan.End, directiveSpan.Start, directiveSpan.End)
            ? lineSpan
            : directiveSpan;
 
        return LspFactory.CreateTextEdit(sourceText.GetRange(removeSpan), string.Empty);
    }
}