Fixes #456
This commit is contained in:
Mike Rousos 2021-05-05 16:09:05 -04:00 коммит произвёл GitHub
Родитель 902d37f0da
Коммит cf64a5bd79
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
35 изменённых файлов: 1310 добавлений и 67 удалений

Просмотреть файл

@ -5,7 +5,6 @@ using System;
using System.Collections.Generic;
using System.Linq;
using DiffPlex;
using Microsoft.CodeAnalysis.Text;
namespace Microsoft.DotNet.UpgradeAssistant.Steps.Razor
{
@ -19,9 +18,9 @@ namespace Microsoft.DotNet.UpgradeAssistant.Steps.Razor
/// <summary>
/// Initializes a new instance of the <see cref="DefaultTextMatcher"/> class.
/// </summary>
/// <param name="differ">The IDiffer implementation to use for determining which text replacements correspond with original text in ambiguous cases.</param>
/// <param name="chunker">The IChunker to be used with the differ.</param>
/// </summary>
public DefaultTextMatcher(IDiffer differ, IChunker chunker)
{
_chunker = chunker ?? throw new ArgumentNullException(nameof(chunker));
@ -60,13 +59,13 @@ namespace Microsoft.DotNet.UpgradeAssistant.Steps.Razor
// If both groups have the same number of elements, then they pair in order
if (originalTexts.Count() == newTexts.Count())
{
AddReplacementCandidates(replacements, originalTexts.Zip(newTexts, (original, updated) => new TextReplacement(original, SourceText.From(updated))));
AddReplacementCandidates(replacements, originalTexts.Zip(newTexts, (original, updated) => new TextReplacement(original, updated)));
}
// If there are no new texts, then the original elements all pair with empty source text
else if (!newTexts.Any())
{
AddReplacementCandidates(replacements, originalTexts.Select(t => new TextReplacement(t, SourceText.From(string.Empty))));
AddReplacementCandidates(replacements, originalTexts.Select(t => new TextReplacement(t, string.Empty)));
}
// This is the tricky one. If there are less updated code blocks than original code blocks, it will be necesary to guess which original code blocks
@ -85,17 +84,17 @@ namespace Microsoft.DotNet.UpgradeAssistant.Steps.Razor
/// </summary>
private static void AddReplacementCandidates(List<TextReplacement> replacements, IEnumerable<TextReplacement> candidates)
{
replacements.AddRange(candidates.Where(c => !c.NewText.ContentEquals(c.OriginalText)));
replacements.AddRange(candidates.Where(c => !c.NewText.Equals(c.OriginalText, StringComparison.Ordinal)));
}
/// <summary>
/// Recursively enumerate all the possible ways the source and updated texts can match
/// Recursively enumerate all the possible ways the source and updated texts can match.
/// </summary>
private static IEnumerable<IEnumerable<TextReplacement>> GetAllPossiblePairings(IEnumerable<MappedSubText> originalTexts, IEnumerable<string> newTexts)
{
if (originalTexts.Count() == newTexts.Count())
{
yield return originalTexts.Zip(newTexts, (original, updated) => new TextReplacement(original, SourceText.From(updated)));
yield return originalTexts.Zip(newTexts, (original, updated) => new TextReplacement(original, updated));
}
else
{
@ -112,7 +111,7 @@ namespace Microsoft.DotNet.UpgradeAssistant.Steps.Razor
}
/// <summary>
/// Finds the total size of diff (sum or all inserts and deletes) between old and new texts in an enumerable of text replacements
/// Finds the total size of diff (sum or all inserts and deletes) between old and new texts in an enumerable of text replacements.
/// </summary>
private int GetTotalDiffSize(IEnumerable<TextReplacement> replacements) =>
replacements.Sum(r =>

Просмотреть файл

@ -7,6 +7,6 @@ namespace Microsoft.DotNet.UpgradeAssistant.Steps.Razor
{
public interface ITextReplacer
{
void ApplyTextReplacements(IList<TextReplacement> replacements);
void ApplyTextReplacements(IEnumerable<TextReplacement> replacements);
}
}
}

Просмотреть файл

@ -86,14 +86,14 @@ namespace Microsoft.DotNet.UpgradeAssistant.Steps.Razor
public bool Equals(MappedSubText other) =>
other != null &&
Text.ContentEquals(other.Text) &&
SourceLocation.Equals(other.SourceLocation, StringComparison.Ordinal);
SourceLocation.Equals(other.SourceLocation, StringComparison.OrdinalIgnoreCase);
/// <inheritdoc/>
public override int GetHashCode()
{
var hashcode = default(HashCode);
hashcode.Add(Text.ToString(), StringComparer.Ordinal);
hashcode.Add(SourceLocation, StringComparer.Ordinal);
hashcode.Add(SourceLocation, StringComparer.OrdinalIgnoreCase);
return hashcode.ToHashCode();
}
}

Просмотреть файл

@ -147,10 +147,11 @@ namespace Microsoft.DotNet.UpgradeAssistant.Steps.Razor
// Identify changed code sections
var textReplacements = await GetReplacements(originalProject, project.Documents.Where(d => generatedFilePaths.Contains(d.FilePath)), token).ConfigureAwait(false);
var z = project.Documents.Where(d => generatedFilePaths.Contains(d.FilePath)).Select(d => d.GetTextAsync().Result.ToString()).ToArray();
// Update cshtml based on changes made to generated source code
// These are applied after finding all of them so that they can be applied in reverse line order
_logger.LogDebug("Applying {ReplacemenCount} updates to Razor documents based on changes made by code fix providers", textReplacements.Count);
_logger.LogDebug("Applying {ReplacementCount} updates to Razor documents based on changes made by code fix providers", textReplacements.Count());
_textReplacer.ApplyTextReplacements(textReplacements);
await FixUpProjectFileAsync(context, token).ConfigureAwait(false);
@ -162,7 +163,7 @@ namespace Microsoft.DotNet.UpgradeAssistant.Steps.Razor
return new FileUpdaterResult(true, textReplacements.Select(r => r.FilePath).Distinct());
}
private async Task<IList<TextReplacement>> GetReplacements(Project originalProject, IEnumerable<Document> updatedDocuments, CancellationToken token)
private async Task<IEnumerable<TextReplacement>> GetReplacements(Project originalProject, IEnumerable<Document> updatedDocuments, CancellationToken token)
{
var replacements = new List<TextReplacement>();
foreach (var updatedDocument in updatedDocuments)
@ -185,7 +186,7 @@ namespace Microsoft.DotNet.UpgradeAssistant.Steps.Razor
}
}
return replacements;
return replacements.Distinct();
}
private static string GetGeneratedFilePath(RazorCodeDocument doc) => $"{doc.Source.FilePath}.cs";

Просмотреть файл

