File: Language\CodeGeneration\CodeWriter.cs
Web Access
Project: src\src\Razor\src\Compiler\Microsoft.CodeAnalysis.Razor.Compiler\src\Microsoft.CodeAnalysis.Razor.Compiler.csproj (Microsoft.CodeAnalysis.Razor.Compiler)
// 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.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Runtime.CompilerServices;
using System.Text;
using Microsoft.CodeAnalysis.Text;
 
namespace Microsoft.AspNetCore.Razor.Language.CodeGeneration;
 
public sealed partial class CodeWriter : IDisposable
{
    // This is the size of each "page", which are arrays of ReadOnlyMemory<char>.
    // This number was chosen arbitrarily as a "best guess". If changed, care should be
    // taken to ensure that pages are not allocated on the LOH. ReadOnlyMemory<char>
    // takes up 16 bytes, so a page size of 1000 is 16k.
    private const int MinimumPageSize = 1000;
 
    // Rather than using a StringBuilder, we maintain a linked list of pages, which are arrays
    // of "chunks of text", represented by ReadOnlyMemory<char>. This avoids copying strings
    // into a StringBuilder's internal char arrays only to copy them out later in
    // StringBuilder.ToString(). This also avoids string duplication by holding onto the strings
    // themselves. So, if the same string instance is added multiple times, we won't duplicate it
    // each time. Instead, we'll hold a ReadOnlyMemory<char> for the string.
    //
    // Note that LinkedList<T> was chosen to avoid copying for especially large generated code files.
    // In addition, because LinkedList<T> provides direct access to the last element, appending
    // is extremely efficient.
    private readonly LinkedList<ReadOnlyMemory<char>[]> _pages;
    private int _pageOffset;
    private char? _lastChar;
 
    private string _newLine;
    private int _indentSize;
    private ReadOnlyMemory<char> _indentString;
 
    private int _absoluteIndex;
    private int _currentLineIndex;
    private int _currentLineCharacterIndex;
 
    public CodeWriter()
        : this(RazorCodeGenerationOptions.Default)
    {
    }
 
    public CodeWriter(RazorCodeGenerationOptions options)
    {
        SetNewLine(options.NewLine);
        IndentWithTabs = options.IndentWithTabs;
        TabSize = options.IndentSize;
 
        _indentSize = 0;
        _indentString = ReadOnlyMemory<char>.Empty;
 
        _pages = new();
    }
 
    public void Dispose()
    {
        foreach (var page in _pages)
        {
            ArrayPool<ReadOnlyMemory<char>>.Shared.Return(page, clearArray: true);
        }
 
        _pages.Clear();
    }
 
    private void AddTextChunk(ReadOnlyMemory<char> value)
    {
        if (value.Length == 0)
        {
            return;
        }
 
        // If we're at the start of a page, we need to add the page first.
        ReadOnlyMemory<char>[] lastPage;
 
        if (_pageOffset == 0)
        {
            lastPage = ArrayPool<ReadOnlyMemory<char>>.Shared.Rent(MinimumPageSize);
            _pages.AddLast(lastPage);
        }
        else
        {
            lastPage = _pages.Last!.Value;
        }
 
        // Add our chunk of text (the ReadOnlyMemory<char>) and increment the offset.
        lastPage[_pageOffset] = value;
        _pageOffset++;
 
        // We've reached the end of a page, so we reset the offset to 0.
        // This will cause a new page to be added next time.
        // _pageOffset is checked against the lastPage.Length as the Rent call that
        // return that array may return an array longer that MinimumPageSize.
        if (_pageOffset == lastPage.Length)
        {
            _pageOffset = 0;
        }
 
        // Remember the last character of the text chunk we just added.
        _lastChar = value.Span[^1];
    }
 
    public int CurrentIndent
    {
        get => _indentSize;
        set
        {
            ArgHelper.ThrowIfNegative(value);
 
            if (_indentSize != value)
            {
                _indentSize = value;
                _indentString = IndentCache.GetIndentString(value, IndentWithTabs, TabSize);
            }
        }
    }
 
    // Because of how _absoluteIndex is computed, it is effectively the length
    // of what has been written.
    public int Length => _absoluteIndex;
 
    public string NewLine
    {
        get => _newLine;
        set => SetNewLine(value);
    }
 
    [MemberNotNull(nameof(_newLine))]
    private void SetNewLine(string value)
    {
        ArgHelper.ThrowIfNull(value);
 
        if (value != "\r\n" && value != "\n")
        {
            throw new ArgumentException(Resources.FormatCodeWriter_InvalidNewLine(value), nameof(value));
        }
 
        _newLine = value;
    }
 
    public bool IndentWithTabs { get; }
 
    public int TabSize { get; }
 
    public SourceLocation Location => new(_absoluteIndex, _currentLineIndex, _currentLineCharacterIndex);
 
