diff --git a/src/Microsoft.AspNetCore.Razor.Evolution/DefaultRazorSourceDocument.cs b/src/Microsoft.AspNetCore.Razor.Evolution/DefaultRazorSourceDocument.cs index f280b62f4f..01b5866d92 100644 --- a/src/Microsoft.AspNetCore.Razor.Evolution/DefaultRazorSourceDocument.cs +++ b/src/Microsoft.AspNetCore.Razor.Evolution/DefaultRazorSourceDocument.cs @@ -2,13 +2,17 @@ // 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.Diagnostics; using System.Text; +using Microsoft.AspNetCore.Razor.Evolution.Legacy; namespace Microsoft.AspNetCore.Razor.Evolution { internal class DefaultRazorSourceDocument : RazorSourceDocument { private readonly string _content; + private readonly RazorSourceLineCollection _lines; public DefaultRazorSourceDocument(string content, Encoding encoding, string filename) { @@ -25,6 +29,8 @@ namespace Microsoft.AspNetCore.Razor.Evolution _content = content; Encoding = encoding; Filename = filename; + + _lines = new LineCollection(this, LineCollection.GetLineStarts(content)); } public override char this[int position] => _content[position]; @@ -35,6 +41,8 @@ namespace Microsoft.AspNetCore.Razor.Evolution public override int Length => _content.Length; + public override RazorSourceLineCollection Lines => _lines; + public override void CopyTo(int sourceIndex, char[] destination, int destinationIndex, int count) { if (destination == null) @@ -64,5 +72,118 @@ namespace Microsoft.AspNetCore.Razor.Evolution _content.CopyTo(sourceIndex, destination, destinationIndex, count); } + + private class LineCollection : RazorSourceLineCollection + { + private readonly DefaultRazorSourceDocument _document; + private readonly int[] _lineStarts; + + public LineCollection(DefaultRazorSourceDocument document, int[] lineStarts) + { + _document = document; + _lineStarts = lineStarts; + } + + public override int Count => _lineStarts.Length; + + public override int GetLineLength(int index) + { + if (index < 0 || index >= _lineStarts.Length) + { + throw new IndexOutOfRangeException(nameof(index)); + } + + if (index == _lineStarts.Length - 1) + { + // Last line is special. + return _document.Length - _lineStarts[index]; + } + + return _lineStarts[index + 1] - _lineStarts[index]; + } + + internal override SourceLocation GetLocation(int position) + { + if (position < 0 || position >= _document.Length) + { + throw new IndexOutOfRangeException(nameof(position)); + } + + var index = Array.BinarySearch(_lineStarts, position); + if (index >= 0) + { + // We have an exact match for the start of a line. + Debug.Assert(_lineStarts[index] == position); + + return new SourceLocation(_document.Filename, position, index, characterIndex: 0); + } + + + // Index is the complement of the line *after* the one we want, because BinarySearch tells + // us where we'd put position *if* it were the start of a line. + index = (~index) - 1; + if (index == -1) + { + // There's no preceding line, so it's based on the start of the string + return new SourceLocation(_document.Filename, position, 0, position); + } + else + { + var characterIndex = position - _lineStarts[index]; + return new SourceLocation(_document.Filename, position, index, characterIndex); + } + } + + public static int[] GetLineStarts(string text) + { + var starts = new List(); + + // We always consider a document to have at least a 0th line, even if it's empty. + starts.Add(0); + + var unprocessedCR = false; + + // Length - 1 because we don't care if there was a linebreak as the last character. + for (var i = 0; i < text.Length - 1; i++) + { + var c = text[i]; + var isLineBreak = false; + + switch (c) + { + case '\r': + unprocessedCR = true; + continue; + + case '\n': + unprocessedCR = false; + isLineBreak = true; + break; + + case '\u0085': + case '\u2028': + case '\u2029': + isLineBreak = true; + break; + + } + + if (unprocessedCR) + { + // If we get here it means that we had a CR followed by something other than an LF. + // Add the CR as a line break. + starts.Add(i); + unprocessedCR = false; + } + + if (isLineBreak) + { + starts.Add(i + 1); + } + } + + return starts.ToArray(); + } + } } } diff --git a/src/Microsoft.AspNetCore.Razor.Evolution/RazorSourceDocument.cs b/src/Microsoft.AspNetCore.Razor.Evolution/RazorSourceDocument.cs index 8ac389e999..6ec2a48c6b 100644 --- a/src/Microsoft.AspNetCore.Razor.Evolution/RazorSourceDocument.cs +++ b/src/Microsoft.AspNetCore.Razor.Evolution/RazorSourceDocument.cs @@ -17,6 +17,8 @@ namespace Microsoft.AspNetCore.Razor.Evolution public abstract int Length { get; } + public abstract RazorSourceLineCollection Lines { get; } + public abstract void CopyTo(int sourceIndex, char[] destination, int destinationIndex, int count); public static RazorSourceDocument ReadFrom(Stream stream, string filename) diff --git a/src/Microsoft.AspNetCore.Razor.Evolution/RazorSourceLineCollection.cs b/src/Microsoft.AspNetCore.Razor.Evolution/RazorSourceLineCollection.cs new file mode 100644 index 0000000000..2a3dc6b358 --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Evolution/RazorSourceLineCollection.cs @@ -0,0 +1,16 @@ +// 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 Microsoft.AspNetCore.Razor.Evolution.Legacy; + +namespace Microsoft.AspNetCore.Razor.Evolution +{ + public abstract class RazorSourceLineCollection + { + public abstract int Count { get; } + + public abstract int GetLineLength(int index); + + internal abstract SourceLocation GetLocation(int position); + } +} diff --git a/test/Microsoft.AspNetCore.Razor.Evolution.Test/DefaultRazorSourceDocumentTest.cs b/test/Microsoft.AspNetCore.Razor.Evolution.Test/DefaultRazorSourceDocumentTest.cs index 9d6909c9f7..74ff8bea26 100644 --- a/test/Microsoft.AspNetCore.Razor.Evolution.Test/DefaultRazorSourceDocumentTest.cs +++ b/test/Microsoft.AspNetCore.Razor.Evolution.Test/DefaultRazorSourceDocumentTest.cs @@ -151,5 +151,295 @@ namespace Microsoft.AspNetCore.Razor.Evolution Assert.Equal("Hi", copiedContent); } } + + [Fact] + public void Lines_Count_EmptyDocument() + { + // Arrange + var content = string.Empty; + var document = new DefaultRazorSourceDocument(content, Encoding.UTF8, filename: null); + + // Act + var actual = document.Lines.Count; + + // Assert + Assert.Equal(1, actual); + } + + [Fact] + public void Lines_GetLineLength_EmptyDocument() + { + // Arrange + var content = string.Empty; + var document = new DefaultRazorSourceDocument(content, Encoding.UTF8, filename: null); + + // Act + var actual = document.Lines.GetLineLength(0); + + // Assert + Assert.Equal(0, actual); + } + + [Fact] + public void Lines_GetLineLength_TrailingNewlineDoesNotStartNewLine() + { + // Arrange + var content = "hello\n"; + var document = new DefaultRazorSourceDocument(content, Encoding.UTF8, filename: null); + + // Act + var actual = document.Lines.GetLineLength(0); + + // Assert + Assert.Equal(6, actual); + } + + [Fact] + public void Lines_GetLineLength_TrailingNewlineDoesNotStartNewLine_CRLF() + { + // Arrange + var content = "hello\r\n"; + var document = new DefaultRazorSourceDocument(content, Encoding.UTF8, filename: null); + + // Act + var actual = document.Lines.GetLineLength(0); + + // Assert + Assert.Equal(7, actual); + } + + [Fact] + public void Lines_Simple_Document() + { + // Arrange + var content = new StringBuilder() + .Append("The quick brown").Append('\n') + .Append("fox").Append("\r\n") + .Append("jumps over the lazy dog.") + .ToString(); + + var document = new DefaultRazorSourceDocument(content, Encoding.UTF8, filename: null); + + // Act + var actual = GetAllLineMappings(document); + + // Assert + Assert.Equal(new int[]{ 16, 5, 24 }, actual); + } + + [Fact] + public void Lines_CRLF_OnlyCountsAsASingleNewLine() + { + // Arrange + var content = "Hello\r\nWorld!"; + + var document = new DefaultRazorSourceDocument(content, Encoding.UTF8, filename: null); + + // Act + var actual = GetAllLineMappings(document); + + // Assert + Assert.Equal(new int[] { 7, 6 }, actual); + } + + [Fact] + public void Lines_CR_IsNewLine() + { + // Arrange + var content = "Hello\rWorld!"; + + var document = new DefaultRazorSourceDocument(content, Encoding.UTF8, filename: null); + + // Act + var actual = GetAllLineMappings(document); + + // Assert + Assert.Equal(new int[] { 6, 6 }, actual); + } + + // CR handling is stateful in the parser, making sure we properly reset the state. + [Fact] + public void Lines_CR_IsNewLine_MultipleCRs() + { + // Arrange + var content = "Hello\rBig\r\nWorld!"; + + var document = new DefaultRazorSourceDocument(content, Encoding.UTF8, filename: null); + + // Act + var actual = GetAllLineMappings(document); + + // Assert + Assert.Equal(new int[] { 6, 5, 6 }, actual); + } + + [Fact] + public void Lines_LF_IsNewLine() + { + // Arrange + var content = "Hello\nWorld!"; + + var document = new DefaultRazorSourceDocument(content, Encoding.UTF8, filename: null); + + // Act + var actual = GetAllLineMappings(document); + + // Assert + Assert.Equal(new int[] { 6, 6 }, actual); + } + + [Fact] + public void Lines_Unicode0085_IsNewLine() + { + // Arrange + var content = "Hello\u0085World!"; + + var document = new DefaultRazorSourceDocument(content, Encoding.UTF8, filename: null); + + // Act + var actual = GetAllLineMappings(document); + + // Assert + Assert.Equal(new int[] { 6, 6 }, actual); + } + + [Fact] + public void Lines_Unicode2028_IsNewLine() + { + // Arrange + var content = "Hello\u2028World!"; + + var document = new DefaultRazorSourceDocument(content, Encoding.UTF8, filename: null); + + // Act + var actual = GetAllLineMappings(document); + + // Assert + Assert.Equal(new int[] { 6, 6 }, actual); + } + + [Fact] + public void Lines_Unicode2029_IsNewLine() + { + // Arrange + var content = "Hello\u2029World!"; + + var document = new DefaultRazorSourceDocument(content, Encoding.UTF8, filename: null); + + // Act + var actual = GetAllLineMappings(document); + + // Assert + Assert.Equal(new int[] { 6, 6 }, actual); + } + + [Fact] + public void Lines_GetLocation_IncludesAbsoluteIndexAndDocument() + { + // Arrange + var content = "Hello, World!"; + + var document = new DefaultRazorSourceDocument(content, Encoding.UTF8, filename: "Hi.cshtml"); + + // Act + var actual = document.Lines.GetLocation(1); + + // Assert + Assert.Equal("Hi.cshtml", actual.FilePath); + Assert.Equal(1, actual.AbsoluteIndex); + } + + // Beginnings of lines are special because the BinarySearch in the implementation + // will succeed. It's a different code path. + [Fact] + public void Lines_GetLocation_FirstCharacter() + { + // Arrange + var content = "Hello\nBig\r\nWorld!"; + + var document = new DefaultRazorSourceDocument(content, Encoding.UTF8, filename: null); + + // Act + var actual = document.Lines.GetLocation(0); + + // Assert + Assert.Equal(0, actual.LineIndex); + Assert.Equal(0, actual.CharacterIndex); + } + + [Fact] + public void Lines_GetLocation_EndOfFirstLine() + { + // Arrange + var content = "Hello\nBig\r\nWorld!"; + + var document = new DefaultRazorSourceDocument(content, Encoding.UTF8, filename: null); + + // Act + var actual = document.Lines.GetLocation(5); + + // Assert + Assert.Equal(0, actual.LineIndex); + Assert.Equal(5, actual.CharacterIndex); + } + + [Fact] + public void Lines_GetLocation_InteriorLine() + { + // Arrange + var content = "Hello\nBig\r\nWorld!"; + + var document = new DefaultRazorSourceDocument(content, Encoding.UTF8, filename: null); + + // Act + var actual = document.Lines.GetLocation(7); + + // Assert + Assert.Equal(1, actual.LineIndex); + Assert.Equal(1, actual.CharacterIndex); + } + + [Fact] + public void Lines_GetLocation_StartOfLastLine() + { + // Arrange + var content = "Hello\nBig\r\nWorld!"; + + var document = new DefaultRazorSourceDocument(content, Encoding.UTF8, filename: null); + + // Act + var actual = document.Lines.GetLocation(11); + + // Assert + Assert.Equal(2, actual.LineIndex); + Assert.Equal(0, actual.CharacterIndex); + } + + [Fact] + public void Lines_GetLocation_EndOfLastLine() + { + // Arrange + var content = "Hello\nBig\r\nWorld!"; + + var document = new DefaultRazorSourceDocument(content, Encoding.UTF8, filename: null); + + // Act + var actual = document.Lines.GetLocation(16); + + // Assert + Assert.Equal(2, actual.LineIndex); + Assert.Equal(5, actual.CharacterIndex); + } + + private static int[] GetAllLineMappings(RazorSourceDocument source) + { + var lines = new int[source.Lines.Count]; + for (var i = 0; i < lines.Length; i++) + { + lines[i] = source.Lines.GetLineLength(i); + } + + return lines; + } } }