Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/msbuild/documentation/release-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ Steps are **mostly parallel** unless noted.
- [ ] **5.4** Update `BootstrapSdkVersion` in [`eng/Versions.props`](https://github.com/dotnet/msbuild/blob/main/eng/Versions.props) if a fresh SDK was released. Check https://dotnet.microsoft.com/download/visual-studio-sdks — always verify the details for the targeted .NET version.
- [ ] **5.4b** Update `tools.dotnet` in [`global.json`](https://github.com/dotnet/msbuild/blob/main/global.json) to the latest released SDK in the targeted band.
- [ ] **5.5** Verify the overall subscription map across **every still-supported branch** — each `vsXX.Y` branch has an Arcade subscription matching its targeted .NET band, and each supported branch's outbound subscriptions land in the right downstream (e.g. SDK band, VMR). \
You can find more info [here](https://dev.azure.com/devdiv/DevDiv/_wiki/wikis/DevDiv.wiki/52573/MSBuild-Maestro-Flow).
- [ ] **5.6** Review this tracking issue for any process deviations. If the process changed, create a PR to update `documentation/release-checklist.md` with the improvements.

---
Expand Down
126 changes: 126 additions & 0 deletions src/msbuild/src/Build.UnitTests/Evaluation/Expander_Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1718,6 +1718,132 @@ public void ExpandAllIntoStringExpanderOptions()
Assert.Equal(@"string$(p);dialogs%3b ; splash.bmp ; ; $(NonExistent) ; %(NonExistent) ; $(OutputPath) ; $(TargetPath) ; %(Language)_%(Culture)", expander.ExpandIntoStringAndUnescape(value, ExpanderOptions.ExpandItems, MockElementLocation.Instance));
}

/// <summary>
/// Builds an <see cref="Expander{P, I}"/> backed by a fixed metadata table for exercising the
/// hand-written metadata scanner. Metadata values intentionally contain no path separators so
/// that <c>MaybeAdjustFilePath</c> does not perturb the asserted results.
/// </summary>
private static Expander<ProjectPropertyInstance, ProjectItemInstance> CreateMetadataExpander()
{
Dictionary<string, string> metadata = new(StringComparer.OrdinalIgnoreCase)
{
["Culture"] = "en-US",
["Foo"] = "Bar",
["Compile.Link"] = "Link.cs",
["Filename"] = "App",
};

return new Expander<ProjectPropertyInstance, ProjectItemInstance>(
new PropertyDictionary<ProjectPropertyInstance>(),
new ItemDictionary<ProjectItemInstance>(),
new StringMetadataTable(metadata),
FileSystems.Default);
}

/// <summary>
/// Parity tests for the hand-written metadata scanner. These pin the exact expanded result for
/// whitespace handling, malformed references, nested references, qualified vs. unqualified
/// names, and missing metadata so future edits to the scanner cannot silently regress them.
/// </summary>
[Theory]
// Simple expansion, unqualified and qualified.
[InlineData("%(Culture)", "en-US")]
[InlineData("%(Foo)", "Bar")]
[InlineData("%(Compile.Link)", "Link.cs")]
// Whitespace around the parentheses and the dot separator is allowed.
[InlineData("%( Culture )", "en-US")]
[InlineData("%( Compile . Link )", "Link.cs")]
// Missing metadata expands to empty; a missing qualifier does not fall back to the unqualified key.
[InlineData("%(DoesNotExist)", "")]
[InlineData("%(Other.Foo)", "")]
// Malformed references are emitted verbatim.
[InlineData("%(", "%(")]
[InlineData("%()", "%()")]
[InlineData("%( )", "%( )")]
[InlineData("%(.x)", "%(.x)")]
// The outer reference is not closed by ')', so only the inner reference expands.
[InlineData("%(Culture%(Foo))", "%(CultureBar)")]
// Mixed with surrounding literal text and adjacent references.
[InlineData("prefix_%(Culture)_suffix", "prefix_en-US_suffix")]
[InlineData("%(Culture)%(Foo)", "en-USBar")]
public void ExpandMetadata_ScannerEdgeCases(string input, string expected)
{
Expander<ProjectPropertyInstance, ProjectItemInstance> expander = CreateMetadataExpander();

expander.ExpandIntoStringLeaveEscaped(input, ExpanderOptions.ExpandMetadata, MockElementLocation.Instance)
.ShouldBe(expected);
}

/// <summary>
/// Parity tests for metadata expansion in the gaps between (and within the separators of) item
/// vector expressions. Items are intentionally left unexpanded (ExpandMetadata only) so the
/// assertions isolate the gap/separator boundary handling in ScanAndExpandMetadataInGaps,
/// including the case where "@(" appears but does not form a well-formed item vector.
/// </summary>
[Theory]
// Metadata after, before, and between item vectors.
[InlineData("@(Compile)%(Culture)", "@(Compile)en-US")]
[InlineData("%(Culture)@(Compile)", "en-US@(Compile)")]
[InlineData("@(A)%(Culture)@(B)", "@(A)en-US@(B)")]
// A lone item vector has no gaps and is returned unchanged, even with embedded metadata in a transform.
[InlineData("@(Compile)", "@(Compile)")]
[InlineData("@(Compile->'%(Filename)')", "@(Compile->'%(Filename)')")]
// Metadata embedded in an item vector's separator is expanded in place.
[InlineData("@(Compile, '%(Culture)')", "@(Compile, 'en-US')")]
// "@(" that does not form a valid item vector still has its surrounding metadata expanded.
[InlineData("%(Culture)@(", "en-US@(")]
public void ExpandMetadata_ItemVectorGapsAndSeparators(string input, string expected)
{
Expander<ProjectPropertyInstance, ProjectItemInstance> expander = CreateMetadataExpander();

expander.ExpandIntoStringLeaveEscaped(input, ExpanderOptions.ExpandMetadata, MockElementLocation.Instance)
.ShouldBe(expected);
}

/// <summary>
/// Verifies the built-in vs. custom metadata gating in the scanner: a reference is expanded only
/// when the matching <see cref="ExpanderOptions"/> flag is set; otherwise it is emitted verbatim.
/// </summary>
/// <remarks>
/// Declared <c>internal</c> because <see cref="ExpanderOptions"/> is internal; this assembly is
/// configured to discover non-public test methods.
/// </remarks>
[Theory]
// Custom metadata (Culture) only expands with ExpandCustomMetadata.
[InlineData("%(Culture)", ExpanderOptions.ExpandCustomMetadata, "en-US")]
[InlineData("%(Culture)", ExpanderOptions.ExpandBuiltInMetadata, "%(Culture)")]
// Built-in metadata (Filename) only expands with ExpandBuiltInMetadata.
[InlineData("%(Filename)", ExpanderOptions.ExpandBuiltInMetadata, "App")]
[InlineData("%(Filename)", ExpanderOptions.ExpandCustomMetadata, "%(Filename)")]
internal void ExpandMetadata_BuiltInVsCustomGating(string input, ExpanderOptions options, string expected)
{
Expander<ProjectPropertyInstance, ProjectItemInstance> expander = CreateMetadataExpander();

expander.ExpandIntoStringLeaveEscaped(input, options, MockElementLocation.Instance)
.ShouldBe(expected);
}

/// <summary>
/// Parity test for the rewritten transform scanner (<c>GetQuotedExpressionMatches</c>): metadata
/// references inside a transform must not be qualified with an item name. This pins the error path
/// (and its message arguments) so the de-regexed scanner keeps rejecting qualified references,
/// including when surrounded by internal whitespace.
/// </summary>
[Theory]
[InlineData("@(i->'%(i.Meta0)')", "%(i.Meta0)")]
[InlineData("@(i->'%( i . Meta0 )')", "%( i . Meta0 )")]
public void Transform_QualifiedMetadataThrows(string input, string qualifiedReference)
{
Expander<ProjectPropertyInstance, ProjectItemInstance> expander = CreateItemFunctionExpander();

InvalidProjectFileException exception = Should.Throw<InvalidProjectFileException>(() =>
expander.ExpandIntoStringLeaveEscaped(input, ExpanderOptions.ExpandItems, MockElementLocation.Instance));

// The error reports the offending qualified reference and suggests the unqualified form.
exception.Message.ShouldContain(qualifiedReference);
exception.Message.ShouldContain("%(Meta0)");
}

/// <summary>
/// Exercises ExpandAllIntoStringListLeaveEscaped with a complex set of data.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@
</ItemGroup>

<ItemGroup>
<Compile Include="..\Shared\UnitTests\ErrorUtilities_Tests.cs" />
<Compile Include="..\Shared\UnitTests\PrintLineDebugger_Tests.cs" />
<Compile Include="..\Shared\UnitTests\ResourceUtilities_Tests.cs" />
<Compile Include="..\Shared\UnitTests\TypeLoader_Tests.cs" />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,56 +1,43 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text.RegularExpressions;

#nullable disable

namespace Microsoft.Build.Evaluation;

internal partial class Expander<P, I>
where P : class, IProperty
where I : class, IItem
{
private static partial class ItemExpander
{
private static partial class Transforms
{
/// <summary>
/// Represents a single match. Whether it was cached or from a Regex should be transparent
/// since we simulate the length calculation.
/// Represents a single match. Whether it was cached or from a manual scan should be transparent
/// since we simulate the length calculation.
/// </summary>
private readonly struct MetadataMatch
private readonly struct MetadataMatch(int index, int length, string name)
{
public MetadataMatch(string name)
: this(index: 0, name.Length + QuotedExpressionSurroundCharCount, name)
{
Name = name;
Index = 0;
Length = name.Length + QuotedExpressionSurroundCharCount;
}

public MetadataMatch(Match match, string name)
{
Name = name;
Index = match.Index;
Length = match.Length;
}

/// <summary>
/// The inner value of the match.
/// Gets the inner value of the match.
/// </summary>
internal string Name { get; }
internal string Name => name;

/// <summary>
/// The index of the match in the original string.
/// If we have an exact string match, this will be 0.
/// Gets the index of the match in the original string.
/// If we have an exact string match, this will be 0.
/// </summary>
internal int Index { get; }
internal int Index => index;

/// <summary>
/// The length of the match in the original string.
/// If we have an exact string match, this computed to match the original input.
/// Gets the length of the match in the original string.
/// If we have an exact string match, this computed to match the original input.
/// </summary>
internal int Length { get; }
internal int Length => length;
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,36 +4,34 @@
namespace Microsoft.Build.Evaluation;

internal partial class Expander<P, I>
where P : class, IProperty
where I : class, IItem
{
private static partial class ItemExpander
{
private static partial class Transforms
{
/// <summary>
/// The type of match we found.
/// We use this to determine how to build the final output string.
/// The type of match we found.
/// We use this to determine how to build the final output string.
/// </summary>
private enum MetadataMatchType
{
/// <summary>
/// No matches found. The result will be empty.
/// No matches found. The result will be empty.
/// </summary>
None,

/// <summary>
/// An exact full string match, e.g. '%(FullPath)'.
/// An exact full string match, e.g. '%(FullPath)'.
/// </summary>
ExactSingle,

/// <summary>
/// A single match with surrounding characters, e.g. 'somedir/%(FileName)'.
/// A single match with surrounding characters, e.g. 'somedir/%(FileName)'.
/// </summary>
InexactSingle,

/// <summary>
/// Multiple matches found, e.g. '%(FullPath)%(Extension)'.
/// Multiple matches found, e.g. '%(FullPath)%(Extension)'.
/// </summary>
Multiple,
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,10 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System.Collections.Generic;
using System.Text.RegularExpressions;

#nullable disable

namespace Microsoft.Build.Evaluation;

internal partial class Expander<P, I>
where P : class, IProperty
where I : class, IItem
{
private static partial class ItemExpander
{
Expand All @@ -21,6 +16,12 @@ private static partial class Transforms
/// </summary>
private readonly struct OneOrMultipleMetadataMatches
{
public static OneOrMultipleMetadataMatches None => default;

public MetadataMatch Single { get; }
public List<MetadataMatch>? Multiple { get; }
public MetadataMatchType Type { get; }

public OneOrMultipleMetadataMatches()
{
Type = MetadataMatchType.None;
Expand All @@ -32,27 +33,22 @@ public OneOrMultipleMetadataMatches(string name)
Single = new MetadataMatch(name);
}

public OneOrMultipleMetadataMatches(string quotedExpressionFunction, Match match, string name)
public OneOrMultipleMetadataMatches(string quotedExpressionFunction, int matchIndex, int matchLength, string name)
{
// We know we have a full string match when our extracted name is the same length as the input
// string minus the surrounding characters.
Type = quotedExpressionFunction.Length == name.Length + QuotedExpressionSurroundCharCount
? MetadataMatchType.ExactSingle
: MetadataMatchType.InexactSingle;
Single = new MetadataMatch(match, name);

Single = new MetadataMatch(matchIndex, matchLength, name);
}

public OneOrMultipleMetadataMatches(List<MetadataMatch> allMatches)
{
Type = MetadataMatchType.Multiple;
Multiple = allMatches;
}

internal MetadataMatch Single { get; }

internal List<MetadataMatch> Multiple { get; }

internal MetadataMatchType Type { get; }
}
}
}
Expand Down
Loading
Loading