    public char this[int index]
    {
        get
        {
            // This Debug.Fail(...) is present because no Razor code currently accesses this
            // indexer and it isn't implemented efficiently. All Razor code that previously
            // used the indexer were really just inspecting the last char, which is now exposed separately.
            Debug.Fail("Do not use this indexer without reimplementing it more efficiently.");
 
            foreach (var page in _pages)
            {
                foreach (var chars in page)
                {
                    if (index < chars.Length)
                    {
                        return chars.Span[index];
                    }
 
                    index -= chars.Length;
                }
            }
 
            throw new IndexOutOfRangeException(nameof(index));
        }
    }
 
    public char? LastChar => _lastChar;
 
    public CodeWriter Indent(int size)
    {
        if (size == 0 || LastChar is not '\n')
        {
            return this;
        }
 
        var indentString = size == _indentSize
            ? _indentString
            : IndentCache.GetIndentString(size, IndentWithTabs, TabSize);
 
        AddTextChunk(indentString);
 
        var indentLength = indentString.Length;
        _currentLineCharacterIndex += indentLength;
        _absoluteIndex += indentLength;
 
        return this;
    }
 
    public CodeWriter Write(string value)
    {
        ArgHelper.ThrowIfNull(value);
 
        return WriteCore(value.AsMemory());
    }
 
    public CodeWriter Write(ReadOnlyMemory<char> value)
        => WriteCore(value);
 
    public CodeWriter Write(string value, int startIndex, int count)
    {
        ArgHelper.ThrowIfNull(value);
        ArgHelper.ThrowIfNegative(startIndex);
        ArgHelper.ThrowIfNegative(count);
        ArgHelper.ThrowIfGreaterThan(startIndex, value.Length - count);
 
        return WriteCore(value.AsMemory(startIndex, count));
    }
 
    internal CodeWriter Write<T>(T value)
        where T : IWriteableValue
    {
        value.WriteTo(this);
 
        return this;
    }
 
    public CodeWriter Write([InterpolatedStringHandlerArgument("")] ref WriteInterpolatedStringHandler handler)
        => this;
 
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    private CodeWriter WriteCore(ReadOnlyMemory<char> value, bool allowIndent = true)
    {
        if (value.IsEmpty)
        {
            return this;
        }
 
        if (allowIndent)
        {
            Indent(_indentSize);
        }
 
        var lastChar = _lastChar;
 
        AddTextChunk(value);
 
        var span = value.Span;
 
        _absoluteIndex += span.Length;
 
        // Check the last character *before* the write and the first character of the span that
        // was written to determine whether this is a new-line that is spread across two writes.
        if (lastChar == '\r' && span[0] == '\n')
        {
            // Skip the first character of span to ensure that it isn't considered in the following
            // line break detection loop.
            span = span[1..];
        }
 
        // Iterate the span, stopping at each occurrence of a new-line character.
        // This lets us count the new-line occurrences and keep the index of the last one.
        int newLineIndex;
        while ((newLineIndex = span.IndexOfAny('\r', '\n')) >= 0)
        {
            _currentLineIndex++;
            _currentLineCharacterIndex = 0;
 
            newLineIndex++;
 
            // We might have stopped at a \r, so check if it's followed by \n and then advance the index.
            // Otherwise, we'll count this line break twice.
            if (newLineIndex < span.Length &&
                span[newLineIndex - 1] == '\r' &&
                span[newLineIndex] == '\n')
            {
                newLineIndex++;
            }
 
            span = span[newLineIndex..];
        }
 
        _currentLineCharacterIndex += span.Length;
 
        return this;
    }
 
    public CodeWriter WriteLine()
        => WriteCore(_newLine.AsMemory(), allowIndent: false);
 
    public CodeWriter WriteLine(ReadOnlyMemory<char> value)
        => WriteCore(value).WriteLine();
 
    public CodeWriter WriteLine(string value)
    {
        ArgHelper.ThrowIfNull(value);
 
        return WriteCore(value.AsMemory()).WriteLine();
    }
 
    public CodeWriter WriteLine([InterpolatedStringHandlerArgument("")] ref WriteInterpolatedStringHandler handler)
        => WriteLine();
 
    public SourceText GetText()
    {
        using var reader = new Reader(_pages, Length);
        return SourceText.From(reader, Length, Encoding.UTF8);
    }
 
    // Internal for testing
    internal static TextReader GetTestTextReader(LinkedList<ReadOnlyMemory<char>[]> pages)
    {
        return new Reader(pages, pages.Count);
    }
 
    private sealed class Reader(LinkedList<ReadOnlyMemory<char>[]> pages, int length) : TextReader
    {
        private LinkedListNode<ReadOnlyMemory<char>[]>? _page = pages.First;
        private int _remainingLength = length;
        private int _chunkIndex;
        private int _charIndex;
 
        public override int Read()
        {
            if (!TryGetNextCharReadLocation(out var page, out var chunkIndex, out var charIndex))
            {
                return -1;
            }
 
            _page = page;
            _chunkIndex = chunkIndex;
            _charIndex = charIndex + 1; // Increment the char index for the next read.
            _remainingLength--;
 
            return page.Value[chunkIndex].Span[charIndex];
        }
 
