File: CodeActions\Razor\ExtractToCssCodeActionResolver.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.Buffers;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.PooledObjects;
using Microsoft.CodeAnalysis.Razor.CodeActions.Models;
using Microsoft.CodeAnalysis.Razor.Formatting;
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
using Microsoft.CodeAnalysis.Razor.Protocol;
using Microsoft.CodeAnalysis.Razor.Utilities;
using Microsoft.CodeAnalysis.Razor.Workspaces;
using Microsoft.CodeAnalysis.Text;
 
namespace Microsoft.CodeAnalysis.Razor.CodeActions;
 
internal class ExtractToCssCodeActionResolver(
    LanguageServerFeatureOptions languageServerFeatureOptions,
    IFileSystem fileSystem) : IRazorCodeActionResolver
{
    private readonly LanguageServerFeatureOptions _languageServerFeatureOptions = languageServerFeatureOptions;
    private readonly IFileSystem _fileSystem = fileSystem;
 
    public string Action => LanguageServerConstants.CodeActions.ExtractToCss;
 
    public async Task<WorkspaceEdit?> ResolveAsync(DocumentContext documentContext, JsonElement data, RazorFormattingOptions options, CancellationToken cancellationToken)
    {
        var actionParams = data.Deserialize<ExtractToCssCodeActionParams>();
        if (actionParams is null)
        {
            return null;
        }
 
        var codeDocument = await documentContext.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false);
 
        var cssFilePath = $"{FilePathNormalizer.Normalize(documentContext.Uri.GetAbsoluteOrUNCPath())}.css";
        var cssFileUri = LspFactory.CreateFilePathUri(cssFilePath, _languageServerFeatureOptions);
 
        var text = await documentContext.GetSourceTextAsync(cancellationToken).ConfigureAwait(false);
 
        var cssContent = text.ToString(new TextSpan(actionParams.ExtractStart, actionParams.ExtractEnd - actionParams.ExtractStart)).Trim();
        var removeRange = codeDocument.Source.Text.GetRange(actionParams.RemoveStart, actionParams.RemoveEnd);
 
        var codeDocumentIdentifier = new OptionalVersionedTextDocumentIdentifier { DocumentUri = new(documentContext.Uri) };
        var cssDocumentIdentifier = new OptionalVersionedTextDocumentIdentifier { DocumentUri = new(cssFileUri) };
 
        using var changes = new PooledArrayBuilder<SumType<TextDocumentEdit, CreateFile, RenameFile, DeleteFile>>(capacity: 3);
 
        // First, an edit to remove the script tag and its contents.
        changes.Add(new TextDocumentEdit
        {
            TextDocument = codeDocumentIdentifier,
            Edits = [LspFactory.CreateTextEdit(removeRange, string.Empty)]
        });
 
        if (_fileSystem.FileExists(cssFilePath))
        {
            // CSS file already exists, insert the content at the end.
            GetLastLineNumberAndLength(cssFilePath, out var lastLineNumber, out var lastLineLength);
 
            changes.Add(new TextDocumentEdit
            {
                TextDocument = cssDocumentIdentifier,
                Edits = [LspFactory.CreateTextEdit(
                    position: (lastLineNumber, lastLineLength),
                    newText: lastLineNumber == 0 && lastLineLength == 0
                        ? cssContent
                        : Environment.NewLine + Environment.NewLine + cssContent)]
            });
        }
        else
        {
            // No CSS file, create it and fill it in
            changes.Add(new CreateFile { DocumentUri = cssDocumentIdentifier.DocumentUri });
            changes.Add(new TextDocumentEdit
            {
                TextDocument = cssDocumentIdentifier,
                Edits = [LspFactory.CreateTextEdit(position: (0, 0), cssContent)]
            });
        }
 
        return new WorkspaceEdit
        {
            DocumentChanges = changes.ToArray(),
        };
    }
 
    private void GetLastLineNumberAndLength(string cssFilePath, out int lastLineNumber, out int lastLineLength)
    {
        using var stream = _fileSystem.OpenReadStream(cssFilePath);
        GetLastLineNumberAndLength(stream, bufferSize: 4096, out lastLineNumber, out lastLineLength);
    }
 
    private static void GetLastLineNumberAndLength(Stream stream, int bufferSize, out int lastLineNumber, out int lastLineLength)
    {
        lastLineNumber = 0;
        lastLineLength = 0;
 
        using var _ = ArrayPool<char>.Shared.GetPooledArray(bufferSize, out var buffer);
        using var reader = new StreamReader(stream);
 
        var currLineLength = 0;
        var currLineNumber = 0;
 
        int charsRead;
        while ((charsRead = reader.Read(buffer, 0, buffer.Length)) > 0)
        {
            var chunk = buffer.AsSpan(0, charsRead);
            while (true)
            {
                // Since we're only concerned with the last line length, we don't need to worry about \r\n. Strictly speaking,
                // we're incorrectly counting the \r in the line length, but since the last line can't end with a \n (since that
                // starts a new line) it doesn't actually change the output of the method.
                var index = chunk.IndexOf('\n');
                if (index == -1)
                {
                    currLineLength += chunk.Length;
                    break;
                }
 
                currLineNumber++;
                currLineLength = 0;
                chunk = chunk[(index + 1)..];
            }
        }
 
        lastLineNumber = currLineNumber;
        lastLineLength = currLineLength;
    }
 
    internal readonly struct TestAccessor
    {
        public static void GetLastLineNumberAndLength(Stream stream, int bufferSize, out int lastLineNumber, out int lastLineLength)
        {
            ExtractToCssCodeActionResolver.GetLastLineNumberAndLength(stream, bufferSize, out lastLineNumber, out lastLineLength);
        }
    }
}