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
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ SPDX-License-Identifier: MIT
<PropertyGroup Label="Packaging">
<Version>2.0.0</Version>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<Copyright>2024-2026 TruePath contributors &lt;https://github.com/ForNeVeR/TruePath&gt;; .NET Foundation and Contributors</Copyright>
<Copyright>2024-2026 TruePath contributors &lt;https://github.com/ForNeVeR/TruePath&gt;; .NET Foundation and Contributors<!-- Comes from some sources in the TruePath package copied from the BCL, and copied documentation comments in TruePathSystemIo. --></Copyright>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<IncludeSymbols>true</IncludeSymbols>
Expand Down
66 changes: 49 additions & 17 deletions TruePath.PathIoGenerator.Tests/PathIoGeneratorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

using System.Collections.Immutable;
using System.Text;
using System.Xml;
using Basic.Reference.Assemblies;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Text;
Expand Down Expand Up @@ -43,7 +45,7 @@ public static partial class PathIo
}
""";

public static string SourcesWithHandWrittenMembers(string members) => DefaultSources
public static string SourcesWithUserDefinedMembers(string members) => DefaultSources
.Replace(
"""
[GenerateSystemIoExtensions]
Expand All @@ -65,16 +67,16 @@ public static CSharpCompilation CreateCompilation(string sources)
// reliable on CI, where the test host runtime and the SDK with the packs may live in different roots.
return CSharpCompilation.Create(
"TestAssembly",
new[] { CSharpSyntaxTree.ParseText(sources, new CSharpParseOptions(LanguageVersion.Latest)) },
Basic.Reference.Assemblies.Net100.References.All,
[CSharpSyntaxTree.ParseText(sources, new CSharpParseOptions(LanguageVersion.Latest))],
Net100.References.All,
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
}

public static string Generate(CSharpCompilation compilation, params AdditionalText[] additionalTexts)
{
GeneratorDriver driver = CSharpGeneratorDriver.Create(
new[] { new PathIoSourceGenerator().AsSourceGenerator() },
additionalTexts.ToImmutableArray());
[new PathIoSourceGenerator().AsSourceGenerator()],
[.. additionalTexts]);
driver = driver.RunGenerators(compilation);
var runResult = driver.GetRunResult();
var tree = runResult.GeneratedTrees.FirstOrDefault(generated => generated.FilePath.Contains("PathIo.Generated"));
Expand Down Expand Up @@ -117,21 +119,51 @@ public void PureForwardMethodIsGenerated()
public void BclDocIndexFindsBclMembers()
{
var index = BclDocIndex.Build(
ImmutableArray.Create<AdditionalText>(new InMemoryAdditionalText("/ref/System.Runtime.xml", BclDocs)));
[new InMemoryAdditionalText("/ref/System.Runtime.xml", BclDocs)]);

Assert.True(index.TryGetMethodMember("M:System.IO.File.ReadAllText(System.String)", out var member));
Assert.Contains("OPENS A TEXT FILE, READS ALL THE TEXT IN THE FILE.", member.ToString());
}

private const string BrokenBclDocs = """
<doc>
<members>
<member name="M:System.IO.File.ReadAllText(System.String)">
<summary>OPENS A TEXT FILE, READS ALL THE TEXT IN THE FILE.</summary>
</member>
<member name="M:System.IO.File.Delete(System.String)">
<summary>Broken <see cref="T:System.IO.File"></summary>
</member>
</members>
</doc>
""";

[Fact]
public void BclDocIndexToleratesBrokenMembersInNetStandardDoc()
{
var index = BclDocIndex.Build(
[new InMemoryAdditionalText("/ref/netstandard.xml", BrokenBclDocs)]);

Assert.True(index.TryGetMethodMember("M:System.IO.File.ReadAllText(System.String)", out _));
Assert.False(index.TryGetMethodMember("M:System.IO.File.Delete(System.String)", out _));
}