        public override int Peek()
        {
            if (!TryGetNextCharReadLocation(out var page, out var chunkIndex, out var charIndex))
            {
                return -1;
            }
 
            return page.Value[chunkIndex].Span[charIndex];
        }
 
        private bool TryGetNextCharReadLocation([NotNullWhen(true)] out LinkedListNode<ReadOnlyMemory<char>[]>? page, out int chunkIndex, out int charIndex)
        {
            page = _page;
            chunkIndex = _chunkIndex;
            charIndex = _charIndex;
 
            if (page is null)
            {
                return false;
            }
 
            do
            {
                var chunks = page.Value.AsSpan(chunkIndex);
 
                foreach (var chunk in chunks)
                {
                    if (charIndex < chunk.Length)
                    {
                        return true;
                    }
 
                    chunkIndex++;
                    charIndex = 0;
                }
 
                page = page.Next;
                chunkIndex = 0;
                charIndex = 0;
            }
            while (page is not null);
 
            chunkIndex = -1;
            charIndex = -1;
 
            return false;
        }
 
        public override int Read(char[] buffer, int index, int count)
        {
            ArgHelper.ThrowIfNull(buffer);
            ArgHelper.ThrowIfNegative(index);
            ArgHelper.ThrowIfNegative(count);
 
            if (buffer.Length - index < count)
            {
                throw new ArgumentException($"{nameof(count)} is greater than the number of elements from {nameof(index)} to the end of {nameof(buffer)}.");
            }
 
            if (_page is null)
            {
                return 0;
            }
 
            var destination = buffer.AsSpan(index, count);
            var charsWritten = 0;
 
            var page = _page;
            var chunkIndex = _chunkIndex;
            var charIndex = _charIndex;
 
            Debug.Assert(chunkIndex >= 0);
            Debug.Assert(charIndex >= 0);
 
            do
            {
                var chunks = page.Value.AsSpan(chunkIndex);
                var isFirst = true;
 
                foreach (var chunk in chunks)
                {
                    if (destination.IsEmpty)
                    {
                        // If we have no more space in the destination, we're done.
                        break;
                    }
 
                    var source = chunk.Span;
 
                    // Slice if the first chunk is partial. Note that this only occurs for the first chunk.
                    if (isFirst)
                    {
                        isFirst = false;
 
                        if (charIndex > 0)
                        {
                            source = source[charIndex..];
                        }
                    }
 
                    // Are we about to write past the end of the buffer? If so, adjust source.
                    // This will be the last chunk we write, so be sure to update charIndex.
                    if (source.Length > destination.Length)
                    {
                        source = source[..destination.Length];
                        charIndex += source.Length;
                    }
                    else
                    {
                        chunkIndex++;
                        charIndex = 0;
                    }
 
                    if (source.IsEmpty)
                    {
                        continue;
                    }
 
                    source.CopyTo(destination);
                    destination = destination[source.Length..];
 
                    charsWritten += source.Length;
                }
 
                if (destination.IsEmpty)
                {
                    break;
                }
 
                page = page.Next;
                chunkIndex = 0;
                charIndex = 0;
            }
            while (page is not null);
 
            if (page is not null)
            {
                _page = page;
                _chunkIndex = chunkIndex;
                _charIndex = charIndex;
            }
            else
            {
                _page = null;
                _chunkIndex = -1;
                _charIndex = -1;
            }
 
            _remainingLength -= charsWritten;
 
            return charsWritten;
        }
 
        public override string ReadToEnd()
        {
            if (_page is null)
            {
                return string.Empty;
            }
 
            var result = string.Create(_remainingLength, (_page, _chunkIndex, _charIndex), static (destination, state) =>
            {
                var (page, chunkIndex, charIndex) = state;
 
                Debug.Assert(page is not null);
                Debug.Assert(chunkIndex >= 0);
                Debug.Assert(charIndex >= 0);
 
                // Use the current chunk index to slice the first set of chunks.
                var chunks = page.Value.AsSpan(chunkIndex);
 
                do
                {
                    foreach (var chunk in chunks)
                    {
                        var source = chunk.Span;
 
                        // Slice the first chunk if it's partial.
                        if (charIndex > 0)
                        {
                            source = source[charIndex..];
                            charIndex = 0;
                        }
 
                        if (source.IsEmpty)
                        {
                            continue;
                        }
 
                        source.CopyTo(destination);
                        destination = destination[source.Length..];
                    }
 
                    page = page.Next;
                    chunks = (page?.Value ?? []).AsSpan();
                }
                while (page is not null);
 
                Debug.Assert(destination.Length == 0, "We didn't fill the whole span!");
            });
 
            _page = null;
            _chunkIndex = -1;
            _charIndex = 1;
            _remainingLength = 0;
 
            return result;
        }
    }
}