@ -16,15 +16,15 @@ namespace Microsoft.DotNet.UpgradeAssistant.Steps.Razor
/// </summary>
public class RazorTextReplacer : ITextReplacer
{
private static readonly Regex UsingBlockRegex = new Regex(@"^(\s*using\s+(?<namespace>.+?);+\s*)+$", RegexOptions.Compiled);
private static readonly Regex UsingNamespaceRegex = new Regex(@"using\s+(?<namespace>.+?);", RegexOptions.Compiled);
private static readonly Regex UsingBlockRegex = new(@"^(\s*using\s+(?<namespace>.+?);+\s*)+$", RegexOptions.Compiled);
private static readonly Regex UsingNamespaceRegex = new(@"using\s+(?<namespace>.+?);", RegexOptions.Compiled);
private readonly ILogger<RazorTextReplacer> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="RazorTextReplacer"/> class.
/// </summary>
/// <param name="logger"></param>
/// <param name="logger">Logger for logging diagnostics.</param>
public RazorTextReplacer(ILogger<RazorTextReplacer> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
@ -34,36 +34,57 @@ namespace Microsoft.DotNet.UpgradeAssistant.Steps.Razor
/// Updates source code in Razor documents based on provider TextReplacements, accounting for Razor source code transition syntax.
/// </summary>
/// <param name="replacements">The text replacements to apply.</param>
public void ApplyTextReplacements(IList<TextReplacement> replacements)
public void ApplyTextReplacements(IEnumerable<TextReplacement> replacements)
{
if (replacements is null)
{
throw new ArgumentNullException(nameof(replacements));
}
// Load each file with replacements into memory and update based on replacements
var replacementsByFile = replacements.Distinct().OrderByDescending(t => t.StartingLine).GroupBy(t => t.FilePath);
foreach (var replacementGroup in replacementsByFile)
{
// Read the document as lines instead of all as one string because replacements
// include line offsets.
var documentLines = File.ReadAllLines(replacementGroup.Key);
var documentText = new StringBuilder();
foreach (var line in documentLines)
{
documentText.AppendLine(line);
}
var documentTextStr = File.ReadAllText(replacementGroup.Key);
var lineOffsets = GetLineOffsets(documentTextStr).ToArray();
var documentText = new StringBuilder(documentTextStr);
foreach (var replacement in replacementGroup)
{
_logger.LogInformation("Updating source code in Razor document {FilePath} at line {Line}", replacement.FilePath, replacement.StartingLine);
// If the original text doesn't fit in the lines of the original document, then the replacement is invalid
if (replacement.StartingLine + GetLineCount(replacement.OriginalText) >= lineOffsets.Length)
{
continue;
}
// Start looking for replacements at the start of the indicated line
var startOffset = GetLineOffset(documentLines, replacement.StartingLine);
var startOffset = lineOffsets[replacement.StartingLine];
// Stop looking for replacements at the start of the first line after the indicated line plus the number of lines in the indicated text
var endOffset = GetLineOffset(documentLines, replacement.StartingLine + replacement.OriginalText.Lines.Count);
var endOffset = lineOffsets[replacement.StartingLine + GetLineCount(replacement.OriginalText)];
// Trim the string that's being replaced because code from Razor code blocks will include a couple extra spaces (to make room for @{)
// compared to the source that actually appeared in the cshtml file.
var originalText = replacement.OriginalText.ToString().TrimStart();
var updatedText = replacement.NewText.ToString().TrimStart();
MinimizeReplacement(ref originalText, ref updatedText);
// Generally, it's not necessary to minimize replacements since the text should be the same in both documents
// However, in the specific case of text being added before the first #line pragma in a Razor doc, it's possible
// that C# unrelated to the original document will show up with the replaced text, so minimize replacements only
// in the case that text is being inserted before the original first line.
if (replacement.StartingLine == 0)
{
MinimizeReplacement(ref originalText, ref updatedText);
}
// If the changed text ends with a semi-colon, trim it since the semi-colon won't appear in implicit Razor expressions
if (originalText.Trim().EndsWith(";", StringComparison.Ordinal) && updatedText.Trim().EndsWith(";", StringComparison.Ordinal))
{
originalText = originalText.Trim().Trim(';');
updatedText = updatedText.Trim().Trim(';');
}
// If new text is being added, insert it with correct Razor transition syntax
if (string.IsNullOrWhiteSpace(originalText))
@ -92,8 +113,12 @@ namespace Microsoft.DotNet.UpgradeAssistant.Steps.Razor
{
var implicitExpression = $"@{originalText.Replace(";", string.Empty)}";
var explicitExpression = $"@({originalText.Replace(";", string.Empty).Trim()})";
documentText.Replace(implicitExpression, updatedText, startOffset, endOffset - startOffset);
endOffset = Math.Min(endOffset, documentText.Length);
documentText.Replace(explicitExpression, updatedText, startOffset, endOffset - startOffset);
endOffset = Math.Min(endOffset, documentText.Length);
}
documentText.Replace(originalText, updatedText, startOffset, endOffset - startOffset);
@ -104,22 +129,33 @@ namespace Microsoft.DotNet.UpgradeAssistant.Steps.Razor
}
}
private static int GetLineOffset(string[] lines, int startingLine)
{
var offset = 0;
private static int GetLineCount(string input) => input.Split('\n').Length;
for (var i = 1; i < startingLine && i <= lines.Length; i++)
private static IEnumerable<int> GetLineOffsets(string text)
{
// Pre-line 1
yield return 0;
// Line 1
if (text.Any())
{
// StreamSourceDoc.Lines is 0-based but line directives (as used in MappedSubText) are 1-based,
// so subtract one from i.
offset += lines[i - 1].Length + Environment.NewLine.Length;
yield return 0;
}
return offset;
// Subsequent lines
var index = text.IndexOf('\n');
while (index > -1)
{
yield return index + 1;
index = text.IndexOf('\n', index + 1);
}
// EOF
yield return text.Length;
}
// Removes leading and trailing portions of original and updated that are the same
private static void MinimizeReplacement(ref string original, ref string? updated)
private static void MinimizeReplacement(ref string original, ref string updated)
{
if (updated is null)
{

Просмотреть файл

@ -12,8 +12,8 @@ namespace Microsoft.DotNet.UpgradeAssistant.Steps.Razor
{
internal class RazorUpdaterSubStep : UpgradeStep
{
private RazorUpdaterStep _razorUpdaterStep;
private IUpdater<RazorCodeDocument> _updater;
private readonly RazorUpdaterStep _razorUpdaterStep;
private readonly IUpdater<RazorCodeDocument> _updater;
public override string Id => _updater.Id;
@ -103,6 +103,8 @@ namespace Microsoft.DotNet.UpgradeAssistant.Steps.Razor
/// <summary>
/// Apply upgrade and update Status as necessary.
/// </summary>
/// <param name="context">The upgrade context to apply this step to.</param>
/// <param name="token">A cancellation token.</param>
/// <returns>True if the upgrade step was successfully applied or false if upgrade failed.</returns>
public override async Task<bool> ApplyAsync(IUpgradeContext context, CancellationToken token)
{

Просмотреть файл

@ -2,7 +2,6 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using Microsoft.CodeAnalysis.Text;
namespace Microsoft.DotNet.UpgradeAssistant.Steps.Razor
{
@ -25,20 +24,20 @@ namespace Microsoft.DotNet.UpgradeAssistant.Steps.Razor
/// <summary>
/// Gets the original text that is to be replaced.
/// </summary>
public SourceText OriginalText { get; }
public string OriginalText { get; }
/// <summary>
/// Gets the new text to replace the original texts with.
/// </summary>
public SourceText NewText { get; }
public string NewText { get; }
/// <summary>
/// Initializes a new instance of the <see cref="TextReplacement"/> class, getting the original text, file path, and starting line from an original MappedSubText.
/// </summary>
/// <param name="originalText">A MappedSubText containing the original text and original text location.</param>
/// <param name="newText">The new text to replace the original text with.</param>
public TextReplacement(MappedSubText originalText, SourceText newText)
: this(originalText?.Text ?? throw new ArgumentNullException(nameof(originalText)), newText, originalText.FilePath, originalText.StartingLine)
public TextReplacement(MappedSubText originalText, string newText)
: this(originalText?.Text.ToString() ?? throw new ArgumentNullException(nameof(originalText)), newText, originalText.FilePath, originalText.StartingLine)
{ }
/// <summary>
@ -48,7 +47,7 @@ namespace Microsoft.DotNet.UpgradeAssistant.Steps.Razor
/// <param name="newText">The text to replace the original text with.</param>
/// <param name="filePath">The path to the file text should be replaced in.</param>
/// <param name="startingLine">The line number the original text starts on.</param>
public TextReplacement(SourceText originalText, SourceText newText, string filePath, int startingLine)
public TextReplacement(string originalText, string newText, string filePath, int startingLine)
{
if (string.IsNullOrEmpty(filePath))
{
@ -68,8 +67,10 @@ namespace Microsoft.DotNet.UpgradeAssistant.Steps.Razor
/// <returns>True of the other TextReplacement is equal to this one, false otherwise.</returns>
public bool Equals(TextReplacement? other) =>
other != null &&
NewText.ContentEquals(other.NewText) &&
OriginalText.Equals(other.OriginalText);
FilePath.Equals(other.FilePath, StringComparison.OrdinalIgnoreCase) &&
StartingLine == other.StartingLine &&
NewText.Equals(other.NewText, StringComparison.Ordinal) &&
OriginalText.Equals(other.OriginalText, StringComparison.Ordinal);
/// <inheritdoc/>
public override bool Equals(object obj) => Equals(obj as TextReplacement);
@ -78,8 +79,10 @@ namespace Microsoft.DotNet.UpgradeAssistant.Steps.Razor
public override int GetHashCode()
{
var hashcode = default(HashCode);
hashcode.Add(NewText.ToString(), StringComparer.Ordinal);
hashcode.Add(OriginalText);
hashcode.Add(StartingLine);
hashcode.Add(FilePath, StringComparer.OrdinalIgnoreCase);
hashcode.Add(NewText, StringComparer.Ordinal);
hashcode.Add(OriginalText, StringComparer.Ordinal);
return hashcode.ToHashCode();
}
}

Просмотреть файл

@ -100,7 +100,7 @@ namespace Microsoft.DotNet.UpgradeAssistant.Steps.Backup.Tests
}
private static UpgradeOptions GetDefaultNonInteractiveOptions() =>
new UpgradeOptions
new()
{
NonInteractive = true,
NonInteractiveWait = 0,

Просмотреть файл

@ -0,0 +1,179 @@
// 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.Linq;
using DiffPlex;
using DiffPlex.Chunkers;
using Microsoft.CodeAnalysis.Text;
using Xunit;
namespace Microsoft.DotNet.UpgradeAssistant.Steps.Razor.Tests
{
public class DefaultTextMatcherTests
{
[Fact]
public void CtorNegativeTests()
{
Assert.Throws<ArgumentNullException>("differ", () => new DefaultTextMatcher(null!, new CharacterChunker()));
Assert.Throws<ArgumentNullException>("chunker", () => new DefaultTextMatcher(new Differ(), null!));
}
[Fact]
public void MatchOrderedSubTextsNegativeTests()
{
var matcher = new DefaultTextMatcher(new Differ(), new CharacterChunker());
var originalTexts = new MappedSubText[]
{
GetSubText("Test", "test.txt", 1)
};
var updatedTexts = new string[] { "Test1", "Test2" };
Assert.Throws<ArgumentNullException>("originalTexts", () => matcher.MatchOrderedSubTexts(null!, updatedTexts));
Assert.Throws<ArgumentNullException>("newTexts", () => matcher.MatchOrderedSubTexts(originalTexts, null!));
Assert.Throws<ArgumentException>(() => matcher.MatchOrderedSubTexts(originalTexts, updatedTexts));
}
[Theory]
[MemberData(nameof(MatchOrderedSubTextsData))]
public void MatchOrderedSubTextsPositiveTests(IEnumerable<MappedSubText> originalTexts, IEnumerable<string> newTexts, IEnumerable<TextReplacement> expectedReplacements)
{
// Arrange
var matcher = new DefaultTextMatcher(new Differ(), new CharacterChunker());
// Act
var replacements = matcher.MatchOrderedSubTexts(originalTexts, newTexts);
// Assert
Assert.Collection(replacements, expectedReplacements.Select<TextReplacement, Action<TextReplacement>>(expected => actual => Assert.Equal(expected, actual)).ToArray());
}
public static IEnumerable<object[]> MatchOrderedSubTextsData
{
get
{
// No original or updated texts
yield return new object[]
{
Array.Empty<MappedSubText>(),
Array.Empty<string>(),
Array.Empty<TextReplacement>()
};
// Equal number of original and updated texts
yield return new object[]
{
new MappedSubText[] { GetSubText("A", "A.txt", 10), GetSubText("B", "B.txt", 2), GetSubText("B", "B.txt", 2) },
new string[] { "C", string.Empty, "D" },
new TextReplacement[]
{
new TextReplacement("A", "C", "A.txt", 10),
new TextReplacement("B", string.Empty, "B.txt", 2),
new TextReplacement("B", "D", "B.txt", 2),
}
};
// All texts removed
yield return new object[]
{
new MappedSubText[] { GetSubText("A", "A.txt", 10), GetSubText("B", "B.txt", 2), GetSubText("C", "B.txt", 2) },
Array.Empty<string>(),
new TextReplacement[]
{
new TextReplacement("A", string.Empty, "A.txt", 10),
new TextReplacement("B", string.Empty, "B.txt", 2),
new TextReplacement("C", string.Empty, "B.txt", 2),
}
};
// Unchange texts aren't returned
yield return new object[]
{
new MappedSubText[] { GetSubText("A", "A.txt", 10), GetSubText("B", "B.txt", 2), GetSubText("C", "C.txt", 0) },
new string[] { "D", "B", "C " },
new TextReplacement[]
{
new TextReplacement("A", "D", "A.txt", 10),
new TextReplacement("C", "C ", "C.txt", 0),
}
};
// Some texts removed
yield return new object[]
{
new MappedSubText[] { GetSubText("Cat", "A.txt", 10), GetSubText("Dog", "B.txt", 2), GetSubText("Fish", "C.txt", 1000000000) },
new string[] { "C", "F" },
new TextReplacement[]
{
new TextReplacement("Cat", "C", "A.txt", 10),
new TextReplacement("Dog", string.Empty, "B.txt", 2),
new TextReplacement("Fish", "F", "C.txt", 1000000000),
}
};
// Some texts removed (but pair with different updated text)
yield return new object[]
{
new MappedSubText[] { GetSubText("Cat", "A.txt", 10), GetSubText("Dog", "B.txt", 2), GetSubText("Fish", "C.txt", 1000000000) },
new string[] { "C", "o" },
new TextReplacement[]
{
new TextReplacement("Cat", "C", "A.txt", 10),
new TextReplacement("Dog", "o", "B.txt", 2),
new TextReplacement("Fish", string.Empty, "C.txt", 1000000000),
}
};
// Some texts removed (but pair with different updated text)
yield return new object[]
{
new MappedSubText[] { GetSubText("Cat", "A.txt", 10), GetSubText("Dog", "B.txt", 2), GetSubText("Fish", "C.txt", 1000000000) },
new string[] { "CDg", "-" },
new TextReplacement[]
{
new TextReplacement("Cat", string.Empty, "A.txt", 10),
new TextReplacement("Dog", "CDg", "B.txt", 2),
new TextReplacement("Fish", "-", "C.txt", 1000000000),
}
};
// Original texts can't be reordered
yield return new object[]
{
new MappedSubText[] { GetSubText("Cat", "A.txt", 10), GetSubText("Dog", "B.txt", 2), GetSubText("Fish", "C.txt", 1000000000) },
new string[] { "Fish", "Dog" },
new TextReplacement[]
{
new TextReplacement("Cat", "Fish", "A.txt", 10),
new TextReplacement("Fish", string.Empty, "C.txt", 1000000000),
}
};
// Multiple texts removed; also check that file path comparison is case-insensitive
yield return new object[]
{
new MappedSubText[]
{
GetSubText("Cat", "A.txt", 10),
GetSubText("Dog", "B.txt", 2),
GetSubText("Fish", "C.txt", 1000000000),
GetSubText("Bird Bird", "D.txt", 11),
GetSubText(" ", "E.txt", 2),
},
new string[] { "Cog", " " },
new TextReplacement[]
{
new TextReplacement("Cat", string.Empty, "a.txt", 10),
new TextReplacement("Dog", "Cog", "b.txt", 2),
new TextReplacement("Fish", string.Empty, "c.txt", 1000000000),
new TextReplacement("Bird Bird", string.Empty, "d.txt", 11),
}
};
}
}
private static MappedSubText GetSubText(string text, string filePath, int startingLine) =>
new MappedSubText(SourceText.From(text), filePath, startingLine);
}
}

Просмотреть файл

@ -0,0 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
namespace Microsoft.DotNet.UpgradeAssistant.Steps.Razor.Tests
{
public record LocationLookup(string Path, string? Keyword, int StartOffset = 0, int EndOffset = 0);
}

Просмотреть файл

@ -0,0 +1,75 @@
// Auto-generated by Razor engine
namespace Razor
{
#line hidden
#line 1 "test.cshtml"
using System;
#line default
#line hidden
[global::Microsoft.AspNetCore.Razor.Hosting.RazorSourceChecksumAttribute(@"SHA1", @"38e4e7b9907aaf0dbd917f25e1c2da15895aa64f", @"/test.cshtml")]
public class Template
{
#line hidden
#pragma warning disable 0169
private string __tagHelperStringValueBuffer;
#pragma warning restore 0169
private global::Microsoft.AspNetCore.Razor.Runtime.TagHelpers.TagHelperExecutionContext __tagHelperExecutionContext;
private global::Microsoft.AspNetCore.Razor.Runtime.TagHelpers.TagHelperRunner __tagHelperRunner = new global::Microsoft.AspNetCore.Razor.Runtime.TagHelpers.TagHelperRunner();
private global::Microsoft.AspNetCore.Razor.Runtime.TagHelpers.TagHelperScopeManager __backed__tagHelperScopeManager = null;
private global::Microsoft.AspNetCore.Razor.Runtime.TagHelpers.TagHelperScopeManager __tagHelperScopeManager
{
get
{
if (__backed__tagHelperScopeManager == null)
{
__backed__tagHelperScopeManager = new global::Microsoft.AspNetCore.Razor.Runtime.TagHelpers.TagHelperScopeManager(StartTagHelperWritingScope, EndTagHelperWritingScope);
}
return __backed__tagHelperScopeManager;
}
}
private global::PTagHelper __PTagHelper;
#pragma warning disable 1998
public async override global::System.Threading.Tasks.Task ExecuteAsync()
{
#line 3 "test.cshtml"
var foo = "Hello World!";
#line default
#line hidden
WriteLiteral("\n");
__tagHelperExecutionContext = __tagHelperScopeManager.Begin("p", global::Microsoft.AspNetCore.Razor.TagHelpers.TagMode.StartTagAndEndTag, "38e4e7b9907aaf0dbd917f25e1c2da15895aa64f2096", async () => {
WriteLiteral("Content");
}
);
__PTagHelper = CreateTagHelper<global::PTagHelper>();
__tagHelperExecutionContext.Add(__PTagHelper);
#line 7 "test.cshtml"
__PTagHelper.FooProp = 123;
#line default
#line hidden
__tagHelperExecutionContext.AddTagHelperAttribute("foo", __PTagHelper.FooProp, global::Microsoft.AspNetCore.Razor.TagHelpers.HtmlAttributeValueStyle.DoubleQuotes);
BeginWriteTagHelperAttribute();
#line 7 "test.cshtml"
WriteLiteral(foo);
#line default
#line hidden
__tagHelperStringValueBuffer = EndWriteTagHelperAttribute();
__PTagHelper.BarProp = __tagHelperStringValueBuffer;
__tagHelperExecutionContext.AddTagHelperAttribute("bar", __PTagHelper.BarProp, global::Microsoft.AspNetCore.Razor.TagHelpers.HtmlAttributeValueStyle.SingleQuotes);
await __tagHelperRunner.RunAsync(__tagHelperExecutionContext);
if (!__tagHelperExecutionContext.Output.IsContentModified)
{
await __tagHelperExecutionContext.SetOutputContentAsync();
}
Write(__tagHelperExecutionContext.Output);
__tagHelperExecutionContext = __tagHelperScopeManager.End();
}
#pragma warning restore 1998
}
}
#pragma warning restore 1591

Просмотреть файл

@ -0,0 +1,133 @@
// 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.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;
using Xunit;
namespace Microsoft.DotNet.UpgradeAssistant.Steps.Razor.Tests
{
public class MappedSubTextTests
{
[Theory]
[MemberData(nameof(EqualsTestData))]
public void EqualsTests(MappedSubText a, MappedSubText b, bool expected)
{
Assert.Equal(expected, a.Equals(b));
Assert.Equal(expected, b.Equals(a));
Assert.Equal(expected, a.GetHashCode() == b.GetHashCode());
}
[Fact]
public async Task GetMappedSubTextsNegativeTests()
{
await Assert.ThrowsAsync<ArgumentNullException>(() => MappedSubText.GetMappedSubTextsAsync(null!, "Foo.cs", CancellationToken.None)).ConfigureAwait(true);
}
[Theory]
[MemberData(nameof(GetMappedSubTextsTestData))]
public async Task GetMappedSubTextsTests(string source, string? defaultMapPath, IEnumerable<MappedSubText> expected)
{
// Arrange
using var workspace = new AdhocWorkspace();
var doc = CreateDoc(workspace, source);
// Act
var subTexts = await MappedSubText.GetMappedSubTextsAsync(doc, defaultMapPath, CancellationToken.None).ConfigureAwait(true);
// Assert
Assert.Collection(subTexts, expected.Select<MappedSubText, Action<MappedSubText>>(e => a => Assert.Equal(e, a)).ToArray());
}
private static Document CreateDoc(AdhocWorkspace workspace, string source) =>
workspace.AddProject("TestProject", "C#").AddDocument("TestDocument", SourceText.From(source));
public static IEnumerable<object[]> EqualsTestData =>
new List<object[]>
{
new object[]
{
new MappedSubText(SourceText.From("a"), "Foo.txt", 83),
new MappedSubText(SourceText.From("a"), "foo.txt", 83),
true
},
new object[]
{
new MappedSubText(SourceText.From("a"), "Foo.txt", 83),
new MappedSubText(SourceText.From("A"), "foo.txt", 83),
false
},
new object[]
{
new MappedSubText(SourceText.From("a"), "foo.txt", 83),
new MappedSubText(SourceText.From("a"), "foo.tx", 83),
false
},
new object[]
{
new MappedSubText(SourceText.From("a"), "foo.txt", 83),
new MappedSubText(SourceText.From("a"), "foo.txt", 84),
false
},
new object[]
{
new MappedSubText(SourceText.From(string.Empty), "Foo.txt", 83),
new MappedSubText(SourceText.From("a"), "foo.txt", 83),
false
},
new object[]
{
new MappedSubText(SourceText.From(string.Empty), "Foo.txt", 0),
new MappedSubText(SourceText.From(string.Empty), "foo.txt", 0),
true
},
new object[]
{
new MappedSubText(SourceText.From("abc\ndef").GetSubText(4), string.Empty, 83),
new MappedSubText(SourceText.From("def"), string.Empty, 83),
true
},
};
public static IEnumerable<object?[]> GetMappedSubTextsTestData =>
new List<object?[]>
{
new object?[]
{
string.Empty,
null,
Enumerable.Empty<MappedSubText>()
},
new object?[]
{
string.Empty,
"C:\\Foo.cs",
new MappedSubText[]
{
new MappedSubText(SourceText.From(string.Empty), "C:\\Foo.cs", 0)
}
},
new object?[]
{
File.ReadAllText("MappedSubTextTestData.cs"),
"Bar.cshtml",
new MappedSubText[]
{
new MappedSubText(SourceText.From("// Auto-generated by Razor engine\r\n\r\nnamespace Razor\r\n{"), "Bar.cshtml", 0),
new MappedSubText(SourceText.From(" using System;\r\n"), "test.cshtml", 1),
new MappedSubText(SourceText.From("\r\n var foo = \"Hello World!\";\r\n"), "test.cshtml", 3),
new MappedSubText(SourceText.From(" __PTagHelper.FooProp = 123;\r\n"), "test.cshtml", 7),
new MappedSubText(SourceText.From(" WriteLiteral(foo);\r\n"), "test.cshtml", 7),
}
}
};
}
}

Просмотреть файл

@ -7,11 +7,18 @@
</PropertyGroup>
<ItemGroup>
<None Remove="**/*.cshtml" />
<Content Include="**/*.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<None Remove="InvalidViews\Invalid.cshtml" />
<Content Include="NoViews\Dummy.txt">
<Content Include="**/*.txt">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<Compile Remove="MappedSubTextTestData.cs" />
<Content Include="MappedSubTextTestData.cs">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>

Просмотреть файл

@ -0,0 +1,387 @@
// 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.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Autofac;
using Autofac.Extras.Moq;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Text;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Microsoft.DotNet.UpgradeAssistant.Steps.Razor.Tests
{
public class RazorSourceUpdaterTests
{
[Fact]
public void CtorNegativeTests()
{
using var mock = GetMock("RazorUpdaterStepViews/Test.csproj", Array.Empty<LocationLookup[]>());
var analyzers = mock.Container.Resolve<IEnumerable<DiagnosticAnalyzer>>();
var textMatcher = mock.Container.Resolve<ITextMatcher>();
var codeFixProviders = mock.Container.Resolve<IEnumerable<CodeFixProvider>>();
var textReplacer = mock.Mock<ITextReplacer>();
var logger = mock.Mock<ILogger<RazorSourceUpdater>>();
Assert.Throws<ArgumentNullException>("analyzers", () => new RazorSourceUpdater(null!, codeFixProviders, textMatcher, textReplacer.Object, logger.Object));
Assert.Throws<ArgumentNullException>("codeFixProviders", () => new RazorSourceUpdater(analyzers, null!, textMatcher, textReplacer.Object, logger.Object));
Assert.Throws<ArgumentNullException>("textMatcher", () => new RazorSourceUpdater(analyzers, codeFixProviders, null!, textReplacer.Object, logger.Object));
Assert.Throws<ArgumentNullException>("textReplacer", () => new RazorSourceUpdater(analyzers, codeFixProviders, textMatcher, null!, logger.Object));
Assert.Throws<ArgumentNullException>("logger", () => new RazorSourceUpdater(analyzers, codeFixProviders, textMatcher, textReplacer.Object, null!));
}
[Fact]
public void PropertyTests()
{
using var mock = GetMock("RazorUpdaterStepViews/Test.csproj", Array.Empty<LocationLookup[]>());
var updater = mock.Create<RazorSourceUpdater>();
Assert.Equal("Microsoft.DotNet.UpgradeAssistant.Steps.Razor.RazorSourceUpdater", updater.Id);
Assert.Equal("Apply code fixes to Razor documents", updater.Title);
Assert.Equal("Update code within Razor documents to fix diagnostics according to registered Roslyn analyzers and code fix providers", updater.Description);
Assert.Equal(BuildBreakRisk.Medium, updater.Risk);
}
[Fact]
public async Task IsApplicableNegativeTests()
{
using var mock = GetMock(null, Array.Empty<LocationLookup[]>());
var updater = mock.Create<RazorSourceUpdater>();
await Assert.ThrowsAsync<ArgumentNullException>("context", () => updater.IsApplicableAsync(null!, ImmutableArray.Create<RazorCodeDocument>(), CancellationToken.None)).ConfigureAwait(true);
await Assert.ThrowsAsync<InvalidOperationException>(() => updater.IsApplicableAsync(mock.Mock<IUpgradeContext>().Object, ImmutableArray.Create<RazorCodeDocument>(), CancellationToken.None)).ConfigureAwait(true);
}
[Theory]
[MemberData(nameof(IsApplicableData))]
public async Task IsApplicableTests(LocationLookup[][] diagnosticLocations, string[] expectedApplicableFilePaths)
{
// Arrange
using var mock = GetMock("RazorUpdaterStepViews/Test.csproj", diagnosticLocations);
var razorDocs = await GetRazorCodeDocumentsAsync(mock).ConfigureAwait(true);
var context = mock.Mock<IUpgradeContext>();
var updater = mock.Create<RazorSourceUpdater>();
// Act
var result = (FileUpdaterResult)await updater.IsApplicableAsync(context.Object, ImmutableArray.CreateRange(razorDocs), CancellationToken.None).ConfigureAwait(true);
var resultWithoutDocs = (FileUpdaterResult)await updater.IsApplicableAsync(context.Object, ImmutableArray.Create<RazorCodeDocument>(), CancellationToken.None).ConfigureAwait(true);
// Assert
Assert.Equal(expectedApplicableFilePaths.Any(), result.Result);
Assert.Collection(result.FilePaths.OrderBy(f => f), expectedApplicableFilePaths.OrderBy(f => f).Select<string, Action<string>>(expected => actual => Assert.Equal(expected, actual)).ToArray());
Assert.False(resultWithoutDocs.Result);
Assert.Empty(resultWithoutDocs.FilePaths);
}
public static IEnumerable<object[]> IsApplicableData =>
new List<object[]>
{
// No diagnostcs reported
new object[]
{
Array.Empty<LocationLookup[]>(),
Array.Empty<string>(),
},
// Diagnostics in non-Razor files
new object[]
{
new[] { new[] { new LocationLookup("Foo.cs", null, 10, 15) } },
Array.Empty<string>(),
},
// Diagnostic in Razor file
new object[]
{
new[]
{
new[] { new LocationLookup("RazorUpdaterStepViews\\TestViews\\View.cshtml.cs", "Model[0]") },
},
new[] { GetFullPath("RazorUpdaterStepViews\\TestViews\\View.cshtml") },
},
// Diagnostic mapped to shared Razor file
new object[]
{
new[]
{
new[] { new LocationLookup("RazorUpdaterStepViews\\TestViews\\View.cshtml.cs", "using Microsoft.AspNetCore.Mvc;") },
},
new[] { GetFullPath("RazorUpdaterStepViews\\_ViewImports.cshtml") },
},
// Diagnostic in unmapped portions of generated files
new object[]
{
new[]
{
new[]
{
new LocationLookup("RazorUpdaterStepViews\\TestViews\\View.cshtml.cs", "assembly: global::Microsoft.AspNetCore"),
new LocationLookup("RazorUpdaterStepViews\\TestViews\\View.cshtml.cs", "WriteLiteral(\" <div>\\r\\n <p>\")"),
},
},
new[]
{
GetFullPath("RazorUpdaterStepViews\\TestViews\\View.cshtml.cs"),
},
},
// Diagnostics in multiple files (from multiple analyzers)
new object[]
{
new[]
{
new[]
{
new LocationLookup("RazorUpdaterStepViews\\TestViews\\View.cshtml.cs", "using Microsoft.AspNetCore.Mvc;"),
new LocationLookup("RazorUpdaterStepViews\\TestViews\\View.cshtml.cs", "Model[0]"),
},
new[]
{
new LocationLookup("RazorUpdaterStepViews\\TestViews\\View.cshtml.cs", "Model[1]"),
new LocationLookup("RazorUpdaterStepViews\\TestViews\\Simple.cshtml.cs", "using Microsoft.AspNetCore.Mvc;"),
new LocationLookup("RazorUpdaterStepViews\\TestViews\\Simple.cshtml.cs", "DateTime.Now.ToString()"),
}
},
new[]
{
GetFullPath("RazorUpdaterStepViews\\_ViewImports.cshtml"),
GetFullPath("RazorUpdaterStepViews\\TestViews\\View.cshtml"),
GetFullPath("RazorUpdaterStepViews\\TestViews\\Simple.cshtml"),
},
}
};
[Fact]
public async Task ApplyNegativeTests()
{
using var mock = GetMock(null, Array.Empty<LocationLookup[]>());
var updater = mock.Create<RazorSourceUpdater>();
await Assert.ThrowsAsync<ArgumentNullException>("context", () => updater.ApplyAsync(null!, ImmutableArray.Create<RazorCodeDocument>(), CancellationToken.None)).ConfigureAwait(true);
await Assert.ThrowsAsync<InvalidOperationException>(() => updater.ApplyAsync(mock.Mock<IUpgradeContext>().Object, ImmutableArray.Create<RazorCodeDocument>(), CancellationToken.None)).ConfigureAwait(true);
}
[Theory]
[MemberData(nameof(ApplyData))]
public async Task ApplyTests(LocationLookup[][] diagnosticLocations, string[] expectedUpdatedFiles, TextReplacement[] expectedReplacements)
{
// Arrange
using var mock = GetMock("RazorUpdaterStepViews/Test.csproj", diagnosticLocations);
var razorDocs = await GetRazorCodeDocumentsAsync(mock).ConfigureAwait(true);
var context = mock.Mock<IUpgradeContext>();
var updater = mock.Create<RazorSourceUpdater>();
var replacements = new List<TextReplacement>();
var textReplacer = mock.Mock<ITextReplacer>();
textReplacer.Setup(r => r.ApplyTextReplacements(It.IsAny<IEnumerable<TextReplacement>>()))
.Callback<IEnumerable<TextReplacement>>(newReplacements => replacements.AddRange(newReplacements));
// Act
var result = (FileUpdaterResult)await updater.ApplyAsync(context.Object, ImmutableArray.CreateRange(razorDocs), CancellationToken.None).ConfigureAwait(true);
var resultWithoutDocs = (FileUpdaterResult)await updater.ApplyAsync(context.Object, ImmutableArray.Create<RazorCodeDocument>(), CancellationToken.None).ConfigureAwait(true);
// Assert
Assert.True(result.Result);
Assert.Collection(result.FilePaths.OrderBy(f => f), expectedUpdatedFiles.OrderBy(f => f).Select<string, Action<string>>(expected => actual => Assert.Equal(expected, actual)).ToArray());
Assert.True(resultWithoutDocs.Result);
Assert.Empty(resultWithoutDocs.FilePaths);
Assert.Collection(replacements, expectedReplacements.Select<TextReplacement, Action<TextReplacement>>(e => a =>
{
if (string.IsNullOrEmpty(e.OriginalText.ToString()) && string.IsNullOrEmpty(e.NewText.ToString()))
{
Assert.Equal(e.FilePath, a.FilePath);
Assert.Equal(e.StartingLine, a.StartingLine);
}
else
{
Assert.Equal(e, a);
}
}).ToArray());
}
public static IEnumerable<object[]> ApplyData =>
new List<object[]>
{
// No diagnostcs reported
new object[]
{
Array.Empty<LocationLookup[]>(),
Array.Empty<string>(),
Array.Empty<TextReplacement>(),
},
// Diagnostics in non-Razor files
new object[]
{
new[] { new[] { new LocationLookup("Foo.cs", null, 10, 15) } },
Array.Empty<string>(),
Array.Empty<TextReplacement>(),
},
// Diagnostic in Razor file
new object[]
{
new[]
{
new[] { new LocationLookup("RazorUpdaterStepViews\\TestViews\\View.cshtml.cs", "Model[0]") },
},
new[] { GetFullPath("RazorUpdaterStepViews\\TestViews\\View.cshtml") },
new[] { new TextReplacement(" Write(Model[0]);\r\n", " Write(Model[0] /* Test! */);\r\n", GetFullPath("RazorUpdaterStepViews\\TestViews\\View.cshtml"), 6) }
},
// Diagnostic mapped to shared Razor file
new object[]
{
new[]
{
new[] { new LocationLookup("RazorUpdaterStepViews\\TestViews\\View.cshtml.cs", "using Microsoft.AspNetCore.Mvc;") },
},
new[] { GetFullPath("RazorUpdaterStepViews\\_ViewImports.cshtml") },
new[] { new TextReplacement("using Microsoft.AspNetCore.Mvc;\r\n", "using Microsoft.AspNetCore.Mvc; /* Test! */\r\n", GetFullPath("RazorUpdaterStepViews\\_ViewImports.cshtml"), 1) }
},
// Diagnostic in unmapped portions of generated files
new object[]
{
new[]
{
new[]
{
new LocationLookup("RazorUpdaterStepViews\\TestViews\\View.cshtml.cs", "assembly: global::Microsoft.AspNetCore"),
new LocationLookup("RazorUpdaterStepViews\\TestViews\\View.cshtml.cs", "WriteLiteral(\" <div>\\r\\n <p>\")"),
},
},
new[] { GetFullPath("RazorUpdaterStepViews\\TestViews\\View.cshtml") },
new[]
{
// The first one *does* generate a replacement because it represents text being prepended to the beginning of the source file
// Don't check the actual text, though, since it will include file path-specific values that will change
new TextReplacement(string.Empty, string.Empty, GetFullPath("RazorUpdaterStepViews\\TestViews\\View.cshtml"), 0),
}
},
// Diagnostics in multiple files (from multiple analyzers)
new object[]
{
new[]
{
new[]
{
new LocationLookup("RazorUpdaterStepViews\\TestViews\\View.cshtml.cs", "using Microsoft.AspNetCore.Mvc;"),
new LocationLookup("RazorUpdaterStepViews\\TestViews\\View.cshtml.cs", "Model[0]"),
},
new[]
{
new LocationLookup("RazorUpdaterStepViews\\TestViews\\View.cshtml.cs", "Model[1]"),
new LocationLookup("RazorUpdaterStepViews\\TestViews\\Simple.cshtml.cs", "using Microsoft.AspNetCore.Mvc;"),
new LocationLookup("RazorUpdaterStepViews\\TestViews\\Simple.cshtml.cs", "DateTime.Now.ToString()"),
}
},
new[]
{
GetFullPath("RazorUpdaterStepViews\\_ViewImports.cshtml"),
GetFullPath("RazorUpdaterStepViews\\TestViews\\View.cshtml"),
GetFullPath("RazorUpdaterStepViews\\TestViews\\Simple.cshtml"),
},
new[]
{
new TextReplacement("using Microsoft.AspNetCore.Mvc;\r\n", "using Microsoft.AspNetCore.Mvc; /* Test! */\r\n", GetFullPath("RazorUpdaterStepViews\\_ViewImports.cshtml"), 1),
new TextReplacement(" Write(DateTime.Now.ToString());\r\n", " Write(DateTime.Now.ToString() /* Test! */);\r\n", GetFullPath("RazorUpdaterStepViews\\TestViews\\Simple.cshtml"), 1),
new TextReplacement(" Write(Model[0]);\r\n", " Write(Model[0] /* Test! */);\r\n", GetFullPath("RazorUpdaterStepViews\\TestViews\\View.cshtml"), 6),
new TextReplacement(" Write(Model[1]);\r\n", " Write(Model[1] /* Test! */);\r\n", GetFullPath("RazorUpdaterStepViews\\TestViews\\View.cshtml"), 18),
}
}
};
private static AutoMock GetMock(string? projectPath, LocationLookup[][] diagnosticLocations)
{
var mock = AutoMock.GetLoose(builder =>
{
builder.RegisterType<DefaultTextMatcher>().As<ITextMatcher>();
var analyzers = new List<DiagnosticAnalyzer>();
var codeFixProviders = new List<CodeFixProvider>();
if (diagnosticLocations.Any())
{
for (var i = 0; i < diagnosticLocations.Length; i++)
{
var analyzer = new Mock<DiagnosticAnalyzer>();
var descriptor = new DiagnosticDescriptor($"Test{i}", $"Test diagnostic {i}", $"Test message {i}", "Test", DiagnosticSeverity.Warning, true);
var locations = diagnosticLocations[i];
analyzer.Setup(a => a.SupportedDiagnostics).Returns(ImmutableArray.Create(descriptor));
analyzer.Setup(a => a.Initialize(It.IsAny<AnalysisContext>())).Callback<AnalysisContext>(context => context.RegisterSyntaxTreeAction(x =>
{
foreach (var lookup in locations.Where(l => GetFullPath(l.Path).Equals(x.Tree.FilePath, StringComparison.Ordinal)))
{
var start = lookup.StartOffset;
var end = lookup.EndOffset;
if (lookup.Keyword is not null)
{
var index = x.Tree.GetText().ToString().IndexOf(lookup.Keyword, StringComparison.Ordinal);
(start, end) = (index, index + lookup.Keyword.Length);
}
// If the 'test' trivia hasn't been added by the code fix yet, report a diagnostic
var line = x.Tree.GetText().Lines.GetLineFromPosition(start);
var lineText = x.Tree.GetText().GetSubText(line.Span).ToString();
if (!lineText.Contains("Test!", StringComparison.Ordinal))
{
var location = Location.Create(x.Tree, TextSpan.FromBounds(start, end));
var diagnostic = Diagnostic.Create(descriptor, location);
x.ReportDiagnostic(diagnostic);
}
}
}));
builder.RegisterMock(analyzer);
builder.RegisterInstance<CodeFixProvider>(new TestCodeFixProvider(new[] { descriptor.Id }));
}
}
else
{
builder.RegisterInstance(Enumerable.Empty<DiagnosticAnalyzer>());
builder.RegisterInstance(Enumerable.Empty<CodeFixProvider>());
}
});
var projectFile = mock.Mock<IProjectFile>();
var project = projectPath is not null ? mock.Mock<IProject>() : null;
project?.Setup(p => p.FileInfo).Returns(new FileInfo(projectPath!));
project?.Setup(p => p.GetFile()).Returns(projectFile.Object);
project?.Setup(p => p.GetRoslynProject()).Returns(() =>
{
var ws = new AdhocWorkspace();
var name = Path.GetFileNameWithoutExtension(projectPath)!;
return ws.AddProject(ProjectInfo.Create(ProjectId.CreateNewId(), VersionStamp.Default, name, name, "C#", filePath: projectPath));
});
var context = mock.Mock<IUpgradeContext>();
context.Setup(c => c.CurrentProject).Returns(project?.Object);
return mock;
}
private static async Task<IEnumerable<RazorCodeDocument>> GetRazorCodeDocumentsAsync(AutoMock mock)
{
var updaterStep = new RazorUpdaterStep(Enumerable.Empty<IUpdater<RazorCodeDocument>>(), mock.Mock<ILogger<RazorUpdaterStep>>().Object);
await updaterStep.InitializeAsync(mock.Mock<IUpgradeContext>().Object, CancellationToken.None).ConfigureAwait(true);
return updaterStep.RazorDocuments;
}
private static string GetFullPath(string path) =>
Path.IsPathFullyQualified(path)
? path
: Path.Combine(AppContext.BaseDirectory, path);
}
}

Просмотреть файл

@ -0,0 +1,170 @@
// 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.IO;
using System.Linq;
using Autofac.Extras.Moq;
using Xunit;
namespace Microsoft.DotNet.UpgradeAssistant.Steps.Razor.Tests
{
public sealed class RazorTextReplacerTests : IDisposable
{
private static readonly string WorkingDir = Path.Combine(Path.GetTempPath(), "RazorTextReplacerTestFiles");
public RazorTextReplacerTests()
{
if (Directory.Exists(WorkingDir))
{
Directory.Delete(WorkingDir, true);
}
Directory.CreateDirectory(WorkingDir);
}
public void Dispose()
{
if (Directory.Exists(WorkingDir))
{
Directory.Delete(WorkingDir, true);
}
}
[Fact]
public void CtorNegativeTests()
{
Assert.Throws<ArgumentNullException>("logger", () => new RazorTextReplacer(null!));
}
[Fact]
public void ApplyTextReplacementsNegativeTests()
{
using var mock = AutoMock.GetLoose();
var replacer = mock.Create<RazorTextReplacer>();
Assert.Throws<ArgumentNullException>("replacements", () => replacer.ApplyTextReplacements(null!));
}
[Theory]
[MemberData(nameof(ApplyReplacementsData))]
public void ApplyTextReplacementsPositiveTests(string testCaseId, IEnumerable<TextReplacement> replacements)
{
// Arrange
replacements = StageInputFiles(replacements);
var inputFiles = replacements.Select(r => r.FilePath).Distinct().OrderBy(p => p);
var expectedPostReplacementFiles = Directory.GetFiles(Path.Combine("TestViewsAfterReplacement", testCaseId), "*.cshtml").OrderBy(p => p);
using var mock = AutoMock.GetLoose();
var replacer = mock.Create<RazorTextReplacer>();
// Act
replacer.ApplyTextReplacements(replacements);
// Assert
Assert.Collection(inputFiles, expectedPostReplacementFiles.Select<string, Action<string>>(e => a =>
{
Assert.Equal(Path.GetFileName(e), Path.GetFileName(a));
Assert.Equal(File.ReadAllText(e), File.ReadAllText(a));
}).ToArray());
}
public static IEnumerable<object[]> ApplyReplacementsData =>
new List<object[]>
{
// No replacements
new object[]
{
"NoReplacements",
Enumerable.Empty<TextReplacement>()
},
// Vanilla replacements
new object[]
{
"VanillaReplacements",
new TextReplacement[]
{
new TextReplacement("ToString()", "ToAnotherString()", GetPath("Simple.cshtml"), 1),
new TextReplacement("Model[1]", "Model[2]", GetPath("View.cshtml"), 18),
new TextReplacement("if(Model != null && Model.Length > 1)", "if(Model is not null)", GetPath("View.cshtml"), 15)
}
},
// Multi-line replacement
new object[]
{
"MultilineReplacement",
new TextReplacement[]
{
new TextReplacement(
"{\r\n <div>\r\n <p>@Model[1]</p>\r\n </div>\r\n}\r\n",
"<h1>\r\n Hi!\r\n</h1>",
GetPath("View.cshtml"),
16)
}
},
// Inapplicable replacement
new object[]
{
"InapplicableReplacement",
new TextReplacement[]
{
new TextReplacement("DateTime", "DateTimeOffset", GetPath("Simple.cshtml"), 2),
new TextReplacement("<div>\r\n ", "<div>\r\n", GetPath("View.cshtml"), 9),
}
},
// Adding code to start-of-file
new object[]
{
"NewTextAtStart",
new TextReplacement[]
{
new TextReplacement("Something inapplicable", "using Foo;\r\nSomething inapplicable", GetPath("Simple.cshtml"), 0),
new TextReplacement(string.Empty, "Test", GetPath("View.cshtml"), 0),
}
},
// Remove code block
new object[]
{
"RemoveCodeBlock",
new TextReplacement[]
{
new TextReplacement("DateTime.Now.ToString();", string.Empty, GetPath("Simple.cshtml"), 1),
new TextReplacement("using Foo;", string.Empty, GetPath("View2.cshtml"), 1),
new TextReplacement("Model[0]", string.Empty, GetPath("View2.cshtml"), 7),
new TextReplacement("\r\n var x = 0;\r\n", string.Empty, GetPath("View2.cshtml"), 23),
}
},
// Remove partial code block
new object[]
{
"RemovePartialCodeBlock",
new TextReplacement[]
{
new TextReplacement(".ToString();", ";", GetPath("Simple.cshtml"), 1),
new TextReplacement(" Foo", string.Empty, GetPath("View2.cshtml"), 1),
new TextReplacement("Model[0];", "Model;", GetPath("View2.cshtml"), 7),
}
}
};
private static string GetPath(string fileName) => Path.Combine(AppContext.BaseDirectory, "TestViews", fileName);
private static IEnumerable<TextReplacement> StageInputFiles(IEnumerable<TextReplacement> replacements)
{
var inputFiles = replacements.Select(r => r.FilePath).Distinct();
foreach (var inputFile in inputFiles)
{
File.Copy(inputFile, Path.Combine(WorkingDir, Path.GetFileName(inputFile)), true);
}
return replacements.Select(r => new TextReplacement(r.OriginalText, r.NewText, Path.Combine(WorkingDir, Path.GetFileName(r.FilePath)), r.StartingLine));
}
}
}

Просмотреть файл

@ -60,11 +60,11 @@ namespace Microsoft.DotNet.UpgradeAssistant.Steps.Razor.Tests
}
[Theory]
[InlineData("TestViews/Test.csproj", 0, 2, true, true)] // Vanilla positive case
[InlineData("TestViews/Test.csproj", 1, 1, false, false)] // Not applicable if no project is loaded
[InlineData("TestViews/Test.csproj", 0, 0, true, false)] // Not applicable if there are no updaters
[InlineData("NoViews/Test.csproj", 0, 1, true, false)] // Not applicable if there are no Razor pages
[InlineData("Test.csproj", 1, 0, true, true)] // Applicable even with only complete updaters (updater status is not checked)
[InlineData("RazorUpdaterStepViews/TestViews/Test.csproj", 0, 2, true, true)] // Vanilla positive case
[InlineData("RazorUpdaterStepViews/TestViews/Test.csproj", 1, 1, false, false)] // Not applicable if no project is loaded
[InlineData("RazorUpdaterStepViews/TestViews/Test.csproj", 0, 0, true, false)] // Not applicable if there are no updaters
[InlineData("RazorUpdaterStepViews/NoViews/Test.csproj", 0, 1, true, false)] // Not applicable if there are no Razor pages
[InlineData("RazorUpdaterStepViews/Test.csproj", 1, 0, true, true)] // Applicable even with only complete updaters (updater status is not checked)
public async Task IsApplicableTests(string projectPath, int completeUpdaterCount, int incompleteUpdaterCount, bool projectLoaded, bool expected)
{
// Arrange
@ -88,11 +88,11 @@ namespace Microsoft.DotNet.UpgradeAssistant.Steps.Razor.Tests
}
[Theory]
[InlineData("Test.csproj", 0, 2, true, new[] { "_ViewImports.cshtml", "Invalid.cshtml", "Simple.cshtml", "View.cshtml" })] // Vanilla positive case
[InlineData("TestViews/Test.csproj", 2, 2, false, new[] { "Simple.cshtml", "View.cshtml" })] // Mixed complete and incomplete updaters and no _ViewImports
[InlineData("Test.csproj", 2, 0, true, new[] { "_ViewImports.cshtml", "Invalid.cshtml", "Simple.cshtml", "View.cshtml" })] // Test with no incomplete updaters
[InlineData("NoViews/Test.csproj", 2, 2, true, new string[0])] // Test with no Razor documents
[InlineData("Test.csproj", 0, 0, true, new[] { "_ViewImports.cshtml", "Invalid.cshtml", "Simple.cshtml", "View.cshtml" })] // No sub-steps
[InlineData("RazorUpdaterStepViews/Test.csproj", 0, 2, true, new[] { "_ViewImports.cshtml", "Invalid.cshtml", "Simple.cshtml", "View.cshtml" })] // Vanilla positive case
[InlineData("RazorUpdaterStepViews/TestViews/Test.csproj", 2, 2, false, new[] { "Simple.cshtml", "View.cshtml", })] // Mixed complete and incomplete updaters and no _ViewImports
[InlineData("RazorUpdaterStepViews/Test.csproj", 2, 0, true, new[] { "_ViewImports.cshtml", "Invalid.cshtml", "Simple.cshtml", "View.cshtml" })] // Test with no incomplete updaters
[InlineData("RazorUpdaterStepViews/NoViews/Test.csproj", 2, 2, true, new string[0])] // Test with no Razor documents
[InlineData("RazorUpdaterStepViews/Test.csproj", 0, 0, true, new[] { "_ViewImports.cshtml", "Invalid.cshtml", "Simple.cshtml", "View.cshtml" })] // No sub-steps
public async Task InitializeTests(string projectPath, int completeUpdaterCount, int incompleteUpdaterCount, bool expectImports, string[] expectedFiles)
{
// Arrange
@ -150,9 +150,9 @@ namespace Microsoft.DotNet.UpgradeAssistant.Steps.Razor.Tests
}
[Theory]
[InlineData("Test.csproj", true, new[] { "_ViewImports.cshtml", "Invalid.cshtml", "Simple.cshtml", "View.cshtml" })] // Vanilla positive case
[InlineData("TestViews/Test.csproj", false, new[] { "Simple.cshtml", "View.cshtml" })] // No _ViewImports
[InlineData("NoViews/Test.csproj", false, new string[0])] // No Razor documents
[InlineData("RazorUpdaterStepViews/Test.csproj", true, new[] { "_ViewImports.cshtml", "Invalid.cshtml", "Simple.cshtml", "View.cshtml" })] // Vanilla positive case
[InlineData("RazorUpdaterStepViews/TestViews/Test.csproj", false, new[] { "Simple.cshtml", "View.cshtml" })] // No _ViewImports
[InlineData("RazorUpdaterStepViews/NoViews/Test.csproj", false, new string[0])] // No Razor documents
public async Task ProcessRazorDocumentsTests(string projectPath, bool expectImports, string[] expectedFiles)
{

Просмотреть файл

@ -0,0 +1 @@
It is @DateTime.Now.ToString()

Просмотреть файл

@ -0,0 +1,20 @@
@model string[]
@if(Model != null && Model.Length > 0)
{
<div>
<p>@Model[0]</p>
</div>
}
<div>
<h1>Hello world!</h1>
<p>This is the body</p>
</div>
@if(Model != null && Model.Length > 1)
{
<div>
<p>@Model[1]</p>
</div>
}

Просмотреть файл

@ -0,0 +1,64 @@
// 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.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Editing;
namespace Microsoft.DotNet.UpgradeAssistant.Steps.Razor.Tests
{
public class TestCodeFixProvider : CodeFixProvider
{
private readonly ImmutableArray<string> _diagnosticIds;
public TestCodeFixProvider(IEnumerable<string> diagnosticIds)
{
_diagnosticIds = ImmutableArray.CreateRange(diagnosticIds ?? throw new ArgumentNullException(nameof(diagnosticIds)));
}
public override ImmutableArray<string> FixableDiagnosticIds => _diagnosticIds;
public sealed override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(true);
if (root is null)
{
return;
}
var node = root.FindNode(context.Span);
if (node is null)
{
return;
}
foreach (var diagnostic in context.Diagnostics)
{
context.RegisterCodeFix(
CodeAction.Create(
$"Fix {diagnostic.Id}",
ct => ReplaceNodeAsync(context.Document, node, ct),
$"Fix {diagnostic.Id}"),
context.Diagnostics);
}
}
private static async Task<Document> ReplaceNodeAsync(Document document, SyntaxNode node, CancellationToken ct)
{
var editor = await DocumentEditor.CreateAsync(document, ct).ConfigureAwait(false);
editor.ReplaceNode(node, node.WithTrailingTrivia(SyntaxFactory.ParseTrailingTrivia(" /* Test! */").AddRange(node.GetTrailingTrivia())));
return editor.GetChangedDocument();
}
}
}

Просмотреть файл

@ -0,0 +1,25 @@
@using Foo
@model string[]
@if(Model != null && Model.Length > 0)
{
<div>
<p>@(Model[0])</p>
</div>
}
<div>
<h1>Hello world!</h1>
<p>This is the body</p>
</div>
@if(Model != null && Model.Length > 1)
{
<div>
<p>@Model[1]</p>
</div>
}
@{
var x = 0;
}

Просмотреть файл

@ -0,0 +1 @@
It is @DateTime.Now.ToString()

Просмотреть файл

@ -0,0 +1,20 @@
@model string[]
@if(Model != null && Model.Length > 0)
{
<div>
<p>@Model[0]</p>
</div>
}
<div>
<h1>Hello world!</h1>
<p>This is the body</p>
</div>
@if(Model != null && Model.Length > 1)
{
<div>
<p>@Model[1]</p>
</div>
}

Просмотреть файл

@ -0,0 +1,18 @@
@model string[]
@if(Model != null && Model.Length > 0)
{
<div>
<p>@Model[0]</p>
</div>
}
<div>
<h1>Hello world!</h1>
<p>This is the body</p>
</div>
@if(Model != null && Model.Length > 1)
<h1>
Hi!
</h1>

Просмотреть файл

@ -0,0 +1,2 @@
@using Foo
It is @DateTime.Now.ToString()

Просмотреть файл

@ -0,0 +1,20 @@
@{ Test }@model string[]
@if(Model != null && Model.Length > 0)
{
<div>
<p>@Model[0]</p>
</div>
}
<div>
<h1>Hello world!</h1>
<p>This is the body</p>
</div>
@if(Model != null && Model.Length > 1)
{
<div>
<p>@Model[1]</p>
</div>
}

Просмотреть файл

@ -0,0 +1 @@
Place-holder file so that a test folder without cshtml files will be available

Просмотреть файл

@ -0,0 +1 @@
It is

Просмотреть файл

@ -0,0 +1,24 @@

@model string[]
@if(Model != null && Model.Length > 0)
{
<div>
<p></p>
</div>
}
<div>
<h1>Hello world!</h1>
<p>This is the body</p>
</div>
@if(Model != null && Model.Length > 1)
{
<div>
<p>@Model[1]</p>
</div>
}
@{
}

Просмотреть файл

@ -0,0 +1 @@
It is @DateTime.Now

Просмотреть файл

@ -0,0 +1,25 @@
@using
@model string[]
@if(Model != null && Model.Length > 0)
{
<div>
<p>@(Model)</p>
</div>
}
<div>
<h1>Hello world!</h1>
<p>This is the body</p>
</div>
@if(Model != null && Model.Length > 1)
{
<div>
<p>@Model[1]</p>
</div>
}
@{
var x = 0;
}

Просмотреть файл

@ -0,0 +1 @@
It is @DateTime.Now.ToAnotherString()

Просмотреть файл

@ -0,0 +1,20 @@
@model string[]
@if(Model != null && Model.Length > 0)
{
<div>
<p>@Model[0]</p>
</div>
}
<div>
<h1>Hello world!</h1>
<p>This is the body</p>
</div>
@if(Model is not null)
{
<div>
<p>@Model[2]</p>
</div>
}