[Fact]
public void BclDocIndexRejectsInvalidXmlInOtherDocs()
{
Assert.Throws<XmlException>(() => BclDocIndex.Build(
[new InMemoryAdditionalText("/ref/System.Runtime.xml", BrokenBclDocs)]));
}

[Fact]
public void CoreUsesBclDocIndexDirectly()
{
var compilation = TestHelper.CreateCompilation(TestHelper.DefaultSources);
var index = BclDocIndex.Build(
ImmutableArray.Create<AdditionalText>(new InMemoryAdditionalText("/ref/System.Runtime.xml", BclDocs)));
[new InMemoryAdditionalText("/ref/System.Runtime.xml", BclDocs)]);

var result = PathIoGeneratorCore.Generate(
compilation, "TruePath.SystemIo.PathIo", markedTypeIsNested: false, HandWrittenInfo.Empty,
compilation, "TruePath.SystemIo.PathIo", markedTypeIsNested: false, UserDefinedMembersInfo.Empty,
ImmutableHashSet<string>.Empty, index);

Assert.Contains("/// <summary>OPENS A TEXT FILE, READS ALL THE TEXT IN THE FILE.</summary>", result.Source);
Expand Down Expand Up @@ -166,7 +198,7 @@ public static partial class PathIo { }
""";
var compilation = TestHelper.CreateCompilation(sources);
GeneratorDriver driver = CSharpGeneratorDriver.Create(
new[] { new PathIoSourceGenerator().AsSourceGenerator() },
[new PathIoSourceGenerator().AsSourceGenerator()],
ImmutableArray<AdditionalText>.Empty);
driver = driver.RunGenerators(compilation, TestContext.Current.CancellationToken);

Expand Down Expand Up @@ -196,26 +228,26 @@ public void FallbackDocumentationIsUsedWhenNoXmlIsAvailable()
}

[Fact]
public void HandWrittenSignatureSuppressesGeneration()
public void UserDefinedSignatureSuppressesGeneration()
{
const string members = """
public static bool Exists(TruePath.AbsolutePath path) => true;
""";
var source = TestHelper.Generate(
TestHelper.CreateCompilation(TestHelper.SourcesWithHandWrittenMembers(members)));
TestHelper.CreateCompilation(TestHelper.SourcesWithUserDefinedMembers(members)));

Assert.DoesNotContain("Exists(", source);
}

[Fact]
public void HandWrittenForwardingCallSuppressesItsBclTarget()
public void UserDefinedForwardingCallSuppressesItsBclTarget()
{
const string members = """
public static void DeleteEmptyDirectory(TruePath.AbsolutePath path) => System.IO.Directory.Delete(path.Value, recursive: false);
public static void DeleteDirectoryRecursively(TruePath.AbsolutePath path) => System.IO.Directory.Delete(path.Value, recursive: true);
""";
var source = TestHelper.Generate(
TestHelper.CreateCompilation(TestHelper.SourcesWithHandWrittenMembers(members)));
TestHelper.CreateCompilation(TestHelper.SourcesWithUserDefinedMembers(members)));

Assert.DoesNotContain("Delete(AbsolutePath path, bool", source);
Assert.Contains(
Expand All @@ -236,7 +268,7 @@ public static TruePath.AbsolutePath[] GetFiles(TruePath.AbsolutePath path, TrueP
""";
var source = TestHelper.Generate(
TestHelper.CreateCompilation(
TestHelper.SourcesWithHandWrittenMembers(members)
TestHelper.SourcesWithUserDefinedMembers(members)
.Replace(
" public readonly struct AbsolutePath",
" public record struct LocalPathPattern(string Value);\n public readonly struct AbsolutePath")));
Expand All @@ -249,13 +281,13 @@ public static TruePath.AbsolutePath[] GetFiles(TruePath.AbsolutePath path, TrueP
[Fact]
public void ForwardingCallWithPinnedTrailingArgumentSuppressesItsBclOverload()
{
// The parameter list of the handwritten member is the leading part of Directory.Delete(string, bool), which
// The parameter list of the user-defined member is the leading part of Directory.Delete(string, bool), which
// makes it a specialization of that overload, so the generated Delete(AbsolutePath, bool) must not appear.
const string members = """
public static void DeleteEmptyDirectory(TruePath.AbsolutePath path) => System.IO.Directory.Delete(path.Value, recursive: false);
""";
var source = TestHelper.Generate(
TestHelper.CreateCompilation(TestHelper.SourcesWithHandWrittenMembers(members)));
TestHelper.CreateCompilation(TestHelper.SourcesWithUserDefinedMembers(members)));

Assert.DoesNotContain("public static void Delete(this AbsolutePath path, bool", source);
}
Expand Down Expand Up @@ -332,7 +364,7 @@ public void GeneratedCodeCompilesWithoutErrors()
{
var compilation = TestHelper.CreateCompilation(TestHelper.DefaultSources);
GeneratorDriver driver = CSharpGeneratorDriver.Create(
new[] { new PathIoSourceGenerator().AsSourceGenerator() },
[new PathIoSourceGenerator().AsSourceGenerator()],
ImmutableArray<AdditionalText>.Empty);
driver = driver.RunGenerators(compilation, TestContext.Current.CancellationToken);
var updated = compilation.AddSyntaxTrees(driver.GetRunResult().GeneratedTrees);
Expand Down
111 changes: 74 additions & 37 deletions TruePath.PathIoGenerator/BclDocIndex.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
using System.Collections.Immutable;
using System.Text;
using System.Text.RegularExpressions;
using System.Xml;
using System.Xml.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;

namespace TruePath.PathIoGenerator;

Expand Down Expand Up @@ -35,60 +37,95 @@ public bool TryGetMethodMember(string docId, out XElement member)
public static BclDocIndex Build(ImmutableArray<AdditionalText> files)
{
// netstandard.xml is processed first, so that the framework-specific System.Runtime.xml wins on conflicts.
var orderedFiles = files.OrderBy(file => Path.GetFileName(file.Path) == "netstandard.xml" ? 0 : 1);
var orderedFiles = files.OrderBy(file => IsNetStandardDoc(file) ? 0 : 1);

var builder = ImmutableDictionary.CreateBuilder<string, XElement>();
foreach (var file in orderedFiles)
{
var text = file.GetText();
if (text == null) continue;

// Some of the BCL documentation files (netstandard.xml in particular) contain invalid XML, so member
// elements are extracted line by line and parsed one by one: a broken member is then skipped instead
// of failing the whole file.
var current = new StringBuilder();
string? currentName = null;
foreach (var textLine in text.Lines)
if (IsNetStandardDoc(file))
ExtractMembersLeniently(text, builder);
else
ExtractMembers(text, builder);
}

return new BclDocIndex(files, builder.ToImmutable());
}

private const string NetStandardDocFileName = "netstandard.xml";

private static bool IsNetStandardDoc(AdditionalText file)
{
return Path.GetFileName(file.Path) == NetStandardDocFileName;
}

private static bool IsIndexedMember(string name)
{
return name.StartsWith("M:System.IO.File.", StringComparison.Ordinal)
|| name.StartsWith("M:System.IO.Directory.", StringComparison.Ordinal);
}

private static void ExtractMembers(SourceText text, ImmutableDictionary<string, XElement>.Builder builder)
{
var document = XDocument.Parse(text.ToString());
foreach (var member in document.Descendants("member"))
{
var name = member.Attribute("name")?.Value;
if (name == null || !IsIndexedMember(name)) continue;

builder[name] = member;
}
}

private static void ExtractMembersLeniently(
SourceText text,
ImmutableDictionary<string, XElement>.Builder builder)
{
// netstandard.xml contains invalid XML, so member elements are extracted line by line and parsed one by one:
// a broken member is then skipped instead of failing the whole file.
//
// See https://github.com/dotnet/standard/issues/1527 on the topic of the broken XML doc.
var current = new StringBuilder();
string? currentName = null;
foreach (var textLine in text.Lines)
{
var line = textLine.ToString();
var trimmed = line.TrimStart();
if (currentName == null)
{
var line = textLine.ToString();
var trimmed = line.TrimStart();
if (currentName == null)
{
if (!trimmed.StartsWith("<member ", StringComparison.Ordinal)) continue;
if (!trimmed.StartsWith("<member ", StringComparison.Ordinal)) continue;

var nameStart = trimmed.IndexOf('"');
var nameEnd = nameStart < 0 ? -1 : trimmed.IndexOf('"', nameStart + 1);
if (nameEnd < 0) continue;
var nameStart = trimmed.IndexOf('"');
var nameEnd = nameStart < 0 ? -1 : trimmed.IndexOf('"', nameStart + 1);
if (nameEnd < 0) continue;

var name = trimmed.Substring(nameStart + 1, nameEnd - nameStart - 1);
if (!name.StartsWith("M:System.IO.File.", StringComparison.Ordinal)
&& !name.StartsWith("M:System.IO.Directory.", StringComparison.Ordinal)) continue;
var name = trimmed.Substring(nameStart + 1, nameEnd - nameStart - 1);
if (!IsIndexedMember(name)) continue;

currentName = name;
current.Clear().Append(line);
}
else
currentName = name;
current.Clear().Append(line);
}
else
{
current.Append('\n').Append(line);
}

if (trimmed.TrimEnd().EndsWith("</member>", StringComparison.Ordinal))
{
try
{
current.Append('\n').Append(line);
builder[currentName] = XElement.Parse(current.ToString());
}

if (trimmed.TrimEnd().EndsWith("</member>", StringComparison.Ordinal))
catch (XmlException)
{
try
{
builder[currentName] = XElement.Parse(current.ToString());
}
catch (System.Xml.XmlException)
{
// Skip a malformed member.
}

currentName = null;
// Skip a malformed member.
}

currentName = null;
}
}

return new BclDocIndex(files, builder.ToImmutable());
}

public override bool Equals(object? obj)
Expand Down
4 changes: 2 additions & 2 deletions TruePath.PathIoGenerator/Exclusions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ namespace TruePath.PathIoGenerator;

/// <summary>
/// The parsed contents of the <c>path-io-exclusions.txt</c> additional file: XML documentation IDs of the BCL
/// methods that must not be exposed as PathIo extensions. Methods covered by handwritten members (either by
/// rewritten signature or by the forwarding call inside the handwritten body) are excluded automatically and
/// methods that must not be exposed as PathIo extensions. Methods covered by user-defined members (either by
/// rewritten signature or by the forwarding call inside their body) are excluded automatically and
/// must not be listed there.
/// </summary>
internal sealed class Exclusions
Expand Down
8 changes: 4 additions & 4 deletions TruePath.PathIoGenerator/MarkedType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,21 @@ namespace TruePath.PathIoGenerator;

/// <summary>
/// The equatable payload the pipeline computes for every marked class: its display name, whether it is nested
/// (rejected by <see cref="PathIoGeneratorCore.Generate"/> with a diagnostic), and the hand-written member
/// (rejected by <see cref="PathIoGeneratorCore.Generate"/> with a diagnostic), and the user-defined member
/// analysis. Being equatable lets the incremental pipeline cache it across unrelated edits.
/// </summary>
internal sealed class MarkedType(string typeName, bool isNested, HandWrittenInfo handWritten)
internal sealed class MarkedType(string typeName, bool isNested, UserDefinedMembersInfo userDefinedMembers)
{
public string TypeName { get; } = typeName;
public bool IsNested { get; } = isNested;
public HandWrittenInfo HandWritten { get; } = handWritten;
public UserDefinedMembersInfo UserDefinedMembers { get; } = userDefinedMembers;

public override bool Equals(object? obj)
{
return obj is MarkedType other
&& TypeName == other.TypeName
&& IsNested == other.IsNested
&& HandWritten.Equals(other.HandWritten);
&& UserDefinedMembers.Equals(other.UserDefinedMembers);
}

public override int GetHashCode()
Expand Down
Loading
Loading