From df503585a6296669f74db6f9cfc2cc4196d06b97 Mon Sep 17 00:00:00 2001 From: ghostflyby Date: Wed, 21 Oct 2026 16:46:01 +0800 Subject: [PATCH 1/6] feat(SystemIo): generate System.IO forwarding APIs with a source generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add TruePath.PathIoGenerator, a Roslyn incremental source generator that fills the other partial part of PathIo (marked with GenerateSystemIoExtensionsAttribute) with forwarding extension methods for every public static File/Directory method of the current target framework, with the BCL XML documentation copied verbatim. - PathIo.cs keeps only the members with custom behavior or documentation; pure forwarders are generated (93 methods on net8.0, 66 on netstandard2.0), introducing previously missing APIs (symlinks, OpenHandle, Replace, EnumerateDirectories, GetParent, etc.). - Hand-written members win over generated ones, both by rewritten signature and by the forwarding call inside their body; File wins over Directory on duplicate signatures; path-io-exclusions.txt lists APIs deliberately not exposed. - BCL XML docs reach the generator via AdditionalFiles (the command-line compiler does not attach them to references), are extracted line-by-line to survive malformed netstandard.xml, and param/paramref names are normalized against the generated parameters; path parameters keep the historical sourceFile/destFile names. - Generated code uses short type names for imported namespaces. - Tests: generator unit tests plus a reflection completeness test asserting BCL API ⊆ generated ∪ hand-written ∪ exclusions. --- .../PathIoGeneratorTests.cs | 296 ++++++ .../TruePath.PathIoGenerator.Tests.csproj | 26 + TruePath.PathIoGenerator/BclDocIndex.cs | 194 ++++ TruePath.PathIoGenerator/DocId.cs | 71 ++ TruePath.PathIoGenerator/Exclusions.cs | 50 ++ .../GeneratorDiagnostics.cs | 36 + .../PathIoGeneratorCore.cs | 408 +++++++++ TruePath.PathIoGenerator/PathIoRules.cs | 44 + .../PathIoSourceGenerator.cs | 58 ++ .../TruePath.PathIoGenerator.csproj | 27 + TruePath.PathIoGenerator/TypeRenderer.cs | 93 ++ .../GenerateSystemIoExtensionsAttribute.cs | 14 + TruePath.SystemIo/PathIo.cs | 846 +----------------- TruePath.SystemIo/TruePath.SystemIo.csproj | 17 + TruePath.SystemIo/path-io-exclusions.txt | 13 + TruePath.Tests/PathIoCompletenessTests.cs | 113 +++ TruePath.Tests/TruePath.Tests.csproj | 2 + TruePath.sln | 80 +- 18 files changed, 1535 insertions(+), 853 deletions(-) create mode 100644 TruePath.PathIoGenerator.Tests/PathIoGeneratorTests.cs create mode 100644 TruePath.PathIoGenerator.Tests/TruePath.PathIoGenerator.Tests.csproj create mode 100644 TruePath.PathIoGenerator/BclDocIndex.cs create mode 100644 TruePath.PathIoGenerator/DocId.cs create mode 100644 TruePath.PathIoGenerator/Exclusions.cs create mode 100644 TruePath.PathIoGenerator/GeneratorDiagnostics.cs create mode 100644 TruePath.PathIoGenerator/PathIoGeneratorCore.cs create mode 100644 TruePath.PathIoGenerator/PathIoRules.cs create mode 100644 TruePath.PathIoGenerator/PathIoSourceGenerator.cs create mode 100644 TruePath.PathIoGenerator/TruePath.PathIoGenerator.csproj create mode 100644 TruePath.PathIoGenerator/TypeRenderer.cs create mode 100644 TruePath.SystemIo/GenerateSystemIoExtensionsAttribute.cs create mode 100644 TruePath.SystemIo/path-io-exclusions.txt create mode 100644 TruePath.Tests/PathIoCompletenessTests.cs diff --git a/TruePath.PathIoGenerator.Tests/PathIoGeneratorTests.cs b/TruePath.PathIoGenerator.Tests/PathIoGeneratorTests.cs new file mode 100644 index 0000000..e7b1bd7 --- /dev/null +++ b/TruePath.PathIoGenerator.Tests/PathIoGeneratorTests.cs @@ -0,0 +1,296 @@ +// SPDX-FileCopyrightText: 2026 TruePath contributors +// +// SPDX-License-Identifier: MIT + +using System.Collections.Immutable; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; +using TruePath.PathIoGenerator; +using Xunit; + +namespace TruePath.PathIoGenerator.Tests; + +public sealed class InMemoryAdditionalText(string path, string text) : AdditionalText +{ + public override string Path { get; } = path; + public override SourceText? GetText(CancellationToken cancellationToken = default) => SourceText.From(text, Encoding.UTF8); +} + +public static class TestHelper +{ + public const string DefaultSources = """ + using System; + #nullable enable + namespace TruePath + { + public readonly struct AbsolutePath + { + public AbsolutePath(string value) { } + public string Value => ""; + } + } + + namespace TruePath.SystemIo + { + [AttributeUsage(AttributeTargets.Class)] + internal sealed class GenerateSystemIoExtensionsAttribute : Attribute { } + + [GenerateSystemIoExtensions] + public static partial class PathIo + { + } + } + """; + + public static string SourcesWithHandWrittenMembers(string members) => DefaultSources + .Replace( + """ + [GenerateSystemIoExtensions] + public static partial class PathIo + { + } + """, + """ + [GenerateSystemIoExtensions] + public static partial class PathIo + { + """ + members + """ + } + """); + + public static CSharpCompilation CreateCompilation(string sources) + { + // Reference the .NET reference pack (the same contract assemblies a real build uses). Runtime-directory + // assemblies cannot be used instead: most public types are implemented in System.Private.CoreLib, whose + // members are not visible when it is referenced directly. + var coreLibPath = typeof(object).Assembly.Location; + var dotnetRoot = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(coreLibPath)!, "..", "..", "..")); + var refDirectory = Directory.GetDirectories(Path.Combine(dotnetRoot, "packs", "Microsoft.NETCore.App.Ref")) + .SelectMany(versionDirectory => Directory.GetDirectories(Path.Combine(versionDirectory, "ref"))) + .SelectMany(tfmDirectory => Directory.EnumerateFiles(tfmDirectory, "*.dll")) + .ToArray(); + var references = refDirectory + .Select(path => MetadataReference.CreateFromFile(path)) + .ToArray(); + + return CSharpCompilation.Create( + "TestAssembly", + new[] { CSharpSyntaxTree.ParseText(sources, new CSharpParseOptions(LanguageVersion.Latest)) }, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + } + + public static string Generate(CSharpCompilation compilation, params AdditionalText[] additionalTexts) + { + GeneratorDriver driver = CSharpGeneratorDriver.Create( + new[] { new PathIoSourceGenerator().AsSourceGenerator() }, + additionalTexts.ToImmutableArray()); + driver = driver.RunGenerators(compilation); + return driver.GetRunResult().GeneratedTrees + .Single(tree => tree.FilePath.Contains("PathIo.Generated")) + .GetText().ToString(); + } +} + +public sealed class PathIoGeneratorTests +{ + private const string BclDocs = """ + + System.Runtime + + + OPENS A TEXT FILE, READS ALL THE TEXT IN THE FILE. + The file to open for reading. + + + SHOULD NEVER BE GENERATED BECAUSE OF THE EXCLUSION RULES. + + + + """; + + [Fact] + public void PureForwardMethodIsGenerated() + { + var source = TestHelper.Generate(TestHelper.CreateCompilation(TestHelper.DefaultSources)); + + Assert.Contains( + "public static string ReadAllText(this AbsolutePath path) => File.ReadAllText(path.Value);", + source); + } + + [Fact] + public void BclDocIndexFindsBclMembers() + { + var index = BclDocIndex.Build( + ImmutableArray.Create(new InMemoryAdditionalText("/ref/System.Runtime.xml", BclDocs))); + + Assert.True(index.TryGetMethodXml("M:System.IO.File.ReadAllText(System.String)", out var memberXml)); + Assert.Contains("OPENS A TEXT FILE, READS ALL THE TEXT IN THE FILE.", memberXml); + } + + [Fact] + public void CoreUsesBclDocIndexDirectly() + { + var compilation = TestHelper.CreateCompilation(TestHelper.DefaultSources); + var index = BclDocIndex.Build( + ImmutableArray.Create(new InMemoryAdditionalText("/ref/System.Runtime.xml", BclDocs))); + + var result = PathIoGeneratorCore.Generate( + compilation, "TruePath.SystemIo.PathIo", ImmutableHashSet.Empty, index); + + Assert.Contains("/// OPENS A TEXT FILE, READS ALL THE TEXT IN THE FILE.", result.Source); + } + + [Fact] + public void DocumentationFromBclXmlIsUsedWhenAvailable() + { + var compilation = TestHelper.CreateCompilation(TestHelper.DefaultSources); + var source = TestHelper.Generate(compilation, new InMemoryAdditionalText("/ref/System.Runtime.xml", BclDocs)); + + Assert.Contains("/// OPENS A TEXT FILE, READS ALL THE TEXT IN THE FILE.", source); + Assert.Contains("/// The file to open for reading.", source); + } + + [Fact] + public void FallbackDocumentationIsUsedWhenNoXmlIsAvailable() + { + var source = TestHelper.Generate(TestHelper.CreateCompilation(TestHelper.DefaultSources)); + + Assert.Contains( + "/// Equivalent to .", + source); + } + + [Fact] + public void HandWrittenSignatureSuppressesGeneration() + { + const string members = """ + public static bool Exists(TruePath.AbsolutePath path) => true; + """; + var source = TestHelper.Generate( + TestHelper.CreateCompilation(TestHelper.SourcesWithHandWrittenMembers(members))); + + Assert.DoesNotContain("Exists(", source); + } + + [Fact] + public void HandWrittenForwardingCallSuppressesItsBclTarget() + { + 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))); + + Assert.DoesNotContain("Delete(AbsolutePath path, bool", source); + Assert.Contains( + "public static void Delete(this AbsolutePath path) => File.Delete(path.Value);", + source); + } + + [Fact] + public void ExplicitExclusionListSuppressesGeneration() + { + var compilation = TestHelper.CreateCompilation(TestHelper.DefaultSources); + var exclusions = new InMemoryAdditionalText( + "/project/path-io-exclusions.txt", + "M:System.IO.File.Encrypt(System.String)\n"); + var source = TestHelper.Generate(compilation, exclusions); + + Assert.DoesNotContain("Encrypt(", source); + } + + [Fact] + public void FileWinsOverDirectoryForDuplicateSignatures() + { + var source = TestHelper.Generate(TestHelper.CreateCompilation(TestHelper.DefaultSources)); + + Assert.Contains( + "public static DateTime GetCreationTime(this AbsolutePath path) => File.GetCreationTime(path.Value);", + source); + Assert.DoesNotContain("=> Directory.GetCreationTime(", source); + } + + [Fact] + public void PlatformAttributesAreForwarded() + { + var source = TestHelper.Generate(TestHelper.CreateCompilation(TestHelper.DefaultSources)); + + Assert.Contains("[UnsupportedOSPlatformAttribute(\"windows\")]", source); + } + + [Fact] + public void HistoricalParameterNamesAreKeptForFileNameParameters() + { + var source = TestHelper.Generate(TestHelper.CreateCompilation(TestHelper.DefaultSources)); + + Assert.Contains( + "public static void Copy(this AbsolutePath sourceFile, AbsolutePath destFile) => File.Copy(sourceFile.Value, destFile.Value);", + source); + Assert.Contains( + "public static void Copy(this AbsolutePath sourceFile, AbsolutePath destFile, bool overwrite)", + source); + } + + [Fact] + public void BclDocumentationTextFollowsRenamedParameters() + { + var source = TestHelper.Generate( + TestHelper.CreateCompilation(TestHelper.DefaultSources), + new InMemoryAdditionalText("/ref/System.Runtime.xml", """ + + + + Copies an existing file to a new file. + The file to copy. + The name of the destination file, see . + + + + """)); + + Assert.Contains("/// Copies an existing file to a new file.", source); + Assert.Contains("/// The file to copy.", source); + Assert.Contains("", source); + Assert.DoesNotContain("sourceFileName", source); + } + + [Fact] + public void GeneratedCodeCompilesWithoutErrors() + { + var compilation = TestHelper.CreateCompilation(TestHelper.DefaultSources); + GeneratorDriver driver = CSharpGeneratorDriver.Create( + new[] { new PathIoSourceGenerator().AsSourceGenerator() }, + ImmutableArray.Empty); + driver = driver.RunGenerators(compilation, Xunit.TestContext.Current.CancellationToken); + var updated = compilation.AddSyntaxTrees(driver.GetRunResult().GeneratedTrees); + + Assert.Empty(updated.GetDiagnostics(Xunit.TestContext.Current.CancellationToken) + .Where(d => d.Severity == DiagnosticSeverity.Error)); + } + + [Fact] + public void MethodsWithoutPathParametersAreListedAsSkipped() + { + var source = TestHelper.Generate(TestHelper.CreateCompilation(TestHelper.DefaultSources)); + + Assert.Contains("M:System.IO.Directory.GetCurrentDirectory", source); + Assert.Contains("M:System.IO.Directory.GetLogicalDrives", source); + } + + private static int CountOccurrences(string text, string pattern) + { + var count = 0; + var position = 0; + while ((position = text.IndexOf(pattern, position, StringComparison.Ordinal)) >= 0) + { + count++; + position += pattern.Length; + } + return count; + } +} diff --git a/TruePath.PathIoGenerator.Tests/TruePath.PathIoGenerator.Tests.csproj b/TruePath.PathIoGenerator.Tests/TruePath.PathIoGenerator.Tests.csproj new file mode 100644 index 0000000..bc79863 --- /dev/null +++ b/TruePath.PathIoGenerator.Tests/TruePath.PathIoGenerator.Tests.csproj @@ -0,0 +1,26 @@ + + + + + + net10.0 + Exe + false + true + false + + + + + + + + + + + + diff --git a/TruePath.PathIoGenerator/BclDocIndex.cs b/TruePath.PathIoGenerator/BclDocIndex.cs new file mode 100644 index 0000000..58922bc --- /dev/null +++ b/TruePath.PathIoGenerator/BclDocIndex.cs @@ -0,0 +1,194 @@ +// SPDX-FileCopyrightText: 2026 TruePath contributors +// +// SPDX-License-Identifier: MIT + +using System.Collections.Immutable; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using Microsoft.CodeAnalysis; + +namespace TruePath.PathIoGenerator; + +/// +/// Documentation lines for the System.IO.File/System.IO.Directory methods, extracted from the BCL +/// XML documentation files passed as additional files. The command-line compiler does not attach XML +/// documentation to referenced assemblies, so alone is not +/// enough in command-line builds. +/// +public sealed class BclDocIndex +{ + private readonly ImmutableArray _files; + private readonly ImmutableDictionary _memberXml; + + private BclDocIndex(ImmutableArray files, ImmutableDictionary memberXml) + { + _files = files; + _memberXml = memberXml; + } + + public bool TryGetMethodXml(string docId, out string memberXml) + { + return _memberXml.TryGetValue(docId, out memberXml!); + } + + public static BclDocIndex Build(ImmutableArray files) + { + // netstandard.xml is processed first, so that the framework-specific System.Runtime.xml wins on conflicts. + var orderedFiles = files + .Where(file => file.Path.EndsWith(".xml", StringComparison.Ordinal)) + .OrderBy(file => Path.GetFileName(file.Path) == "netstandard.xml" ? 0 : 1); + + var builder = ImmutableDictionary.CreateBuilder(); + foreach (var file in orderedFiles) + { + var text = file.GetText()?.ToString(); + if (string.IsNullOrEmpty(text)) 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 line in text!.Replace("\r\n", "\n").Split('\n')) + { + var trimmed = line.TrimStart(); + if (currentName == null) + { + if (!trimmed.StartsWith("", StringComparison.Ordinal) && currentName != null) + { + var memberXml = current.ToString(); + try + { + XElement.Parse(memberXml); + builder[currentName] = memberXml; + } + catch (System.Xml.XmlException) + { + // Skip a malformed member. + } + + currentName = null; + } + } + } + + return new BclDocIndex(files, builder.ToImmutable()); + } + + public override bool Equals(object? obj) + { + return obj is BclDocIndex other && _files.SequenceEqual(other._files); + } + + public override int GetHashCode() + { + return _files.Length; + } +} + +internal static class DocFormatting +{ + private static readonly Regex ParamRefRegex = new( + "[^\"]+)\"\\s*(?:/>|>(?[^<]*))", + RegexOptions.Compiled); + + // Some legacy BCL documentation contains docfx artifact crefs such as "~/docs/…​.md", which are not valid + // member references and would fail the XML documentation checks. + private static readonly Regex PairedSeeCrefRegex = new( + "<(?see|seealso)\\s+cref=\"(?[^\"]+)\">(?[^<]*)", + RegexOptions.Compiled); + + private static readonly Regex SelfClosingSeeCrefRegex = new( + "<(?:see|seealso)\\s+cref=\"(?[^\"]+)\"\\s*/>", + RegexOptions.Compiled); + + /// + /// Formats the child elements of a BCL `member` element as `///`-ready lines. Parameter references are + /// normalized against the actual parameter names of the generated method: the BCL documentation sometimes + /// mismatches them (in casing or entirely), which would break XML documentation checks. + /// + public static ImmutableArray ToDocLines(XElement member, ImmutableArray parameterNames) + { + var documentedParameters = new HashSet(StringComparer.Ordinal); + var elements = new List(); + foreach (var element in member.Elements()) + { + if (element.Name.LocalName == "param") + { + var name = element.Attribute("name")?.Value; + if (name == null) continue; + + var actual = MatchParameter(name, parameterNames); + if (actual == null) continue; // BCL documentation for a parameter this method does not have. + + if (!string.Equals(actual, name, StringComparison.Ordinal)) element.SetAttributeValue("name", actual); + documentedParameters.Add(actual); + } + + elements.Add(element.ToString(SaveOptions.DisableFormatting)); + } + + foreach (var missing in parameterNames.Where(p => !documentedParameters.Contains(p))) + elements.Add("See the documentation of the corresponding System.IO API."); + + return elements + .SelectMany(element => Rewrite(element, parameterNames).Replace("\r\n", "\n").Split('\n')) + .Where(line => line.Trim().Length > 0) + .ToImmutableArray(); + } + + /// + /// Finds the generated parameter a BCL documentation name refers to: either a direct (case-insensitive) + /// match, or a `…Name`-suffixed BCL spelling such as `sourceFileName` referring to the generated + /// `sourceFile` parameter. + /// + private static string? MatchParameter(string documentationName, ImmutableArray parameterNames) + { + var direct = parameterNames.FirstOrDefault(p => string.Equals(p, documentationName, StringComparison.OrdinalIgnoreCase)); + if (direct != null) return direct; + + return parameterNames.FirstOrDefault(p => + string.Equals(p + "Name", documentationName, StringComparison.OrdinalIgnoreCase)); + } + + private static string Rewrite(string line, ImmutableArray parameterNames) + { line = ParamRefRegex.Replace(line, match => + { + var actual = MatchParameter(match.Groups["name"].Value, parameterNames); + var inner = match.Groups["inner"].Success ? match.Groups["inner"].Value : null; + if (actual == null) return "" + (inner ?? match.Groups["name"].Value) + ""; + return inner == null + ? "" + : "" + inner + ""; + }); + + line = PairedSeeCrefRegex.Replace(line, match => + IsBadCref(match.Groups["cref"].Value) ? match.Groups["text"].Value : match.Value); + return SelfClosingSeeCrefRegex.Replace(line, match => + IsBadCref(match.Groups["cref"].Value) ? "" : match.Value); + } + + private static bool IsBadCref(string cref) + { + return cref.Contains('/') || cref.Contains('~'); + } +} diff --git a/TruePath.PathIoGenerator/DocId.cs b/TruePath.PathIoGenerator/DocId.cs new file mode 100644 index 0000000..4bb0b82 --- /dev/null +++ b/TruePath.PathIoGenerator/DocId.cs @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: 2026 TruePath contributors +// +// SPDX-License-Identifier: MIT + +using Microsoft.CodeAnalysis; + +namespace TruePath.PathIoGenerator; + +/// +/// Formats XML documentation IDs ("M:…") for the methods this generator works with. Only covers the shape of the +/// System.IO.File/System.IO.Directory signatures: named types, arrays, and closed generic +/// instantiations; no by-reference, pointer, or generic-method parameters occur there. +/// +internal static class DocId +{ + public static string ForMethod(IMethodSymbol method) + { + var parameters = string.Join(",", method.Parameters.Select(p => TypeReference(p.Type))); + return $"M:{TypeReference(method.ContainingType)}.{method.Name}({parameters})"; + } + + /// Strips the "M:" prefix, so the result is usable as a cref attribute value. + public static string ForCref(IMethodSymbol method) + { + return ForMethod(method).Substring(2); + } + + private static string TypeReference(ITypeSymbol type) + { + switch (type.SpecialType) + { + case SpecialType.System_String: + return "System.String"; + case SpecialType.System_Int32: + return "System.Int32"; + case SpecialType.System_Int64: + return "System.Int64"; + case SpecialType.System_Boolean: + return "System.Boolean"; + case SpecialType.System_Byte: + return "System.Byte"; + case SpecialType.System_Object: + return "System.Object"; + } + + switch (type) + { + case IArrayTypeSymbol array: + { + var suffix = array.Rank == 1 ? "[]" : "[" + new string(',', array.Rank - 1) + "]"; + return TypeReference(array.ElementType) + suffix; + } + case INamedTypeSymbol named: + { + var name = MetadataName(named); + if (named.TypeArguments.Length > 0) + name += "{" + string.Join(",", named.TypeArguments.Select(TypeReference)) + "}"; + return name; + } + default: + return type.ToDisplayString(); + } + } + + private static string MetadataName(INamedTypeSymbol type) + { + return type.ContainingNamespace is { IsGlobalNamespace: false } namespaceSymbol + ? namespaceSymbol.ToDisplayString() + "." + type.Name + : type.Name; + } +} diff --git a/TruePath.PathIoGenerator/Exclusions.cs b/TruePath.PathIoGenerator/Exclusions.cs new file mode 100644 index 0000000..fdb1bae --- /dev/null +++ b/TruePath.PathIoGenerator/Exclusions.cs @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2026 TruePath contributors +// +// SPDX-License-Identifier: MIT + +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; + +namespace TruePath.PathIoGenerator; + +/// +/// The parsed contents of the path-io-exclusions.txt 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 +/// must not be listed there. +/// +internal sealed class Exclusions +{ + public ImmutableHashSet DocIds { get; } + public ImmutableArray Diagnostics { get; } + + private Exclusions(ImmutableHashSet docIds, ImmutableArray diagnostics) + { + DocIds = docIds; + Diagnostics = diagnostics; + } + + public static Exclusions Parse(ImmutableArray fileTexts) + { + var docIds = ImmutableHashSet.CreateBuilder(); + var diagnostics = ImmutableArray.CreateBuilder(); + + foreach (var text in fileTexts) + foreach (var rawLine in text.Replace("\r\n", "\n").Split('\n')) + { + var line = rawLine.Trim(); + if (line.Length == 0 || line.StartsWith("#", StringComparison.Ordinal)) continue; + + if (line.StartsWith("M:", StringComparison.Ordinal)) + { + docIds.Add(line); + } + else + { + diagnostics.Add(Diagnostic.Create(GeneratorDiagnostics.InvalidExclusionEntry, Location.None, line)); + } + } + + return new Exclusions(docIds.ToImmutable(), diagnostics.ToImmutable()); + } +} diff --git a/TruePath.PathIoGenerator/GeneratorDiagnostics.cs b/TruePath.PathIoGenerator/GeneratorDiagnostics.cs new file mode 100644 index 0000000..0da2c2a --- /dev/null +++ b/TruePath.PathIoGenerator/GeneratorDiagnostics.cs @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2026 TruePath contributors +// +// SPDX-License-Identifier: MIT + +using Microsoft.CodeAnalysis; + +namespace TruePath.PathIoGenerator; + +internal static class GeneratorDiagnostics +{ + private const string Category = "PathIoGenerator"; + + public static readonly DiagnosticDescriptor RequiredTypeNotFound = new( + id: "TPG001", + title: "Required type not found", + messageFormat: "The type '{0}' was not found in the compilation, so the PathIo source generator cannot run", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor MarkedClassNotPartial = new( + id: "TPG002", + title: "Marked class is not partial", + messageFormat: "The class '{0}' is marked with GenerateSystemIoExtensionsAttribute, but it is not declared as partial", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor InvalidExclusionEntry = new( + id: "TPG003", + title: "Invalid exclusion list entry", + messageFormat: "The exclusion list line '{0}' is ignored: it is not a method documentation ID starting with 'M:'", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); +} diff --git a/TruePath.PathIoGenerator/PathIoGeneratorCore.cs b/TruePath.PathIoGenerator/PathIoGeneratorCore.cs new file mode 100644 index 0000000..74c23ad --- /dev/null +++ b/TruePath.PathIoGenerator/PathIoGeneratorCore.cs @@ -0,0 +1,408 @@ +// SPDX-FileCopyrightText: 2026 TruePath contributors +// +// SPDX-License-Identifier: MIT + +using System.Collections.Immutable; +using System.Globalization; +using System.Text; +using System.Xml.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace TruePath.PathIoGenerator; + +public sealed class GenerationResult(string? source, ImmutableArray diagnostics) +{ + public string? Source { get; } = source; + public ImmutableArray Diagnostics { get; } = diagnostics; +} + +/// +/// Generates the forwarding part of a class marked with GenerateSystemIoExtensionsAttribute: for every +/// public static method of System.IO.File and System.IO.Directory present in the current +/// compilation, a public static extension method on TruePath.AbsolutePath that forwards to it, with the +/// BCL XML documentation copied verbatim. +/// +/// A BCL method is not generated when: +/// +/// its ID is listed in path-io-exclusions.txt; +/// a handwritten member of the marked class already occupies the same rewritten signature; +/// a handwritten member forwards to it (detected by the forwarding call in its body); +/// another BCL method (File wins over Directory) already produced the same signature. +/// +/// +public static class PathIoGeneratorCore +{ + private const string GeneratorName = "TruePath.PathIoGenerator"; + private const string GeneratorVersion = "1.0.0"; + + private static readonly ImmutableHashSet ForwardedAttributes = ImmutableHashSet.Create( + "System.Runtime.Versioning.SupportedOSPlatformAttribute", + "System.Runtime.Versioning.UnsupportedOSPlatformAttribute", + "System.ObsoleteAttribute"); + + public static GenerationResult Generate( + Compilation compilation, + string markedTypeMetadataName, + ImmutableHashSet excludedDocIds, + BclDocIndex docIndex) + { + var diagnostics = ImmutableArray.CreateBuilder(); + + var fileType = compilation.GetTypeByMetadataName("System.IO.File"); + var directoryType = compilation.GetTypeByMetadataName("System.IO.Directory"); + var absolutePathType = compilation.GetTypeByMetadataName(PathIoRules.AbsolutePathTypeName); + var markedType = compilation.GetTypeByMetadataName(markedTypeMetadataName); + + foreach (var (name, symbol) in new[] + { + ("System.IO.File", fileType), + ("System.IO.Directory", directoryType), + (PathIoRules.AbsolutePathTypeName, absolutePathType), + (markedTypeMetadataName, markedType), + }) + if (symbol == null) + diagnostics.Add(Diagnostic.Create(GeneratorDiagnostics.RequiredTypeNotFound, Location.None, name)); + if (diagnostics.Count > 0) + return new GenerationResult(null, diagnostics.ToImmutable()); + + if (!IsDeclaredPartial(markedType!)) + { + diagnostics.Add(Diagnostic.Create( + GeneratorDiagnostics.MarkedClassNotPartial, + markedType!.Locations.FirstOrDefault() ?? Location.None, + markedType.Name)); + return new GenerationResult(null, diagnostics.ToImmutable()); + } + + var renderer = TypeRenderer.Create(compilation); + var handWritten = CollectHandWrittenInfo(compilation, markedType!, fileType!, directoryType!, absolutePathType!, renderer); + var generatedMethods = new List(); + var generatedKeys = new HashSet(); + var skippedWithoutPath = new SortedSet(); + + foreach (var owner in new[] { fileType!, directoryType! }) // File first, so File wins on duplicate signatures + foreach (var method in owner.GetMembers().OfType()) + { + if (method.MethodKind != MethodKind.Ordinary + || !method.IsStatic + || method.DeclaredAccessibility != Accessibility.Public + || method.IsGenericMethod + || method.Parameters.Any(p => p.RefKind != RefKind.None)) + continue; + + var docId = DocId.ForMethod(method); + if (excludedDocIds.Contains(docId) || handWritten.ForwardedDocIds.Contains(docId)) continue; + + var pathOrdinals = method.Parameters + .Where(p => p.Type.SpecialType == SpecialType.System_String && PathIoRules.IsPathParameterName(p.Name)) + .Select(p => p.Ordinal) + .ToImmutableHashSet(); + if (pathOrdinals.Count == 0 || !pathOrdinals.Contains(0)) + { + skippedWithoutPath.Add(docId); + continue; + } + + var key = SignatureKey(method.Name, method.Parameters.Select(p => TypeDisplay(p, pathOrdinals, absolutePathType!, renderer))); + if (handWritten.Signatures.Contains(key)) continue; + if (!generatedKeys.Add(key)) continue; + + generatedMethods.Add(BuildMethod(owner, method, pathOrdinals, absolutePathType!, docIndex, renderer)); + } + + var source = RenderFile(compilation, markedType!, generatedMethods, skippedWithoutPath, renderer); + return new GenerationResult(source, diagnostics.ToImmutable()); + } + + private static bool IsDeclaredPartial(INamedTypeSymbol type) + { + return type.DeclaringSyntaxReferences + .Select(reference => reference.GetSyntax()) + .OfType() + .Any(syntax => syntax.Modifiers.Any(SyntaxKind.PartialKeyword)); + } + + private static HandWrittenInfo CollectHandWrittenInfo( + Compilation compilation, + INamedTypeSymbol markedType, + INamedTypeSymbol fileType, + INamedTypeSymbol directoryType, + INamedTypeSymbol absolutePathType, + TypeRenderer renderer) + { + var signatures = new HashSet(); + var forwardedDocIds = new HashSet(); + var comparer = SymbolEqualityComparer.Default; + + foreach (var member in markedType.GetMembers().OfType()) + { + signatures.Add(SignatureKey(member.Name, member.Parameters.Select(p => TypeDisplay(p, null, absolutePathType, renderer)))); + + foreach (var reference in member.DeclaringSyntaxReferences) + { + var syntax = reference.GetSyntax(); + var model = compilation.GetSemanticModel(syntax.SyntaxTree); + foreach (var invocation in syntax.DescendantNodes().OfType()) + { + if (invocation.ArgumentList.Arguments.Count == 0) continue; + if (model.GetSymbolInfo(invocation).Symbol is not IMethodSymbol target) continue; + + var owner = target.ContainingType?.OriginalDefinition; + if (!comparer.Equals(owner, fileType.OriginalDefinition) + && !comparer.Equals(owner, directoryType.OriginalDefinition)) continue; + + if (!FirstArgumentDerivesFromPathParameter( + invocation.ArgumentList.Arguments[0], member, absolutePathType)) continue; + + forwardedDocIds.Add(DocId.ForMethod(target)); + } + } + } + + return new HandWrittenInfo(signatures, forwardedDocIds); + } + + /// + /// Matches the forwarding pattern `SomeBclCall(path.Value, …)` where `path` is a parameter of the handwritten + /// method typed as AbsolutePath. Anything less direct is deliberately not treated as a forwarding call, + /// so that incidental BCL calls inside custom logic cannot silently suppress generation. + /// + private static bool FirstArgumentDerivesFromPathParameter( + ArgumentSyntax argument, + IMethodSymbol enclosingMethod, + INamedTypeSymbol absolutePathType) + { + if (argument.Expression is not MemberAccessExpressionSyntax { Name.Identifier.ValueText: "Value" } access) return false; + if (access.Expression is not IdentifierNameSyntax identifier) return false; + + var parameter = enclosingMethod.Parameters.FirstOrDefault(p => p.Name == identifier.Identifier.ValueText); + return parameter != null + && SymbolEqualityComparer.Default.Equals(parameter.Type.OriginalDefinition, absolutePathType.OriginalDefinition); + } + + private static GeneratedMethod BuildMethod( + INamedTypeSymbol owner, + IMethodSymbol method, + ImmutableHashSet pathOrdinals, + INamedTypeSymbol absolutePathType, + BclDocIndex docIndex, + TypeRenderer renderer) + { + var parameterNames = method.Parameters + .Select(p => pathOrdinals.Contains(p.Ordinal) + ? PathIoRules.GeneratedParameterName(p.Name) + : p.Name) + .ToImmutableArray(); + return new GeneratedMethod( + method.Name, + renderer.Render(method.ReturnType), + method.Parameters + .Select(p => new GeneratedParameter( + TypeDisplay(p, pathOrdinals, absolutePathType, renderer), + parameterNames[p.Ordinal], + pathOrdinals.Contains(p.Ordinal), + RenderDefaultValue(p, renderer))) + .ToImmutableArray(), + renderer.Render(owner) + "." + method.Name, + method.GetAttributes() + .Where(a => a.AttributeClass != null && ForwardedAttributes.Contains(a.AttributeClass.ToDisplayString())) + .Select(attribute => RenderAttribute(attribute, renderer)) + .ToImmutableArray(), + BuildDocLines(method, docIndex, parameterNames)); + } + + private static string? RenderDefaultValue(IParameterSymbol parameter, TypeRenderer renderer) + { + if (!parameter.IsOptional) return null; + if (!parameter.HasExplicitDefaultValue) return "default"; + + var value = parameter.ExplicitDefaultValue; + switch (value) + { + // A null explicit default on an optional parameter means default(T): true null for reference types, + // and a null-valued default for value types such as CancellationToken. + case null: + return parameter.Type.IsValueType ? "default" : "null"; + case bool b: + return b ? "true" : "false"; + case string s: + return SymbolDisplay.FormatLiteral(s, quote: true); + } + + if (parameter.Type.TypeKind != TypeKind.Enum || parameter.Type is not INamedTypeSymbol enumType) + return "default"; + + var field = enumType.GetMembers().OfType() + .FirstOrDefault(f => f.IsConst && Equals(f.ConstantValue, value)); + if (field != null) return renderer.Render(enumType) + "." + field.Name; + + var number = Convert.ToString( + Convert.ToInt64(value, CultureInfo.InvariantCulture), + CultureInfo.InvariantCulture); + return "(" + renderer.Render(enumType) + ")" + number; + + } + + private static string RenderAttribute(AttributeData attribute, TypeRenderer renderer) + { + var arguments = string.Join(", ", attribute.ConstructorArguments + .Select(a => a.Value switch + { + string s => SymbolDisplay.FormatLiteral(s, quote: true), + bool b => b ? "true" : "false", + _ => null, + }) + .Where(rendered => rendered != null)); + + var name = renderer.Render(attribute.AttributeClass!); + return "[" + name + (arguments.Length == 0 ? "" : "(" + arguments + ")") + "]"; + } + private static ImmutableArray BuildDocLines( + IMethodSymbol method, + BclDocIndex docIndex, + ImmutableArray parameterNames) + { + if (docIndex.TryGetMethodXml(DocId.ForMethod(method), out var memberXml)) + try + { + return DocFormatting.ToDocLines(XElement.Parse(memberXml), parameterNames); + } + catch (Exception) + { + // Malformed documentation falls through to the next source below. + } + + var xml = method.GetDocumentationCommentXml(); + if (!string.IsNullOrWhiteSpace(xml)) + try + { + var root = XElement.Parse("" + xml!.Trim() + ""); + if (root.Element("member") is { } member) + return DocFormatting.ToDocLines(member, parameterNames); + } + catch (Exception) + { + // Malformed documentation falls back to the generated summary below. + } + + var cref = DocId.ForCref(method); + return + [ + $"Equivalent to .", + .. parameterNames.Select(name => + $"See the documentation of .") + ]; + } + + private static string TypeDisplay( + IParameterSymbol parameter, + ImmutableHashSet? pathOrdinals, + INamedTypeSymbol absolutePathType, + TypeRenderer renderer) + { + var isPath = parameter.Type.SpecialType == SpecialType.System_String + && pathOrdinals != null + && pathOrdinals.Contains(parameter.Ordinal); + return isPath ? renderer.Render(absolutePathType) : renderer.Render(parameter.Type); + } + + private static string SignatureKey(string name, IEnumerable parameterTypes) + { + return name + "(" + string.Join(", ", parameterTypes) + ")"; + } + + private static string RenderFile( + Compilation compilation, + INamedTypeSymbol markedType, + List methods, + SortedSet skippedWithoutPath, + TypeRenderer renderer) + { + var orderedMethods = methods + .OrderBy(m => m.Name, StringComparer.Ordinal) + .ThenBy(m => m.Parameters.Length) + .ThenBy(m => string.Join(", ", m.Parameters.Select(p => p.Type)), StringComparer.Ordinal) + .ToList(); + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("// Generated by the TruePath.PathIoGenerator source generator. Manual changes will be lost."); + sb.AppendLine("// "); + sb.AppendLine(); + sb.AppendLine("// SPDX-FileCopyrightText: 2025 .NET Foundation and Contributors"); + sb.AppendLine("// SPDX-FileCopyrightText: 2025-2026 TruePath contributors "); + sb.AppendLine("//"); + sb.AppendLine("// SPDX-License-Identifier: MIT"); + sb.AppendLine(); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + foreach (var importedNamespace in renderer.ImportedNamespaces.OrderBy(namespaceName => namespaceName, StringComparer.Ordinal)) + sb.AppendLine("using " + importedNamespace + ";"); + sb.AppendLine(); + sb.AppendLine("namespace " + markedType.ContainingNamespace.ToDisplayString() + ";"); + sb.AppendLine(); + sb.AppendLine("[global::System.CodeDom.Compiler.GeneratedCodeAttribute(\"" + GeneratorName + "\", \"" + GeneratorVersion + "\")]"); + sb.AppendLine("public static partial class " + markedType.Name); + sb.AppendLine("{"); + + for (var memberIndex = 0; memberIndex < orderedMethods.Count; memberIndex++) + { + var method = orderedMethods[memberIndex]; + if (memberIndex > 0) sb.AppendLine(); + + foreach (var line in method.DocLines) sb.AppendLine(" /// " + line); + foreach (var attribute in method.Attributes) sb.AppendLine(" " + attribute); + + var parameters = string.Join(", ", method.Parameters.Select((p, parameterIndex) => + (parameterIndex == 0 ? "this " : "") + p.Type + " " + p.Name + + (p.DefaultValue == null ? "" : " = " + p.DefaultValue))); + var arguments = string.Join(", ", method.Parameters.Select(p => p.IsPath ? p.Name + ".Value" : p.Name)); + sb.Append(" public static ").Append(method.ReturnType).Append(' ').Append(method.Name) + .Append('(').Append(parameters).Append(')') + .Append(" => ").Append(method.BclCall).Append('(').Append(arguments).Append(");"); + sb.AppendLine(); + } + + if (skippedWithoutPath.Count > 0) + { + sb.AppendLine(); + sb.AppendLine(" // BCL methods without a path parameter in position zero are not generated:"); + foreach (var docId in skippedWithoutPath) sb.AppendLine(" // " + docId); + } + + sb.AppendLine("}"); + return sb.ToString(); + } +} + +internal sealed class HandWrittenInfo(HashSet signatures, HashSet forwardedDocIds) +{ + public HashSet Signatures { get; } = signatures; + public HashSet ForwardedDocIds { get; } = forwardedDocIds; +} + +internal sealed class GeneratedMethod( + string name, + string returnType, + ImmutableArray parameters, + string bclCall, + ImmutableArray attributes, + ImmutableArray docLines) +{ + public string Name { get; } = name; + public string ReturnType { get; } = returnType; + public ImmutableArray Parameters { get; } = parameters; + public string BclCall { get; } = bclCall; + public ImmutableArray Attributes { get; } = attributes; + public ImmutableArray DocLines { get; } = docLines; +} + +internal sealed class GeneratedParameter(string type, string name, bool isPath, string? defaultValue) +{ + public string Type { get; } = type; + public string Name { get; } = name; + public bool IsPath { get; } = isPath; + public string? DefaultValue { get; } = defaultValue; +} diff --git a/TruePath.PathIoGenerator/PathIoRules.cs b/TruePath.PathIoGenerator/PathIoRules.cs new file mode 100644 index 0000000..7558d78 --- /dev/null +++ b/TruePath.PathIoGenerator/PathIoRules.cs @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2026 TruePath contributors +// +// SPDX-License-Identifier: MIT + +namespace TruePath.PathIoGenerator; + +/// +/// Naming and matching rules of the PathIo API generation, shared between the generator itself and the tests +/// that verify the completeness of the generated API surface. +/// +public static class PathIoRules +{ + public const string MarkerAttributeName = "TruePath.SystemIo.GenerateSystemIoExtensionsAttribute"; + public const string AbsolutePathTypeName = "TruePath.AbsolutePath"; + public const string ExclusionFileName = "path-io-exclusions.txt"; + + /// + /// Determines whether a System.String-typed parameter of a System.IO.File or + /// System.IO.Directory method is a path parameter, i.e. one that becomes a + /// TruePath.AbsolutePath in the generated API. This covers the parameter naming conventions actually + /// used by the BCL: path/linkPath/pathToTarget, sourceFile/destFile, and + /// the .NET Framework-era sourceFileName/destDirName spellings. + /// + public static bool IsPathParameterName(string parameterName) + { + return parameterName.Contains("path", StringComparison.OrdinalIgnoreCase) + || parameterName.EndsWith("file", StringComparison.OrdinalIgnoreCase) + || parameterName.EndsWith("dir", StringComparison.OrdinalIgnoreCase) + || parameterName.EndsWith("filename", StringComparison.OrdinalIgnoreCase) + || parameterName.EndsWith("dirname", StringComparison.OrdinalIgnoreCase); + } + + /// + /// The parameter name a path parameter gets in the generated API: the BCL `…FileName`/`…DirName` spellings + /// lose their `Name` suffix, so that e.g. Copy keeps the sourceFile/destFile parameter + /// names the library used when they were still hand-written. + /// + public static string GeneratedParameterName(string bclParameterName) + { + return bclParameterName.Length > 4 && bclParameterName.EndsWith("Name", StringComparison.OrdinalIgnoreCase) + ? bclParameterName.Substring(0, bclParameterName.Length - "Name".Length) + : bclParameterName; + } +} diff --git a/TruePath.PathIoGenerator/PathIoSourceGenerator.cs b/TruePath.PathIoGenerator/PathIoSourceGenerator.cs new file mode 100644 index 0000000..af71f2a --- /dev/null +++ b/TruePath.PathIoGenerator/PathIoSourceGenerator.cs @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: 2026 TruePath contributors +// +// SPDX-License-Identifier: MIT + +using System.Collections.Immutable; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace TruePath.PathIoGenerator; + +/// +/// Fills the other partial part of every class marked with TruePath.SystemIo.GenerateSystemIoExtensionsAttribute +/// with the BCL-forwarding extension methods described in . +/// +[Generator] +public sealed class PathIoSourceGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var markedTypes = context.SyntaxProvider.ForAttributeWithMetadataName( + PathIoRules.MarkerAttributeName, + static (node, _) => node is ClassDeclarationSyntax, + static (attributeContext, _) => attributeContext.TargetSymbol.ToDisplayString()); + + var additionalTexts = context.AdditionalTextsProvider.Collect(); + var docIndex = additionalTexts.Select(static (files, _) => BclDocIndex.Build(files)); + + var input = markedTypes.Collect() + .Combine(docIndex) + .Combine(additionalTexts) + .Combine(context.CompilationProvider); + + context.RegisterSourceOutput(input, static (productionContext, tuple) => + { + var (((typeNames, docIndex), additionalTexts), compilation) = tuple; + + var exclusions = Exclusions.Parse(additionalTexts + .Where(file => file.Path.EndsWith(PathIoRules.ExclusionFileName, StringComparison.Ordinal)) + .Select(file => file.GetText()?.ToString() ?? string.Empty) + .ToImmutableArray()); + foreach (var diagnostic in exclusions.Diagnostics) productionContext.ReportDiagnostic(diagnostic); + + foreach (var typeName in typeNames.Sort(StringComparer.Ordinal)) + { + var result = PathIoGeneratorCore.Generate(compilation, typeName, exclusions.DocIds, docIndex); + foreach (var diagnostic in result.Diagnostics) productionContext.ReportDiagnostic(diagnostic); + + if (result.Source is { } source) + { + var hintName = typeName.Substring(typeName.LastIndexOf('.') + 1) + ".Generated.g.cs"; + productionContext.AddSource(hintName, SourceText.From(source, Encoding.UTF8)); + } + } + }); + } +} diff --git a/TruePath.PathIoGenerator/TruePath.PathIoGenerator.csproj b/TruePath.PathIoGenerator/TruePath.PathIoGenerator.csproj new file mode 100644 index 0000000..c9eed61 --- /dev/null +++ b/TruePath.PathIoGenerator/TruePath.PathIoGenerator.csproj @@ -0,0 +1,27 @@ + + + + + + netstandard2.0 + false + false + true + true + + $(NoWarn);RS2008 + + + + + + + + + + + diff --git a/TruePath.PathIoGenerator/TypeRenderer.cs b/TruePath.PathIoGenerator/TypeRenderer.cs new file mode 100644 index 0000000..35027a9 --- /dev/null +++ b/TruePath.PathIoGenerator/TypeRenderer.cs @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2026 TruePath contributors +// +// SPDX-License-Identifier: MIT + +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; + +namespace TruePath.PathIoGenerator; + +/// +/// Renders type names for the generated source: types from the namespaces imported by the generated file use +/// their short names, and everything else is namespace-qualified. The imported namespace set is also the +/// single source of truth for the `using` directives the generated file emits, so the two cannot diverge. +/// +internal sealed class TypeRenderer +{ + private readonly ImmutableHashSet _importedNamespaces; + + private TypeRenderer(ImmutableHashSet importedNamespaces) + { + _importedNamespaces = importedNamespaces; + ImportedNamespaces = importedNamespaces; + } + + public ImmutableHashSet ImportedNamespaces { get; } + + public static TypeRenderer Create(Compilation compilation) + { + var namespaces = ImmutableHashSet.Create( + "System", + "System.Collections.Generic", + "System.IO", + "System.Text", + "System.Threading", + "System.Threading.Tasks", + "TruePath"); + + // Conditional namespaces must only be imported when the target framework has their types. + if (compilation.GetTypeByMetadataName("Microsoft.Win32.SafeHandles.SafeFileHandle") != null) + namespaces = namespaces.Add("Microsoft.Win32.SafeHandles"); + if (compilation.GetTypeByMetadataName("System.Runtime.Versioning.UnsupportedOSPlatformAttribute") != null) + namespaces = namespaces.Add("System.Runtime.Versioning"); + + return new TypeRenderer(namespaces); + } + + public string Render(ITypeSymbol type) + { + var name = type switch + { + IArrayTypeSymbol array => Render(array.ElementType) + + (array.Rank == 1 ? "[]" : "[" + new string(',', array.Rank - 1) + "]"), + INamedTypeSymbol named => RenderNamed(named), + _ => type.ToDisplayString(), + }; + + name = type.SpecialType switch + { + SpecialType.System_String => "string", + SpecialType.System_Int32 => "int", + SpecialType.System_Boolean => "bool", + SpecialType.System_Byte => "byte", + SpecialType.System_Int64 => "long", + SpecialType.System_Object => "object", + SpecialType.System_Void => "void", + _ => name, + }; + + if (type.IsReferenceType && type.NullableAnnotation == NullableAnnotation.Annotated) name += "?"; + return name; + } + + private string RenderNamed(INamedTypeSymbol named) + { + var namespaceName = named.ContainingNamespace is { IsGlobalNamespace: false } namespaceSymbol + ? namespaceSymbol.ToDisplayString() + : string.Empty; + var qualifier = _importedNamespaces.Contains(namespaceName) ? string.Empty : namespaceName + "."; + + var result = qualifier + ContainingTypesPrefix(named) + named.Name; + if (named.TypeArguments.Length > 0) + result += "<" + string.Join(", ", named.TypeArguments.Select(Render)) + ">"; + return result; + } + + private static string ContainingTypesPrefix(INamedTypeSymbol named) + { + var names = new List(); + for (var containing = named.ContainingType; containing != null; containing = containing.ContainingType) + names.Insert(0, containing.Name); + return names.Count == 0 ? string.Empty : string.Join(".", names) + "."; + } +} diff --git a/TruePath.SystemIo/GenerateSystemIoExtensionsAttribute.cs b/TruePath.SystemIo/GenerateSystemIoExtensionsAttribute.cs new file mode 100644 index 0000000..c04fc94 --- /dev/null +++ b/TruePath.SystemIo/GenerateSystemIoExtensionsAttribute.cs @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2026 TruePath contributors +// +// SPDX-License-Identifier: MIT + +namespace TruePath.SystemIo; + +/// +/// Marks a class as a generation target: the +/// TruePath.PathIoGenerator source generator will fill the other partial part with forwarding extension methods +/// for every public static method of System.IO.File and System.IO.Directory available in the +/// current target framework. Hand-written members of the marked class win over generated ones. +/// +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +internal sealed class GenerateSystemIoExtensionsAttribute : Attribute; diff --git a/TruePath.SystemIo/PathIo.cs b/TruePath.SystemIo/PathIo.cs index 9844a79..ffe1a84 100644 --- a/TruePath.SystemIo/PathIo.cs +++ b/TruePath.SystemIo/PathIo.cs @@ -3,283 +3,20 @@ // // SPDX-License-Identifier: MIT -using System.Text; -#if NET8_0_OR_GREATER -using System.Runtime.Versioning; -#endif - namespace TruePath.SystemIo; /// /// Extension methods for the type that provide common I/O operations. /// -public static class PathIo +[GenerateSystemIoExtensions] +public static partial class PathIo { - /// - /// Appends the specified lines to the file, creating the file if it does not already exist. - /// - /// The file to append to. - /// The lines to write to the file. - /// is . - /// is empty. - /// The specified path, file name, or both exceed the system-defined maximum length. - /// The specified path is invalid (for example, it is on an unmapped drive). - /// An I/O error occurred while opening the file. - /// specified a file that is read-only. - /// specified a file that is hidden. - /// specified a directory. - /// This operation is not supported on the current platform. - /// The caller does not have the required permission. - /// is in an invalid format. - /// - /// Given a sequence of strings and a file path, this method opens the specified file, appends the each string as a line to the end of the file using the specified encoding, - /// and then closes the file. The file handle is guaranteed to be closed by this method, even if exceptions are raised. The method creates the file - /// if it doesn't exist, but it doesn't create new directories. Therefore, the value of the path parameter must contain existing directories. - /// - public static void AppendAllLines(this AbsolutePath path, IEnumerable contents) => File.AppendAllLines(path.Value, contents); - - /// - /// Appends the specified lines to the file, creating the file if it does not already exist. - /// - /// The file to append to. - /// The lines to write to the file. - /// The encoding to apply to the each line. - /// is . - /// is empty. - /// is . - /// The specified path, file name, or both exceed the system-defined maximum length. - /// The specified path is invalid (for example, it is on an unmapped drive). - /// An I/O error occurred while opening the file. - /// specified a file that is read-only. - /// specified a file that is hidden. - /// specified a directory. - /// This operation is not supported on the current platform. - /// The caller does not have the required permission. - /// is in an invalid format. - /// - /// Given a sequence of strings and a file path, this method opens the specified file, appends the each string as a line to the end of the file using the specified encoding, - /// and then closes the file. The file handle is guaranteed to be closed by this method, even if exceptions are raised. The method creates the file - /// if it doesn't exist, but it doesn't create new directories. Therefore, the value of the path parameter must contain existing directories. - /// - public static void AppendAllLines(this AbsolutePath path, IEnumerable contents, Encoding encoding) => File.AppendAllLines(path.Value, contents, encoding); - -#if NET8_0_OR_GREATER - /// - /// Asynchronously appends the specified lines to the file, creating the file if it does not already exist. - /// - /// The file to append to. - /// The lines to write to the file. - /// The token to monitor for cancellation requests. The default value is . - /// A task that represents the asynchronous append operation. - /// is . - /// is empty. - /// The specified path, file name, or both exceed the system-defined maximum length. - /// The specified path is invalid (for example, it is on an unmapped drive). - /// An I/O error occurred while opening the file. - /// specified a file that is read-only. - /// specified a file that is hidden. - /// specified a directory. - /// This operation is not supported on the current platform. - /// The caller does not have the required permission. - /// is in an invalid format. - /// - /// Given a sequence of strings and a file path, this method opens the specified file, appends the each string as a line to the end of the file using the specified encoding, - /// and then closes the file. The file handle is guaranteed to be closed by this method, even if exceptions are raised. The method creates the file - /// if it doesn't exist, but it doesn't create new directories. Therefore, the value of the path parameter must contain existing directories. - /// - public static Task AppendAllLinesAsync(this AbsolutePath path, IEnumerable contents, CancellationToken cancellationToken = default) => File.AppendAllLinesAsync(path.Value, contents, cancellationToken); - - /// - /// Asynchronously appends the specified lines to the file, creating the file if it does not already exist. - /// - /// The file to append to. - /// The lines to write to the file. - /// The encoding to apply to the each line. - /// The token to monitor for cancellation requests. The default value is . - /// A task that represents the asynchronous append operation. - /// is . - /// is empty. - /// is . - /// The specified path, file name, or both exceed the system-defined maximum length. - /// The specified path is invalid (for example, it is on an unmapped drive). - /// An I/O error occurred while opening the file. - /// specified a file that is read-only. - /// specified a file that is hidden. - /// specified a directory. - /// This operation is not supported on the current platform. - /// The caller does not have the required permission. - /// is in an invalid format. - /// - /// Given a sequence of strings and a file path, this method opens the specified file, appends the each string as a line to the end of the file using the specified encoding, - /// and then closes the file. The file handle is guaranteed to be closed by this method, even if exceptions are raised. The method creates the file - /// if it doesn't exist, but it doesn't create new directories. Therefore, the value of the path parameter must contain existing directories. - /// - public static Task AppendAllLinesAsync(this AbsolutePath path, IEnumerable contents, Encoding encoding, CancellationToken cancellationToken = default) => File.AppendAllLinesAsync(path.Value, contents, encoding, cancellationToken); -#endif - - /// - /// Appends the specified string to the file, creating the file if it does not already exist. - /// - /// The file to append to. - /// The characters to write to the file. - /// is . - /// is empty. - /// The specified path, file name, or both exceed the system-defined maximum length. - /// The specified path is invalid (for example, it is on an unmapped drive). - /// An I/O error occurred while opening the file. - /// specified a file that is read-only. - /// specified a file that is hidden. - /// specified a directory. - /// This operation is not supported on the current platform. - /// The caller does not have the required permission. - /// is in an invalid format. - /// - /// Given a string and a file path, this method opens the specified file, appends the string to the end of the file using the specified encoding, - /// and then closes the file. The file handle is guaranteed to be closed by this method, even if exceptions are raised. The method creates the file - /// if it doesn't exist, but it doesn't create new directories. Therefore, the value of the path parameter must contain existing directories. - /// - public static void AppendAllText(this AbsolutePath path, string? contents) => File.AppendAllText(path.Value, contents); - - /// - /// Appends the specified string to the file, creating the file if it does not already exist. - /// - /// The file to append to. - /// The characters to write to the file. - /// The encoding to apply to the string. - /// is . - /// is empty. - /// is . - /// The specified path, file name, or both exceed the system-defined maximum length. - /// The specified path is invalid (for example, it is on an unmapped drive). - /// An I/O error occurred while opening the file. - /// specified a file that is read-only. - /// specified a file that is hidden. - /// specified a directory. - /// This operation is not supported on the current platform. - /// The caller does not have the required permission. - /// is in an invalid format. - /// - /// Given a string and a file path, this method opens the specified file, appends the string to the end of the file using the specified encoding, - /// and then closes the file. The file handle is guaranteed to be closed by this method, even if exceptions are raised. The method creates the file - /// if it doesn't exist, but it doesn't create new directories. Therefore, the value of the path parameter must contain existing directories. - /// - public static void AppendAllText(this AbsolutePath path, string? contents, Encoding encoding) => File.AppendAllText(path.Value, contents, encoding); - -#if NET8_0_OR_GREATER - /// - /// Asynchronously appends the specified string to the file, creating the file if it does not already exist. - /// - /// The file to append to. - /// The characters to write to the file. - /// The token to monitor for cancellation requests. The default value is . - /// A task that represents the asynchronous append operation. - /// is . - /// is empty. - /// The specified path, file name, or both exceed the system-defined maximum length. - /// The specified path is invalid (for example, it is on an unmapped drive). - /// An I/O error occurred while opening the file. - /// specified a file that is read-only. - /// specified a file that is hidden. - /// specified a directory. - /// This operation is not supported on the current platform. - /// The caller does not have the required permission. - /// is in an invalid format. - /// - /// Given a string and a file path, this method opens the specified file, appends the string to the end of the file using the specified encoding, - /// and then closes the file. The file handle is guaranteed to be closed by this method, even if exceptions are raised. The method creates the file - /// if it doesn't exist, but it doesn't create new directories. Therefore, the value of the path parameter must contain existing directories. - /// - public static Task AppendAllTextAsync(this AbsolutePath path, string? contents, CancellationToken cancellationToken = default) => File.AppendAllTextAsync(path.Value, contents, cancellationToken); - - /// - /// Asynchronously appends the specified string to the file, creating the file if it does not already exist. - /// - /// The file to append to. - /// The characters to write to the file. - /// The encoding to apply to the string. - /// The token to monitor for cancellation requests. The default value is . - /// A task that represents the asynchronous append operation. - /// is . - /// is empty. - /// is . - /// The specified path, file name, or both exceed the system-defined maximum length. - /// The specified path is invalid (for example, it is on an unmapped drive). - /// An I/O error occurred while opening the file. - /// specified a file that is read-only. - /// specified a file that is hidden. - /// specified a directory. - /// This operation is not supported on the current platform. - /// The caller does not have the required permission. - /// is in an invalid format. - /// - /// Given a string and a file path, this method opens the specified file, appends the string to the end of the file using the specified encoding, - /// and then closes the file. The file handle is guaranteed to be closed by this method, even if exceptions are raised. The method creates the file - /// if it doesn't exist, but it doesn't create new directories. Therefore, the value of the path parameter must contain existing directories. - /// - public static Task AppendAllTextAsync(this AbsolutePath path, string? contents, Encoding encoding, CancellationToken cancellationToken = default) => File.AppendAllTextAsync(path.Value, contents, encoding, cancellationToken); -#endif - - /// - /// Creates a that appends UTF-8 encoded text to an existing file, or to a new file if the specified file does not exist. - /// - /// The path to the file to append to. - /// A stream writer that appends UTF-8 encoded text to the specified file or to a new file. - public static StreamWriter AppendText(this AbsolutePath path) => File.AppendText(path.Value); - - /// - /// Creates or opens a file for writing UTF-8 encoded text. If the file already exists, its contents are replaced. - /// - /// The file to be opened for writing. - /// A that writes to the specified file using UTF-8 encoding. - public static StreamWriter CreateText(this AbsolutePath path) => File.CreateText(path.Value); - - /// - /// Copies an existing file to a new file. Overwriting a file of the same name is not allowed. - /// - /// The file to copy. - /// The name of the destination file. This cannot be a directory or an existing file. - public static void Copy(this AbsolutePath sourceFile, AbsolutePath destFile) => File.Copy(sourceFile.Value, destFile.Value); - - /// - /// Copies an existing file to a new file. Overwriting a file of the same name is allowed. - /// - /// The file to copy. - /// The name of the destination file. This cannot be a directory. - /// true if the destination file should be replaced if it already exists; otherwise, false. - public static void Copy(this AbsolutePath sourceFile, AbsolutePath destFile, bool overwrite) => File.Copy(sourceFile.Value, destFile.Value, overwrite); - - /// - /// Creates, or truncates and overwrites, a file in the specified path. - /// - /// The path and name of the file to create. - /// A that provides read/write access to the file specified in path. - public static FileStream Create(this AbsolutePath path) => File.Create(path.Value); - /// - /// Creates, or truncates and overwrites, a file in the specified path, specifying a buffer size. - /// - /// The path and name of the file to create. - /// The number of bytes buffered for reads and writes to the file. - /// A that provides read/write access to the file specified in path. - public static FileStream Create(this AbsolutePath path, int bufferSize) => File.Create(path.Value, bufferSize); - /// - /// Creates or overwrites a file in the specified path, specifying a buffer size and options that describe how to create or overwrite the file. - /// - /// The path and name of the file to create. - /// The number of bytes buffered for reads and writes to the file. - /// One of the values that describes how to create or overwrite the file. - /// A that provides read/write access to the file specified in path. - public static FileStream Create(this AbsolutePath path, int bufferSize, FileOptions options) => File.Create(path.Value, bufferSize, options); /// /// Creates all directories and subdirectories in the specified path unless they already exist. /// /// The directory to create. public static void CreateDirectory(this AbsolutePath path) => Directory.CreateDirectory(path.Value); /// - /// Deletes the specified file or directory. - /// - /// The name of the file to be deleted. Wildcard characters are not supported. - public static void Delete(this AbsolutePath path) => File.Delete(path.Value); - /// /// Deletes the specified empty directory. /// /// The name of the directory to remove. This directory must be writable and empty. @@ -308,585 +45,6 @@ public static class PathIo /// true if path refers to an existing directory; false if the directory does not exist or an error occurs when trying to determine if the specified directory exists. public static bool ExistsDirectory(this AbsolutePath path) => Directory.Exists(path.Value); - /// - /// Gets the of the file on the path. - /// - /// The path to the file. - /// The of the file on the path. - public static FileAttributes GetAttributes(this AbsolutePath path) => File.GetAttributes(path.Value); - - /// - /// Returns the creation date and time of the specified file or directory. - /// - /// The file or directory for which to obtain creation date and time information. - /// A structure set to the creation date and time for the specified file or directory. This value is expressed in local time. - public static DateTime GetCreationTime(this AbsolutePath path) => File.GetCreationTime(path.Value); - - /// - /// Returns the creation date and time, in Coordinated Universal Time (UTC), of the specified file or directory. - /// - /// The file or directory for which to obtain creation date and time information. - /// A structure set to the creation date and time for the specified file or directory. This value is expressed in UTC time. - public static DateTime GetCreationTimeUtc(this AbsolutePath path) => File.GetCreationTimeUtc(path.Value); - - /// - /// Returns the date and time the specified file or directory was last accessed. - /// - /// The file or directory for which to obtain access date and time information. - /// A structure set to the date and time that the specified file or directory was last accessed. This value is expressed in local time. - public static DateTime GetLastAccessTime(this AbsolutePath path) => File.GetLastAccessTime(path.Value); - - /// - /// Returns the date and time, in Coordinated Universal Time (UTC), that the specified file or directory was last accessed. - /// - /// The file or directory for which to obtain access date and time information. - /// A structure set to the date and time that the specified file or directory was last accessed. This value is expressed in UTC time. - public static DateTime GetLastAccessTimeUtc(this AbsolutePath path) => File.GetLastAccessTimeUtc(path.Value); - - /// - /// Returns the date and time the specified file or directory was last written to. - /// - /// The file or directory for which to obtain write date and time information. - /// A structure set to the date and time that the specified file or directory was last written to. This value is expressed in local time. - public static DateTime GetLastWriteTime(this AbsolutePath path) => File.GetLastWriteTime(path.Value); - - /// - /// Returns the date and time, in Coordinated Universal Time (UTC), that the specified file or directory was last written to. - /// - /// The file or directory for which to obtain write date and time information. - /// A structure set to the date and time that the specified file or directory was last written to. This value is expressed in UTC time. - public static DateTime GetLastWriteTimeUtc(this AbsolutePath path) => File.GetLastWriteTimeUtc(path.Value); - -#if NET8_0_OR_GREATER - /// - /// Gets the of the file on the path. - /// - /// The path to the file. - /// The of the file on the path. - [UnsupportedOSPlatform("windows")] - public static UnixFileMode GetUnixFileMode(this AbsolutePath path) => File.GetUnixFileMode(path.Value); -#endif - - /// - /// Moves a specified file to a new location, providing the option to specify a new file name. - /// - /// The name of the file to move. Can include a relative or absolute path. - /// The new path and name for the file. - public static void Move(this AbsolutePath sourceFile, AbsolutePath destFile) => File.Move(sourceFile.Value, destFile.Value); - -#if NET8_0_OR_GREATER - /// - /// Moves a specified file to a new location, providing the options to specify a new file name and to replace the destination file if it already exists. - /// - /// The name of the file to move. Can include a relative or absolute path. - /// The new path and name for the file. - /// true to replace the destination file if it already exists; false otherwise. - public static void Move(this AbsolutePath sourceFile, AbsolutePath destFile, bool overwrite) => File.Move(sourceFile.Value, destFile.Value, overwrite); -#endif - - /// - /// Opens a on the specified path with read/write access with no sharing. - /// - /// The file to open. - /// A value that specifies whether a file is created if one does not exist, and determines whether the contents of existing files are retained or overwritten. - /// A opened in the specified mode and path, with read/write access and not shared. - public static FileStream Open(this AbsolutePath path, FileMode mode) => File.Open(path.Value, mode); - - /// - /// Opens a on the specified path with the specified mode and access with no sharing. - /// - /// The file to open. - /// A value that specifies whether a file is created if one does not exist, and determines whether the contents of existing files are retained or overwritten. - /// A value that specifies the operations that can be performed on the file. - /// A opened in the specified mode and path, with the specified mode and access with no sharing. - public static FileStream Open(this AbsolutePath path, FileMode mode, FileAccess access) => File.Open(path.Value, mode, access); - /// - /// Opens a on the specified path, having the specified mode with read, write, or read/write access and the specified sharing option. - /// - /// The file to open. - /// A value that specifies whether a file is created if one does not exist, and determines whether the contents of existing files are retained or overwritten. - /// A value that specifies the operations that can be performed on the file. - /// A value specifying the type of access other threads have to the file. - /// A on the specified path, having the specified mode with read, write, or read/write access and the specified sharing option. - public static FileStream Open(this AbsolutePath path, FileMode mode, FileAccess access, FileShare share) => File.Open(path.Value, mode, access, share); - - /// - /// Opens an existing file for reading. - /// - /// The file to be opened for reading. - /// A read-only on the specified path. - public static FileStream OpenRead(this AbsolutePath path) => File.OpenRead(path.Value); - - /// - /// Opens an existing UTF-8 encoded text file for reading. - /// - /// The file to be opened for reading. - /// A on the specified path. - public static StreamReader OpenText(this AbsolutePath path) => File.OpenText(path.Value); - - /// - /// Opens an existing file or creates a new file for writing. - /// - /// The file to be opened for writing. - /// An unshared object on the specified path with access. - public static FileStream OpenWrite(this AbsolutePath path) => File.OpenWrite(path.Value); - - /// - /// Opens a binary file, reads the contents of the file into a byte array, and then closes the file. - /// - /// The file to open for reading. - /// A byte array containing the contents of the file. - public static byte[] ReadAllBytes(this AbsolutePath path) => File.ReadAllBytes(path.Value); - -#if NET8_0_OR_GREATER - /// - /// Asynchronously opens a binary file, reads the contents of the file into a byte array, and then closes the file. - /// - /// The file to open for reading. - /// The token to monitor for cancellation requests. The default value is . - /// A task that represents the asynchronous read operation, which wraps the byte array containing the contents of the file. - public static Task ReadAllBytesAsync(this AbsolutePath path, CancellationToken cancellationToken = default) => File.ReadAllBytesAsync(path.Value, cancellationToken); -#endif - - /// - /// Opens a text file, reads all lines of the file, and then closes the file. - /// - /// The file to open for reading. - /// A string array containing all lines of the file. - public static string[] ReadAllLines(this AbsolutePath path) => File.ReadAllLines(path.Value); - - /// - /// Opens a file, reads all lines of the file with the specified encoding, and then closes the file. - /// - /// The file to open for reading. - /// The encoding applied to the contents of the file. - /// A string array containing all lines of the file. - public static string[] ReadAllLines(this AbsolutePath path, Encoding encoding) => File.ReadAllLines(path.Value, encoding); - -#if NET8_0_OR_GREATER - /// - /// Asynchronously opens a text file, reads all lines of the file, and then closes the file. - /// - /// The file to open for reading. - /// The token to monitor for cancellation requests. The default value is . - /// A task that represents the asynchronous read operation, which wraps the string array containing all lines of the file. - public static Task ReadAllLinesAsync(this AbsolutePath path, CancellationToken cancellationToken = default) => File.ReadAllLinesAsync(path.Value, cancellationToken); - - /// - /// Asynchronously opens a text file, reads all lines of the file with the specified encoding, and then closes the file. - /// - /// The file to open for reading. - /// The encoding applied to the contents of the file. - /// The token to monitor for cancellation requests. The default value is . - /// A task that represents the asynchronous read operation, which wraps the string array containing all lines of the file. - public static Task ReadAllLinesAsync(this AbsolutePath path, Encoding encoding, CancellationToken cancellationToken = default) => File.ReadAllLinesAsync(path.Value, encoding, cancellationToken); -#endif - - /// - /// Reads the lines of a file. - /// - /// The file to read. - /// All the lines of the file, or the lines that are the result of a query. - public static IEnumerable ReadLines(this AbsolutePath path) => File.ReadLines(path.Value); - - /// - /// Read the lines of a file that has a specified encoding. - /// - /// The file to read. - /// The encoding that is applied to the contents of the file. - /// All the lines of the file, or the lines that are the result of a query. - public static IEnumerable ReadLines(this AbsolutePath path, Encoding encoding) => File.ReadLines(path.Value, encoding); - -#if NET8_0_OR_GREATER - /// - /// Asynchronously reads the lines of a file. - /// - /// The file to read. - /// The token to monitor for cancellation requests. The default value is . - /// The async enumerable that represents all the lines of the file, or the lines that are the result of a query. - public static IAsyncEnumerable ReadLinesAsync(this AbsolutePath path, CancellationToken cancellationToken = default) => File.ReadLinesAsync(path.Value, cancellationToken); - - /// - /// Asynchronously reads the lines of a file that has a specified encoding. - /// - /// The file to read. - /// The encoding that is applied to the contents of the file. - /// The token to monitor for cancellation requests. The default value is . - /// The async enumerable that represents all the lines of the file, or the lines that are the result of a query. - public static IAsyncEnumerable ReadLinesAsync(this AbsolutePath path, Encoding encoding, CancellationToken cancellationToken = default) => File.ReadLinesAsync(path.Value, encoding, cancellationToken); -#endif - - /// - /// Opens a text file, reads all the text in the file, and then closes the file. - /// - /// The file to open for reading. - /// A string containing all the text in the file. - public static string ReadAllText(this AbsolutePath path) => File.ReadAllText(path.Value); - - /// - /// Opens a text file, reads all the text in the file, and then closes the file. - /// - /// The file to open for reading. - /// The encoding that is applied to the contents of the file. - /// A string containing all the text in the file. - public static string ReadAllText(this AbsolutePath path, Encoding encoding) => File.ReadAllText(path.Value, encoding); - -#if NET8_0_OR_GREATER - /// - /// Asynchronously opens a text file, reads all the text in the file, and then closes the file. - /// - /// The file to open for reading. - /// The token to monitor for cancellation requests. The default value is . - /// A task that represents the asynchronous read operation, which wraps the string containing all text in the file. - public static Task ReadAllTextAsync(this AbsolutePath path, CancellationToken cancellationToken = default) => File.ReadAllTextAsync(path.Value, cancellationToken); - - /// - /// Asynchronously opens a text file, reads all text in the file with the specified encoding, and then closes the file. - /// - /// The file to open for reading. - /// The encoding that is applied to the contents of the file. - /// The token to monitor for cancellation requests. The default value is . - /// A task that represents the asynchronous read operation, which wraps the string containing all text in the file. - public static Task ReadAllTextAsync(this AbsolutePath path, Encoding encoding, CancellationToken cancellationToken = default) => File.ReadAllTextAsync(path.Value, encoding, cancellationToken); -#endif - - /// - /// Sets the specified of the file on the specified path. - /// - /// The path to the file. - /// A bitwise combination of the enumeration values. - public static void SetAttributes(this AbsolutePath path, FileAttributes fileAttributes) => File.SetAttributes(path.Value, fileAttributes); - - /// - /// Sets the date and time the file was created. - /// - /// The file for which to set the creation date and time information. - /// A containing the value to set for the creation date and time of . This value is expressed in local time. - public static void SetCreationTime(this AbsolutePath path, DateTime creationTime) => File.SetCreationTime(path.Value, creationTime); - - /// - /// Sets the date and time, in Coordinated Universal Time (UTC), that the file was created. - /// - /// The file for which to set the creation date and time information. - /// The value to set for the creation date and time of . This value is expressed in UTC time. - public static void SetCreationTimeUtc(this AbsolutePath path, DateTime creationTimeUtc) => File.SetCreationTimeUtc(path.Value, creationTimeUtc); - - /// - /// Sets the date and time the specified file was last accessed. - /// - /// The file for which to set the access date and time information. - /// A containing the value to set for the last access date and time of . This value is expressed in local time. - public static void SetLastAccessTime(this AbsolutePath path, DateTime lastAccessTime) => File.SetLastAccessTime(path.Value, lastAccessTime); - - /// - /// Sets the date and time, in Coordinated Universal Time (UTC), that the specified file was last accessed. - /// - /// The file for which to set the access date and time information. - /// A containing the value to set for the last access date and time of . This value is expressed in UTC time. - public static void SetLastAccessTimeUtc(this AbsolutePath path, DateTime lastAccessTimeUtc) => File.SetLastAccessTimeUtc(path.Value, lastAccessTimeUtc); - - /// - /// Sets the date and time that the specified file was last written to. - /// - /// The file for which to set the date and time information. - /// A containing the value to set for the last write date and time of . This value is expressed in local time. - public static void SetLastWriteTime(this AbsolutePath path, DateTime lastWriteTime) => File.SetLastWriteTime(path.Value, lastWriteTime); - - /// - /// Sets the date and time, in Coordinated Universal Time (UTC), that the specified file was last written to. - /// - /// The file for which to set the date and time information. - /// A containing the value to set for the last write date and time of . This value is expressed in UTC time. - public static void SetLastWriteTimeUtc(this AbsolutePath path, DateTime lastWriteTimeUtc) => File.SetLastWriteTimeUtc(path.Value, lastWriteTimeUtc); - - /// - /// Creates a new file, writes the specified byte array to the file, and then closes the file. If the target file already exists, it is truncated and overwritten. - /// - /// The file to write to. - /// The bytes to write to the file. - public static void WriteAllBytes(this AbsolutePath path, byte[] bytes) => File.WriteAllBytes(path.Value, bytes); - -#if NET8_0_OR_GREATER - /// - /// Asynchronously creates a new file, writes the specified byte array to the file, and then closes the file. If the target file already exists, it is truncated and overwritten. - /// - /// The file to write to. - /// The bytes to write to the file. - /// The token to monitor for cancellation requests. The default value is . - /// A task that represents the asynchronous write operation. - public static Task WriteAllBytesAsync(this AbsolutePath path, byte[] bytes, CancellationToken cancellationToken = default) => File.WriteAllBytesAsync(path.Value, bytes, cancellationToken); -#endif - - /// - /// Creates a new file, writes a collection of strings to the file, and then closes the file. - /// - /// The file to write to. - /// The lines to write to the file. - public static void WriteAllLines(this AbsolutePath path, IEnumerable contents) => File.WriteAllLines(path.Value, contents); - - /// - /// Creates a new file by using the specified encoding, writes a collection of strings to the file, and then closes the file. - /// - /// The file to write to. - /// The lines to write to the file. - /// The character encoding to use. - public static void WriteAllLines(this AbsolutePath path, IEnumerable contents, Encoding encoding) => File.WriteAllLines(path.Value, contents, encoding); - - /// - /// Creates a new file, write the specified string array to the file, and then closes the file. - /// - /// The file to write to. - /// The string array to write to the file. - public static void WriteAllLines(this AbsolutePath path, string[] contents) => File.WriteAllLines(path.Value, contents); - - /// - /// Creates a new file, writes the specified string array to the file by using the specified encoding, and then closes the file. - /// - /// The file to write to. - /// The string array to write to the file. - /// An object that represents the character encoding applied to the string array. - public static void WriteAllLines(this AbsolutePath path, string[] contents, Encoding encoding) => File.WriteAllLines(path.Value, contents, encoding); - -#if NET8_0_OR_GREATER - /// - /// Asynchronously creates a new file, writes the specified lines to the file, and then closes the file. - /// - /// The file to write to. - /// The lines to write to the file. - /// The token to monitor for cancellation requests. The default value is . - /// A task that represents the asynchronous write operation. - public static Task WriteAllLinesAsync(this AbsolutePath path, IEnumerable contents, CancellationToken cancellationToken = default) => File.WriteAllLinesAsync(path.Value, contents, cancellationToken); - - /// - /// Asynchronously creates a new file, write the specified lines to the file by using the specified encoding, and then closes the file. - /// - /// The file to write to. - /// The lines to write to the file. - /// The character encoding to use. - /// The token to monitor for cancellation requests. The default value is . - /// A task that represents the asynchronous write operation. - public static Task WriteAllLinesAsync(this AbsolutePath path, IEnumerable contents, Encoding encoding, CancellationToken cancellationToken = default) => File.WriteAllLinesAsync(path.Value, contents, encoding, cancellationToken); -#endif - - /// - /// Creates a new file, writes the specified string to the file, and then closes the file. If the target file already exists, it is truncated and overwritten. - /// - /// The file to write to. - /// The string to write to the file. - public static void WriteAllText(this AbsolutePath path, string? contents) => File.WriteAllText(path.Value, contents); - - /// - /// Creates a new file, writes the specified string to the file using the specified encoding, and then closes the file. If the target file already exists, it is truncated and overwritten. - /// - /// The file to write to. - /// The string to write to the file. - /// The character encoding to use. - public static void WriteAllText(this AbsolutePath path, string? contents, Encoding encoding) => File.WriteAllText(path.Value, contents, encoding); - -#if NET8_0_OR_GREATER - /// - /// Asynchronously creates a new file, writes the specified string to the file, and then closes the file. If the target file already exists, it is truncated and overwritten. - /// - /// The file to write to. - /// The string to write to the file. - /// The token to monitor for cancellation requests. The default value is . - /// A task that represents the asynchronous write operation. - public static Task WriteAllTextAsync(this AbsolutePath path, string? contents, CancellationToken cancellationToken = default) => File.WriteAllTextAsync(path.Value, contents, cancellationToken); - - /// - /// Asynchronously creates a new file, writes the specified string to the file using the specified encoding, and then closes the file. If the target file already exists, it is truncated and overwritten. - /// - /// The file to write to. - /// The string to write to the file. - /// The character encoding to use. - /// The token to monitor for cancellation requests. The default value is . - /// A task that represents the asynchronous write operation. - public static Task WriteAllTextAsync(this AbsolutePath path, string? contents, Encoding encoding, CancellationToken cancellationToken = default) => File.WriteAllTextAsync(path.Value, contents, encoding, cancellationToken); -#endif - - /// - /// Returns the names of files (including their paths) in the specified directory. - /// - /// The directory to search. - /// An array of the full names (including paths) for the files in the specified directory. - public static string[] GetFiles(this AbsolutePath path) => Directory.GetFiles(path.Value); - - /// - /// Returns the names of files (including their paths) that match the specified search pattern in the specified directory. - /// - /// The directory to search. - /// - /// - /// The search string to match against the names of files in . It may combine - /// literal characters with the * and ? wildcards, but it does not support regular - /// expressions. This overload matches with Win32 semantics, where * stands for zero or more - /// characters and ? for exactly one, except immediately before a period or at the end of the - /// name, where it may also match none: a?.txt matches both ab.txt and a.txt, - /// while ?ello.txt matches only hello.txt. - /// - /// - /// When running on .NET Framework on Windows, the pattern is also matched against the short (8.3) name of - /// each file, if it has one. Since a short name keeps at most three characters of the extension, - /// *.htm may also return index.html (whose short name is INDEX~1.HTM), and a pattern - /// such as *1* may match many files with no 1 in their long names, through the ~1 - /// suffix of their short names. Whether a file has a short name depends on the volume settings at the time - /// the file was created, so the results may differ between machines and even between files in the same - /// directory. See - /// Raymond Chen's explanation - /// for details. - /// - /// - /// For the full pattern syntax and its caveats, see - /// . - /// - /// - /// An array of the full names (including paths) for the files in the specified directory that match the specified search pattern. - public static string[] GetFiles(this AbsolutePath path, string searchPattern) => Directory.GetFiles(path.Value, searchPattern); - -#if NET8_0_OR_GREATER - /// - /// Returns the names of files (including their paths) that match the specified search pattern and enumeration options in the specified directory. - /// - /// The directory to search. - /// - /// - /// The search string to match against the names of files in . It may combine - /// literal characters with the * and ? wildcards, but it does not support regular - /// expressions. This overload matches with Win32 semantics, where * stands for zero or more - /// characters and ? for exactly one, except immediately before a period or at the end of the - /// name, where it may also match none: a?.txt matches both ab.txt and a.txt, - /// while ?ello.txt matches only hello.txt. - /// - /// - /// For the full pattern syntax and its caveats, see - /// . - /// - /// - /// An object that contains the search options to use. - /// An array of the full names (including paths) for the files in the specified directory that match the specified search pattern and enumeration options. - public static string[] GetFiles(this AbsolutePath path, string searchPattern, EnumerationOptions enumerationOptions) => Directory.GetFiles(path.Value, searchPattern, enumerationOptions); -#endif - - /// - /// Returns the names of files (including their paths) that match the specified search pattern in the specified directory, using a value to determine whether to search subdirectories. - /// - /// The directory to search. - /// - /// - /// The search string to match against the names of files in . It may combine - /// literal characters with the * and ? wildcards, but it does not support regular - /// expressions. This overload matches with Win32 semantics, where * stands for zero or more - /// characters and ? for exactly one, except immediately before a period or at the end of the - /// name, where it may also match none: a?.txt matches both ab.txt and a.txt, - /// while ?ello.txt matches only hello.txt. - /// - /// - /// When running on .NET Framework on Windows, the pattern is also matched against the short (8.3) name of - /// each file, if it has one. Since a short name keeps at most three characters of the extension, - /// *.htm may also return index.html (whose short name is INDEX~1.HTM), and a pattern - /// such as *1* may match many files with no 1 in their long names, through the ~1 - /// suffix of their short names. Whether a file has a short name depends on the volume settings at the time - /// the file was created, so the results may differ between machines and even between files in the same - /// directory. See - /// Raymond Chen's explanation - /// for details. - /// - /// - /// For the full pattern syntax and its caveats, see - /// . - /// - /// - /// One of the enumeration values that specifies whether the search operation should include only the current directory or should include all subdirectories. - /// An array of the full names (including paths) for the files in the specified directory that match the specified search pattern and option. - public static string[] GetFiles(this AbsolutePath path, string searchPattern, SearchOption searchOption) => Directory.GetFiles(path.Value, searchPattern, searchOption); - - /// - /// Returns the names of subdirectories (including their paths) in the specified directory. - /// - /// The directory to search. - /// An array of the full names (including paths) for the subdirectories in the specified directory. - public static string[] GetDirectories(this AbsolutePath path) => Directory.GetDirectories(path.Value); - - /// - /// Returns the names of subdirectories (including their paths) that match the specified search pattern in the specified directory. - /// - /// The directory to search. - /// - /// - /// The search string to match against the names of subdirectories in . It may - /// combine literal characters with the * and ? wildcards, but it does not support - /// regular expressions. This overload matches with Win32 semantics, where * stands for zero or - /// more characters and ? for exactly one, except immediately before a period or at the end of - /// the name, where it may also match none: a? matches both ab and a. - /// - /// - /// When running on .NET Framework on Windows, the pattern is also matched against the short (8.3) name of - /// each subdirectory, if it has one. So a pattern such as *1* may also return Program Files - /// (whose short name is PROGRA~1), and *.htm may return pages.html (whose short name - /// is PAGES~1.HTM). Whether a subdirectory has a short name depends on the volume settings at the time - /// it was created, so the results may differ between machines and even between subdirectories of the same - /// directory. See - /// Raymond Chen's explanation - /// for details. - /// - /// - /// For the full pattern syntax and its caveats, see - /// . - /// - /// - /// An array of the full names (including paths) for the subdirectories in the specified directory that match the specified search pattern. - public static string[] GetDirectories(this AbsolutePath path, string searchPattern) => Directory.GetDirectories(path.Value, searchPattern); - -#if NET8_0_OR_GREATER - /// - /// Returns the names of subdirectories (including their paths) that match the specified search pattern and enumeration options in the specified directory. - /// - /// The directory to search. - /// - /// The search string to match against the names of subdirectories in . It may combine literal - /// characters with the * (zero or more characters) and ? wildcards, but it does not support - /// regular expressions. Under the default , ? matches exactly one - /// character; under , selected through , it - /// may also match none immediately before a period or at the end of the name. For the full pattern syntax - /// and its caveats, see - /// . - /// - /// An object that contains the search options to use. - /// An array of the full names (including paths) for the subdirectories in the specified directory that match the specified search pattern and enumeration options. - public static string[] GetDirectories(this AbsolutePath path, string searchPattern, EnumerationOptions enumerationOptions) => Directory.GetDirectories(path.Value, searchPattern, enumerationOptions); -#endif - - /// - /// Returns the names of subdirectories (including their paths) that match the specified search pattern in the specified directory, using a value to determine whether to search subdirectories. - /// - /// The directory to search. - /// - /// - /// The search string to match against the names of subdirectories in . It may - /// combine literal characters with the * and ? wildcards, but it does not support - /// regular expressions. This overload matches with Win32 semantics, where * stands for zero or - /// more characters and ? for exactly one, except immediately before a period or at the end of - /// the name, where it may also match none: a? matches both ab and a. - /// - /// - /// When running on .NET Framework on Windows, the pattern is also matched against the short (8.3) name of - /// each subdirectory, if it has one. So a pattern such as *1* may also return Program Files - /// (whose short name is PROGRA~1), and *.htm may return pages.html (whose short name - /// is PAGES~1.HTM). Whether a subdirectory has a short name depends on the volume settings at the time - /// it was created, so the results may differ between machines and even between subdirectories of the same - /// directory. See - /// Raymond Chen's explanation - /// for details. - /// - /// - /// For the full pattern syntax and its caveats, see - /// . - /// - /// - /// One of the enumeration values that specifies whether the search operation should include only the current directory or should include all subdirectories. - /// An array of the full names (including paths) for the subdirectories in the specified directory that match the specified search pattern and option. - public static string[] GetDirectories(this AbsolutePath path, string searchPattern, SearchOption searchOption) => Directory.GetDirectories(path.Value, searchPattern, searchOption); - /// /// Returns an enumerable collection of of full file names in a specified path. /// diff --git a/TruePath.SystemIo/TruePath.SystemIo.csproj b/TruePath.SystemIo/TruePath.SystemIo.csproj index 4c65253..7a56293 100644 --- a/TruePath.SystemIo/TruePath.SystemIo.csproj +++ b/TruePath.SystemIo/TruePath.SystemIo.csproj @@ -19,5 +19,22 @@ SPDX-License-Identifier: MIT + + + + + + + + + + + + + diff --git a/TruePath.SystemIo/path-io-exclusions.txt b/TruePath.SystemIo/path-io-exclusions.txt new file mode 100644 index 0000000..9ec03a1 --- /dev/null +++ b/TruePath.SystemIo/path-io-exclusions.txt @@ -0,0 +1,13 @@ +# BCL methods that must not be exposed as PathIo extension methods. +# +# One XML documentation ID per line, e.g.: +# M:System.IO.Directory.Delete(System.String,System.Boolean) +# +# Methods covered by handwritten PathIo members are excluded automatically and +# must not be listed here. This holds both for handwritten members occupying +# the same rewritten signature (e.g. CreateDirectory), and for handwritten +# members whose body forwards to the BCL method with `path.Value` as the first +# argument (e.g. DeleteEmptyDirectory/DeleteDirectoryRecursively forwarding to +# Directory.Delete(string, bool)). +# +# (No entries yet.) diff --git a/TruePath.Tests/PathIoCompletenessTests.cs b/TruePath.Tests/PathIoCompletenessTests.cs new file mode 100644 index 0000000..5167372 --- /dev/null +++ b/TruePath.Tests/PathIoCompletenessTests.cs @@ -0,0 +1,113 @@ +// SPDX-FileCopyrightText: 2026 TruePath contributors +// +// SPDX-License-Identifier: MIT + +using System.Reflection; +using System.Runtime.Versioning; +using TruePath.PathIoGenerator; +using TruePath.SystemIo; + +namespace TruePath.Tests; + +/// +/// Verifies that every path-taking public static API of and is +/// exposed by : either generated by the TruePath.PathIoGenerator source generator, +/// provided by a hand-written member, or deliberately excluded via path-io-exclusions.txt. This mirrors the +/// generator rules, so a drift between them shows up as a failing assertion. +/// +public class PathIoCompletenessTests +{ + [Fact] + public void EveryPathTakingBclApiIsExposedByPathIo() + { + // Reflection over the runtime BCL only matches the compiled PathIo API when the running runtime is of the + // same major version as the target framework TruePath.SystemIo was built for. + var frameworkName = typeof(PathIo).Assembly.GetCustomAttribute()!.FrameworkName; + var targetFrameworkMajor = int.Parse(frameworkName.Split('v')[^1].Split('.')[0]); + if (Environment.Version.Major != targetFrameworkMajor) + { + Assert.Skip( + $"The runtime ({Environment.Version}) does not match the target framework of TruePath.SystemIo ({frameworkName})."); + } + + var exclusions = LoadExclusions(); + var pathIoMethods = typeof(PathIo) + .GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly) + .ToList(); + + foreach (var bclMethod in new[] { typeof(File), typeof(Directory) } + .SelectMany(type => type.GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly)) + .Where(method => !method.IsGenericMethod)) + { + var parameters = bclMethod.GetParameters(); + var pathOrdinals = parameters + .Select((parameter, index) => (parameter, index)) + .Where(pair => pair.parameter.ParameterType == typeof(string) + && PathIoRules.IsPathParameterName(pair.parameter.Name!)) + .Select(pair => pair.index) + .ToHashSet(); + if (pathOrdinals.Count == 0 || !pathOrdinals.Contains(0)) continue; // Not representable as an extension. + + var docId = DocId(bclMethod); + if (exclusions.Contains(docId)) continue; + + var expectedParameterTypes = parameters + .Select((parameter, index) => pathOrdinals.Contains(index) + ? "TruePath.AbsolutePath" + : DocIdType(parameter.ParameterType)) + .ToList(); + + var match = pathIoMethods.FirstOrDefault(method => + method.Name == bclMethod.Name + && method.GetParameters().Length == expectedParameterTypes.Count + && method.GetParameters().Select((parameter, index) => (parameter, index)) + .All(pair => PathIoParameterType(pair.parameter) == expectedParameterTypes[pair.index])); + + Assert.True( + match != null, + $"No PathIo counterpart for {docId}: add a hand-written or generated member, " + + "or exclude the method in TruePath.SystemIo/path-io-exclusions.txt."); + } + } + + private static string PathIoParameterType(ParameterInfo parameter) + { + return parameter.ParameterType == typeof(AbsolutePath) ? "TruePath.AbsolutePath" : DocIdType(parameter.ParameterType); + } + + private static HashSet LoadExclusions() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null && !File.Exists(Path.Combine(directory.FullName, "TruePath.sln"))) + directory = directory.Parent; + + if (directory == null) Assert.Skip("Repository root with TruePath.sln not found."); + + return File.ReadAllLines(Path.Combine(directory!.FullName, "TruePath.SystemIo", "path-io-exclusions.txt")) + .Select(line => line.Trim()) + .Where(line => line.Length > 0 && !line.StartsWith("#", StringComparison.Ordinal)) + .ToHashSet(); + } + + private static string DocId(MethodInfo method) + { + return $"M:{method.DeclaringType!.FullName}.{method.Name}({string.Join(",", method.GetParameters().Select(p => DocIdType(p.ParameterType)))})"; + } + + /// Renders types in the same shape the generator uses for its XML documentation IDs. + private static string DocIdType(Type type) + { + if (type.IsArray) return DocIdType(type.GetElementType()!) + "[]"; + + if (type.IsGenericType) + { + var definition = type.GetGenericTypeDefinition(); + var name = definition.FullName!; + var arity = definition.GetGenericArguments().Length; + name = name.Substring(0, name.IndexOf('`')); + return name + "{" + string.Join(",", type.GetGenericArguments().Select(DocIdType)) + "}"; + } + + return type.FullName!; + } +} diff --git a/TruePath.Tests/TruePath.Tests.csproj b/TruePath.Tests/TruePath.Tests.csproj index 810aa61..f9dd312 100644 --- a/TruePath.Tests/TruePath.Tests.csproj +++ b/TruePath.Tests/TruePath.Tests.csproj @@ -24,6 +24,8 @@ SPDX-License-Identifier: MIT + + diff --git a/TruePath.sln b/TruePath.sln index d0459f9..1963fd3 100644 --- a/TruePath.sln +++ b/TruePath.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 @@ -30,8 +30,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "scripts", "scripts", "{8C06 EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{10D9133A-772F-4364-929E-EEAE2E5DF04D}" - ProjectSection(SolutionItems) = preProject - EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{55C1E2A5-1630-4C22-A403-65DA2B1B969D}" ProjectSection(SolutionItems) = preProject @@ -68,33 +66,97 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{98C65796-8 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TruePath.SystemIo", "TruePath.SystemIo\TruePath.SystemIo.csproj", "{46252949-EA57-49F5-BE71-2F387DA173E8}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TruePath.PathIoGenerator", "TruePath.PathIoGenerator\TruePath.PathIoGenerator.csproj", "{48FE6B83-4F3B-442E-A651-4901DE88E111}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TruePath.PathIoGenerator.Tests", "TruePath.PathIoGenerator.Tests\TruePath.PathIoGenerator.Tests.csproj", "{01937DD8-3DE0-4262-9E16-2740E95F75D6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {55C1E2A5-1630-4C22-A403-65DA2B1B969D} = {10D9133A-772F-4364-929E-EEAE2E5DF04D} + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {DB806921-D806-452A-99FB-94FC725291DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {DB806921-D806-452A-99FB-94FC725291DC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DB806921-D806-452A-99FB-94FC725291DC}.Debug|x64.ActiveCfg = Debug|Any CPU + {DB806921-D806-452A-99FB-94FC725291DC}.Debug|x64.Build.0 = Debug|Any CPU + {DB806921-D806-452A-99FB-94FC725291DC}.Debug|x86.ActiveCfg = Debug|Any CPU + {DB806921-D806-452A-99FB-94FC725291DC}.Debug|x86.Build.0 = Debug|Any CPU {DB806921-D806-452A-99FB-94FC725291DC}.Release|Any CPU.ActiveCfg = Release|Any CPU {DB806921-D806-452A-99FB-94FC725291DC}.Release|Any CPU.Build.0 = Release|Any CPU + {DB806921-D806-452A-99FB-94FC725291DC}.Release|x64.ActiveCfg = Release|Any CPU + {DB806921-D806-452A-99FB-94FC725291DC}.Release|x64.Build.0 = Release|Any CPU + {DB806921-D806-452A-99FB-94FC725291DC}.Release|x86.ActiveCfg = Release|Any CPU + {DB806921-D806-452A-99FB-94FC725291DC}.Release|x86.Build.0 = Release|Any CPU {7E3E01D3-19A2-4583-9895-E2AEDC3ED07A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7E3E01D3-19A2-4583-9895-E2AEDC3ED07A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7E3E01D3-19A2-4583-9895-E2AEDC3ED07A}.Debug|x64.ActiveCfg = Debug|Any CPU + {7E3E01D3-19A2-4583-9895-E2AEDC3ED07A}.Debug|x64.Build.0 = Debug|Any CPU + {7E3E01D3-19A2-4583-9895-E2AEDC3ED07A}.Debug|x86.ActiveCfg = Debug|Any CPU + {7E3E01D3-19A2-4583-9895-E2AEDC3ED07A}.Debug|x86.Build.0 = Debug|Any CPU {7E3E01D3-19A2-4583-9895-E2AEDC3ED07A}.Release|Any CPU.ActiveCfg = Release|Any CPU {7E3E01D3-19A2-4583-9895-E2AEDC3ED07A}.Release|Any CPU.Build.0 = Release|Any CPU + {7E3E01D3-19A2-4583-9895-E2AEDC3ED07A}.Release|x64.ActiveCfg = Release|Any CPU + {7E3E01D3-19A2-4583-9895-E2AEDC3ED07A}.Release|x64.Build.0 = Release|Any CPU + {7E3E01D3-19A2-4583-9895-E2AEDC3ED07A}.Release|x86.ActiveCfg = Release|Any CPU + {7E3E01D3-19A2-4583-9895-E2AEDC3ED07A}.Release|x86.Build.0 = Release|Any CPU {4FB7CAE4-241F-4DA8-ABC7-699BEAFBC637}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4FB7CAE4-241F-4DA8-ABC7-699BEAFBC637}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4FB7CAE4-241F-4DA8-ABC7-699BEAFBC637}.Debug|x64.ActiveCfg = Debug|Any CPU + {4FB7CAE4-241F-4DA8-ABC7-699BEAFBC637}.Debug|x64.Build.0 = Debug|Any CPU + {4FB7CAE4-241F-4DA8-ABC7-699BEAFBC637}.Debug|x86.ActiveCfg = Debug|Any CPU + {4FB7CAE4-241F-4DA8-ABC7-699BEAFBC637}.Debug|x86.Build.0 = Debug|Any CPU {4FB7CAE4-241F-4DA8-ABC7-699BEAFBC637}.Release|Any CPU.ActiveCfg = Release|Any CPU {4FB7CAE4-241F-4DA8-ABC7-699BEAFBC637}.Release|Any CPU.Build.0 = Release|Any CPU + {4FB7CAE4-241F-4DA8-ABC7-699BEAFBC637}.Release|x64.ActiveCfg = Release|Any CPU + {4FB7CAE4-241F-4DA8-ABC7-699BEAFBC637}.Release|x64.Build.0 = Release|Any CPU + {4FB7CAE4-241F-4DA8-ABC7-699BEAFBC637}.Release|x86.ActiveCfg = Release|Any CPU + {4FB7CAE4-241F-4DA8-ABC7-699BEAFBC637}.Release|x86.Build.0 = Release|Any CPU {46252949-EA57-49F5-BE71-2F387DA173E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {46252949-EA57-49F5-BE71-2F387DA173E8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {46252949-EA57-49F5-BE71-2F387DA173E8}.Debug|x64.ActiveCfg = Debug|Any CPU + {46252949-EA57-49F5-BE71-2F387DA173E8}.Debug|x64.Build.0 = Debug|Any CPU + {46252949-EA57-49F5-BE71-2F387DA173E8}.Debug|x86.ActiveCfg = Debug|Any CPU + {46252949-EA57-49F5-BE71-2F387DA173E8}.Debug|x86.Build.0 = Debug|Any CPU {46252949-EA57-49F5-BE71-2F387DA173E8}.Release|Any CPU.ActiveCfg = Release|Any CPU {46252949-EA57-49F5-BE71-2F387DA173E8}.Release|Any CPU.Build.0 = Release|Any CPU + {46252949-EA57-49F5-BE71-2F387DA173E8}.Release|x64.ActiveCfg = Release|Any CPU + {46252949-EA57-49F5-BE71-2F387DA173E8}.Release|x64.Build.0 = Release|Any CPU + {46252949-EA57-49F5-BE71-2F387DA173E8}.Release|x86.ActiveCfg = Release|Any CPU + {46252949-EA57-49F5-BE71-2F387DA173E8}.Release|x86.Build.0 = Release|Any CPU + {48FE6B83-4F3B-442E-A651-4901DE88E111}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {48FE6B83-4F3B-442E-A651-4901DE88E111}.Debug|Any CPU.Build.0 = Debug|Any CPU + {48FE6B83-4F3B-442E-A651-4901DE88E111}.Debug|x64.ActiveCfg = Debug|Any CPU + {48FE6B83-4F3B-442E-A651-4901DE88E111}.Debug|x64.Build.0 = Debug|Any CPU + {48FE6B83-4F3B-442E-A651-4901DE88E111}.Debug|x86.ActiveCfg = Debug|Any CPU + {48FE6B83-4F3B-442E-A651-4901DE88E111}.Debug|x86.Build.0 = Debug|Any CPU + {48FE6B83-4F3B-442E-A651-4901DE88E111}.Release|Any CPU.ActiveCfg = Release|Any CPU + {48FE6B83-4F3B-442E-A651-4901DE88E111}.Release|Any CPU.Build.0 = Release|Any CPU + {48FE6B83-4F3B-442E-A651-4901DE88E111}.Release|x64.ActiveCfg = Release|Any CPU + {48FE6B83-4F3B-442E-A651-4901DE88E111}.Release|x64.Build.0 = Release|Any CPU + {48FE6B83-4F3B-442E-A651-4901DE88E111}.Release|x86.ActiveCfg = Release|Any CPU + {48FE6B83-4F3B-442E-A651-4901DE88E111}.Release|x86.Build.0 = Release|Any CPU + {01937DD8-3DE0-4262-9E16-2740E95F75D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {01937DD8-3DE0-4262-9E16-2740E95F75D6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {01937DD8-3DE0-4262-9E16-2740E95F75D6}.Debug|x64.ActiveCfg = Debug|Any CPU + {01937DD8-3DE0-4262-9E16-2740E95F75D6}.Debug|x64.Build.0 = Debug|Any CPU + {01937DD8-3DE0-4262-9E16-2740E95F75D6}.Debug|x86.ActiveCfg = Debug|Any CPU + {01937DD8-3DE0-4262-9E16-2740E95F75D6}.Debug|x86.Build.0 = Debug|Any CPU + {01937DD8-3DE0-4262-9E16-2740E95F75D6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {01937DD8-3DE0-4262-9E16-2740E95F75D6}.Release|Any CPU.Build.0 = Release|Any CPU + {01937DD8-3DE0-4262-9E16-2740E95F75D6}.Release|x64.ActiveCfg = Release|Any CPU + {01937DD8-3DE0-4262-9E16-2740E95F75D6}.Release|x64.Build.0 = Release|Any CPU + {01937DD8-3DE0-4262-9E16-2740E95F75D6}.Release|x86.ActiveCfg = Release|Any CPU + {01937DD8-3DE0-4262-9E16-2740E95F75D6}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {55C1E2A5-1630-4C22-A403-65DA2B1B969D} = {10D9133A-772F-4364-929E-EEAE2E5DF04D} EndGlobalSection EndGlobal From f82b8f39241f640e770b1aaa7e06d821cdfdd39e Mon Sep 17 00:00:00 2001 From: ghostflyby Date: Wed, 21 Oct 2026 16:53:05 +0800 Subject: [PATCH 2/6] refactor(PathIoGenerator): cleanup, cache parsed members in the doc index - Drop dead test helpers, redundant nullability and qualifiers, an unused RenderFile parameter, and a redundant currentName check in BclDocIndex. - Store parsed XElement members in the doc index instead of raw strings: XElement.Parse is now called exactly once per member with its result actually used, instead of a validation-only call with a discarded return value followed by a second parse at emission time. The cached element is cloned before doc formatting, which renames param elements in place. --- .../PathIoGeneratorTests.cs | 23 ++++--------------- TruePath.PathIoGenerator/BclDocIndex.cs | 18 +++++++-------- .../PathIoGeneratorCore.cs | 18 ++++----------- TruePath.Tests/PathIoCompletenessTests.cs | 3 +-- 4 files changed, 19 insertions(+), 43 deletions(-) diff --git a/TruePath.PathIoGenerator.Tests/PathIoGeneratorTests.cs b/TruePath.PathIoGenerator.Tests/PathIoGeneratorTests.cs index e7b1bd7..eea81cc 100644 --- a/TruePath.PathIoGenerator.Tests/PathIoGeneratorTests.cs +++ b/TruePath.PathIoGenerator.Tests/PathIoGeneratorTests.cs @@ -7,7 +7,6 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Text; -using TruePath.PathIoGenerator; using Xunit; namespace TruePath.PathIoGenerator.Tests; @@ -15,7 +14,7 @@ namespace TruePath.PathIoGenerator.Tests; public sealed class InMemoryAdditionalText(string path, string text) : AdditionalText { public override string Path { get; } = path; - public override SourceText? GetText(CancellationToken cancellationToken = default) => SourceText.From(text, Encoding.UTF8); + public override SourceText GetText(CancellationToken cancellationToken = default) => SourceText.From(text, Encoding.UTF8); } public static class TestHelper @@ -127,8 +126,8 @@ public void BclDocIndexFindsBclMembers() var index = BclDocIndex.Build( ImmutableArray.Create(new InMemoryAdditionalText("/ref/System.Runtime.xml", BclDocs))); - Assert.True(index.TryGetMethodXml("M:System.IO.File.ReadAllText(System.String)", out var memberXml)); - Assert.Contains("OPENS A TEXT FILE, READS ALL THE TEXT IN THE FILE.", memberXml); + 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()); } [Fact] @@ -266,10 +265,10 @@ public void GeneratedCodeCompilesWithoutErrors() GeneratorDriver driver = CSharpGeneratorDriver.Create( new[] { new PathIoSourceGenerator().AsSourceGenerator() }, ImmutableArray.Empty); - driver = driver.RunGenerators(compilation, Xunit.TestContext.Current.CancellationToken); + driver = driver.RunGenerators(compilation, TestContext.Current.CancellationToken); var updated = compilation.AddSyntaxTrees(driver.GetRunResult().GeneratedTrees); - Assert.Empty(updated.GetDiagnostics(Xunit.TestContext.Current.CancellationToken) + Assert.Empty(updated.GetDiagnostics(TestContext.Current.CancellationToken) .Where(d => d.Severity == DiagnosticSeverity.Error)); } @@ -281,16 +280,4 @@ public void MethodsWithoutPathParametersAreListedAsSkipped() Assert.Contains("M:System.IO.Directory.GetCurrentDirectory", source); Assert.Contains("M:System.IO.Directory.GetLogicalDrives", source); } - - private static int CountOccurrences(string text, string pattern) - { - var count = 0; - var position = 0; - while ((position = text.IndexOf(pattern, position, StringComparison.Ordinal)) >= 0) - { - count++; - position += pattern.Length; - } - return count; - } } diff --git a/TruePath.PathIoGenerator/BclDocIndex.cs b/TruePath.PathIoGenerator/BclDocIndex.cs index 58922bc..c1382a7 100644 --- a/TruePath.PathIoGenerator/BclDocIndex.cs +++ b/TruePath.PathIoGenerator/BclDocIndex.cs @@ -19,17 +19,17 @@ namespace TruePath.PathIoGenerator; public sealed class BclDocIndex { private readonly ImmutableArray _files; - private readonly ImmutableDictionary _memberXml; + private readonly ImmutableDictionary _members; - private BclDocIndex(ImmutableArray files, ImmutableDictionary memberXml) + private BclDocIndex(ImmutableArray files, ImmutableDictionary members) { _files = files; - _memberXml = memberXml; + _members = members; } - public bool TryGetMethodXml(string docId, out string memberXml) + public bool TryGetMethodMember(string docId, out XElement member) { - return _memberXml.TryGetValue(docId, out memberXml!); + return _members.TryGetValue(docId, out member!); } public static BclDocIndex Build(ImmutableArray files) @@ -39,7 +39,7 @@ public static BclDocIndex Build(ImmutableArray files) .Where(file => file.Path.EndsWith(".xml", StringComparison.Ordinal)) .OrderBy(file => Path.GetFileName(file.Path) == "netstandard.xml" ? 0 : 1); - var builder = ImmutableDictionary.CreateBuilder(); + var builder = ImmutableDictionary.CreateBuilder(); foreach (var file in orderedFiles) { var text = file.GetText()?.ToString(); @@ -73,13 +73,11 @@ public static BclDocIndex Build(ImmutableArray files) current.Append('\n').Append(line); } - if (trimmed.TrimEnd().EndsWith("", StringComparison.Ordinal) && currentName != null) + if (trimmed.TrimEnd().EndsWith("", StringComparison.Ordinal)) { - var memberXml = current.ToString(); try { - XElement.Parse(memberXml); - builder[currentName] = memberXml; + builder[currentName] = XElement.Parse(current.ToString()); } catch (System.Xml.XmlException) { diff --git a/TruePath.PathIoGenerator/PathIoGeneratorCore.cs b/TruePath.PathIoGenerator/PathIoGeneratorCore.cs index 74c23ad..db40652 100644 --- a/TruePath.PathIoGenerator/PathIoGeneratorCore.cs +++ b/TruePath.PathIoGenerator/PathIoGeneratorCore.cs @@ -112,7 +112,7 @@ public static GenerationResult Generate( generatedMethods.Add(BuildMethod(owner, method, pathOrdinals, absolutePathType!, docIndex, renderer)); } - var source = RenderFile(compilation, markedType!, generatedMethods, skippedWithoutPath, renderer); + var source = RenderFile(markedType!, generatedMethods, skippedWithoutPath, renderer); return new GenerationResult(source, diagnostics.ToImmutable()); } @@ -264,15 +264,9 @@ private static ImmutableArray BuildDocLines( BclDocIndex docIndex, ImmutableArray parameterNames) { - if (docIndex.TryGetMethodXml(DocId.ForMethod(method), out var memberXml)) - try - { - return DocFormatting.ToDocLines(XElement.Parse(memberXml), parameterNames); - } - catch (Exception) - { - // Malformed documentation falls through to the next source below. - } + if (docIndex.TryGetMethodMember(DocId.ForMethod(method), out var cachedMember)) + // The cached element is cloned because ToDocLines renames its param elements in place. + return DocFormatting.ToDocLines(new XElement(cachedMember), parameterNames); var xml = method.GetDocumentationCommentXml(); if (!string.IsNullOrWhiteSpace(xml)) @@ -313,9 +307,7 @@ private static string SignatureKey(string name, IEnumerable parameterTyp return name + "(" + string.Join(", ", parameterTypes) + ")"; } - private static string RenderFile( - Compilation compilation, - INamedTypeSymbol markedType, + private static string RenderFile(INamedTypeSymbol markedType, List methods, SortedSet skippedWithoutPath, TypeRenderer renderer) diff --git a/TruePath.Tests/PathIoCompletenessTests.cs b/TruePath.Tests/PathIoCompletenessTests.cs index 5167372..25851f1 100644 --- a/TruePath.Tests/PathIoCompletenessTests.cs +++ b/TruePath.Tests/PathIoCompletenessTests.cs @@ -83,7 +83,7 @@ private static HashSet LoadExclusions() if (directory == null) Assert.Skip("Repository root with TruePath.sln not found."); - return File.ReadAllLines(Path.Combine(directory!.FullName, "TruePath.SystemIo", "path-io-exclusions.txt")) + return File.ReadAllLines(Path.Combine(directory.FullName, "TruePath.SystemIo", "path-io-exclusions.txt")) .Select(line => line.Trim()) .Where(line => line.Length > 0 && !line.StartsWith("#", StringComparison.Ordinal)) .ToHashSet(); @@ -103,7 +103,6 @@ private static string DocIdType(Type type) { var definition = type.GetGenericTypeDefinition(); var name = definition.FullName!; - var arity = definition.GetGenericArguments().Length; name = name.Substring(0, name.IndexOf('`')); return name + "{" + string.Join(",", type.GetGenericArguments().Select(DocIdType)) + "}"; } From aebe79c3d5520720cbeee2acab11813fef3e8038 Mon Sep 17 00:00:00 2001 From: ghostflyby Date: Wed, 21 Oct 2026 17:32:43 +0800 Subject: [PATCH 3/6] fix: satisfy CI checks - Strip the UTF-8 BOM from TruePath.sln introduced by an editor. - Add a REUSE license companion for path-io-exclusions.txt. - Pin the generator test references to the Basic.Reference.Assemblies package: discovering the SDK's reference packs from the test host's runtime directory is not reliable on CI runners where the runtime and the SDK live in different roots. The test helper now also surfaces the driver diagnostics when the expected generated tree is missing. - The completeness test now mirrors the generator's third exclusion rule: BCL overloads that hand-written members forward to with path.Value as the first argument (previously flagged Directory.Delete(string,bool)). --- .../PathIoGeneratorTests.cs | 27 +++++-------- .../TruePath.PathIoGenerator.Tests.csproj | 1 + .../path-io-exclusions.txt.license | 3 ++ TruePath.Tests/PathIoCompletenessTests.cs | 39 ++++++++++++++++--- TruePath.sln | 2 +- 5 files changed, 49 insertions(+), 23 deletions(-) create mode 100644 TruePath.SystemIo/path-io-exclusions.txt.license diff --git a/TruePath.PathIoGenerator.Tests/PathIoGeneratorTests.cs b/TruePath.PathIoGenerator.Tests/PathIoGeneratorTests.cs index eea81cc..2c48fb0 100644 --- a/TruePath.PathIoGenerator.Tests/PathIoGeneratorTests.cs +++ b/TruePath.PathIoGenerator.Tests/PathIoGeneratorTests.cs @@ -61,23 +61,12 @@ public static partial class PathIo public static CSharpCompilation CreateCompilation(string sources) { - // Reference the .NET reference pack (the same contract assemblies a real build uses). Runtime-directory - // assemblies cannot be used instead: most public types are implemented in System.Private.CoreLib, whose - // members are not visible when it is referenced directly. - var coreLibPath = typeof(object).Assembly.Location; - var dotnetRoot = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(coreLibPath)!, "..", "..", "..")); - var refDirectory = Directory.GetDirectories(Path.Combine(dotnetRoot, "packs", "Microsoft.NETCore.App.Ref")) - .SelectMany(versionDirectory => Directory.GetDirectories(Path.Combine(versionDirectory, "ref"))) - .SelectMany(tfmDirectory => Directory.EnumerateFiles(tfmDirectory, "*.dll")) - .ToArray(); - var references = refDirectory - .Select(path => MetadataReference.CreateFromFile(path)) - .ToArray(); - + // Pinned .NET reference assemblies from a NuGet package: discovering them in the local SDK layout is not + // 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)) }, - references, + Basic.Reference.Assemblies.Net100.References.All, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); } @@ -87,9 +76,13 @@ public static string Generate(CSharpCompilation compilation, params AdditionalTe new[] { new PathIoSourceGenerator().AsSourceGenerator() }, additionalTexts.ToImmutableArray()); driver = driver.RunGenerators(compilation); - return driver.GetRunResult().GeneratedTrees - .Single(tree => tree.FilePath.Contains("PathIo.Generated")) - .GetText().ToString(); + var runResult = driver.GetRunResult(); + var tree = runResult.GeneratedTrees.FirstOrDefault(generated => generated.FilePath.Contains("PathIo.Generated")); + Assert.True( + tree != null, + "The generator produced no PathIo.Generated source. Driver diagnostics: " + + string.Join("; ", runResult.Diagnostics.Select(diagnostic => diagnostic.ToString()))); + return tree!.GetText().ToString(); } } diff --git a/TruePath.PathIoGenerator.Tests/TruePath.PathIoGenerator.Tests.csproj b/TruePath.PathIoGenerator.Tests/TruePath.PathIoGenerator.Tests.csproj index bc79863..c75d251 100644 --- a/TruePath.PathIoGenerator.Tests/TruePath.PathIoGenerator.Tests.csproj +++ b/TruePath.PathIoGenerator.Tests/TruePath.PathIoGenerator.Tests.csproj @@ -15,6 +15,7 @@ SPDX-License-Identifier: MIT + diff --git a/TruePath.SystemIo/path-io-exclusions.txt.license b/TruePath.SystemIo/path-io-exclusions.txt.license new file mode 100644 index 0000000..611889c --- /dev/null +++ b/TruePath.SystemIo/path-io-exclusions.txt.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2026 TruePath contributors + +SPDX-License-Identifier: MIT diff --git a/TruePath.Tests/PathIoCompletenessTests.cs b/TruePath.Tests/PathIoCompletenessTests.cs index 25851f1..9528f50 100644 --- a/TruePath.Tests/PathIoCompletenessTests.cs +++ b/TruePath.Tests/PathIoCompletenessTests.cs @@ -4,6 +4,7 @@ using System.Reflection; using System.Runtime.Versioning; +using System.Text.RegularExpressions; using TruePath.PathIoGenerator; using TruePath.SystemIo; @@ -31,6 +32,7 @@ public void EveryPathTakingBclApiIsExposedByPathIo() } var exclusions = LoadExclusions(); + var forwardedCalls = LoadForwardedCalls(); var pathIoMethods = typeof(PathIo) .GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly) .ToList(); @@ -50,6 +52,10 @@ public void EveryPathTakingBclApiIsExposedByPathIo() var docId = DocId(bclMethod); if (exclusions.Contains(docId)) continue; + // Hand-written members that forward to a BCL overload with `path.Value` as the first argument + // suppress its generation. The generator detects this semantically; here it is approximated on the + // hand-written source text (see PathIoGeneratorCore.CollectHandWrittenInfo). + if (forwardedCalls.Contains((bclMethod.DeclaringType!.Name, bclMethod.Name, parameters.Length))) continue; var expectedParameterTypes = parameters .Select((parameter, index) => pathOrdinals.Contains(index) @@ -76,17 +82,40 @@ private static string PathIoParameterType(ParameterInfo parameter) } private static HashSet LoadExclusions() + { + return File.ReadAllLines(Path.Combine(RepositoryRoot(), "TruePath.SystemIo", "path-io-exclusions.txt")) + .Select(line => line.Trim()) + .Where(line => line.Length > 0 && !line.StartsWith("#", StringComparison.Ordinal)) + .ToHashSet(); + } + + /// + /// (type, method, argument count) triples of the BCL calls the hand-written PathIo members forward to with + /// `path.Value` as the first argument. + /// + private static HashSet<(string TypeName, string MethodName, int ArgumentCount)> LoadForwardedCalls() + { + var source = File.ReadAllText(Path.Combine(RepositoryRoot(), "TruePath.SystemIo", "PathIo.cs")); + var calls = new HashSet<(string, string, int)>(); + foreach (Match match in Regex.Matches(source, @"\b(?File|Directory)\.(?\w+)\((?[^()]*)\)")) + { + var arguments = match.Groups["args"].Value.Split(','); + if (arguments.Length == 0 + || !arguments[0].Trim().StartsWith("path.Value", StringComparison.Ordinal)) continue; + calls.Add((match.Groups["type"].Value, match.Groups["method"].Value, arguments.Length)); + } + + return calls; + } + + private static string RepositoryRoot() { var directory = new DirectoryInfo(AppContext.BaseDirectory); while (directory != null && !File.Exists(Path.Combine(directory.FullName, "TruePath.sln"))) directory = directory.Parent; if (directory == null) Assert.Skip("Repository root with TruePath.sln not found."); - - return File.ReadAllLines(Path.Combine(directory.FullName, "TruePath.SystemIo", "path-io-exclusions.txt")) - .Select(line => line.Trim()) - .Where(line => line.Length > 0 && !line.StartsWith("#", StringComparison.Ordinal)) - .ToHashSet(); + return directory!.FullName; } private static string DocId(MethodInfo method) diff --git a/TruePath.sln b/TruePath.sln index 1963fd3..f24d186 100644 --- a/TruePath.sln +++ b/TruePath.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 From 1d20cd41225f351998c23fe3f9385f6ad50b8936 Mon Sep 17 00:00:00 2001 From: ghostflyby Date: Wed, 21 Oct 2026 17:35:37 +0800 Subject: [PATCH 4/6] fix: mark the generated header literals as REUSE-ignored The SPDX tags emitted into the generated file are string literals in the generator source; REUSE picked them up as this file's own tags and failed on the trailing C# syntax inside the expression. --- TruePath.PathIoGenerator/PathIoGeneratorCore.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/TruePath.PathIoGenerator/PathIoGeneratorCore.cs b/TruePath.PathIoGenerator/PathIoGeneratorCore.cs index db40652..03d28a8 100644 --- a/TruePath.PathIoGenerator/PathIoGeneratorCore.cs +++ b/TruePath.PathIoGenerator/PathIoGeneratorCore.cs @@ -323,10 +323,12 @@ private static string RenderFile(INamedTypeSymbol markedType, sb.AppendLine("// Generated by the TruePath.PathIoGenerator source generator. Manual changes will be lost."); sb.AppendLine("// "); sb.AppendLine(); + // REUSE-IgnoreStart: these are string literals for the generated file's header, not this file's tags. sb.AppendLine("// SPDX-FileCopyrightText: 2025 .NET Foundation and Contributors"); sb.AppendLine("// SPDX-FileCopyrightText: 2025-2026 TruePath contributors "); sb.AppendLine("//"); sb.AppendLine("// SPDX-License-Identifier: MIT"); + // REUSE-IgnoreEnd sb.AppendLine(); sb.AppendLine("#nullable enable"); sb.AppendLine(); From 73cfdab7c690040b3dec5d239a9ea144aa08ac27 Mon Sep 17 00:00:00 2001 From: ghostflyby Date: Wed, 21 Oct 2026 17:56:07 +0800 Subject: [PATCH 5/6] refactor(PathIoGenerator): review follow-ups - Reject nested marked classes with the new TPG004 diagnostic instead of silently generating a same-named top-level class, and derive the hint name from the full type name (sanitized) so same-named types cannot collide. - Hoist the exclusion file texts and the XML documentation index into their own cached pipeline nodes: the .xml filter now happens before BclDocIndex.Build, so editing path-io-exclusions.txt no longer re-scans the ~15 MB documentation files, and the exclusion file is matched by exact file name. - Move the hand-written member analysis into the syntax-anchored ForAttributeWithMetadataName transform (equatable MarkedType payload with sorted arrays), so it is cached across edits in unrelated files instead of re-running on every compilation change. - Clone the member element inside DocFormatting.ToDocLines rather than at the call site, and read the XML documentation files through SourceText.Lines instead of ToString/Replace/Split (three fewer full copies of the text). Generated output verified byte-identical on both target frameworks before and after the change. --- .../PathIoGeneratorTests.cs | 43 +++++++++++++- TruePath.PathIoGenerator/BclDocIndex.cs | 15 +++-- .../GeneratorDiagnostics.cs | 8 +++ TruePath.PathIoGenerator/MarkedType.cs | 30 ++++++++++ .../PathIoGeneratorCore.cs | 57 ++++++++++++++----- .../PathIoSourceGenerator.cs | 47 ++++++++++----- TruePath.Tests/PathIoCompletenessTests.cs | 2 +- 7 files changed, 166 insertions(+), 36 deletions(-) create mode 100644 TruePath.PathIoGenerator/MarkedType.cs diff --git a/TruePath.PathIoGenerator.Tests/PathIoGeneratorTests.cs b/TruePath.PathIoGenerator.Tests/PathIoGeneratorTests.cs index 2c48fb0..8655bcc 100644 --- a/TruePath.PathIoGenerator.Tests/PathIoGeneratorTests.cs +++ b/TruePath.PathIoGenerator.Tests/PathIoGeneratorTests.cs @@ -82,7 +82,7 @@ public static string Generate(CSharpCompilation compilation, params AdditionalTe tree != null, "The generator produced no PathIo.Generated source. Driver diagnostics: " + string.Join("; ", runResult.Diagnostics.Select(diagnostic => diagnostic.ToString()))); - return tree!.GetText().ToString(); + return tree.GetText().ToString(); } } @@ -131,11 +131,50 @@ public void CoreUsesBclDocIndexDirectly() ImmutableArray.Create(new InMemoryAdditionalText("/ref/System.Runtime.xml", BclDocs))); var result = PathIoGeneratorCore.Generate( - compilation, "TruePath.SystemIo.PathIo", ImmutableHashSet.Empty, index); + compilation, "TruePath.SystemIo.PathIo", markedTypeIsNested: false, HandWrittenInfo.Empty, + ImmutableHashSet.Empty, index); Assert.Contains("/// OPENS A TEXT FILE, READS ALL THE TEXT IN THE FILE.", result.Source); } + [Fact] + public void NestedMarkedClassIsRejected() + { + const string sources = """ + using System; + #nullable enable + namespace TruePath + { + public readonly struct AbsolutePath + { + public AbsolutePath(string value) { } + public string Value => ""; + } + } + + namespace TruePath.SystemIo + { + [AttributeUsage(AttributeTargets.Class)] + internal sealed class GenerateSystemIoExtensionsAttribute : Attribute { } + + public static class Outer + { + [GenerateSystemIoExtensions] + public static partial class PathIo { } + } + } + """; + var compilation = TestHelper.CreateCompilation(sources); + GeneratorDriver driver = CSharpGeneratorDriver.Create( + new[] { new PathIoSourceGenerator().AsSourceGenerator() }, + ImmutableArray.Empty); + driver = driver.RunGenerators(compilation, TestContext.Current.CancellationToken); + + var runResult = driver.GetRunResult(); + Assert.Contains(runResult.Diagnostics, diagnostic => diagnostic.Id == "TPG004"); + Assert.DoesNotContain(runResult.GeneratedTrees, tree => tree.FilePath.Contains("PathIo.Generated")); + } + [Fact] public void DocumentationFromBclXmlIsUsedWhenAvailable() { diff --git a/TruePath.PathIoGenerator/BclDocIndex.cs b/TruePath.PathIoGenerator/BclDocIndex.cs index c1382a7..b097955 100644 --- a/TruePath.PathIoGenerator/BclDocIndex.cs +++ b/TruePath.PathIoGenerator/BclDocIndex.cs @@ -35,23 +35,22 @@ public bool TryGetMethodMember(string docId, out XElement member) public static BclDocIndex Build(ImmutableArray files) { // netstandard.xml is processed first, so that the framework-specific System.Runtime.xml wins on conflicts. - var orderedFiles = files - .Where(file => file.Path.EndsWith(".xml", StringComparison.Ordinal)) - .OrderBy(file => Path.GetFileName(file.Path) == "netstandard.xml" ? 0 : 1); + var orderedFiles = files.OrderBy(file => Path.GetFileName(file.Path) == "netstandard.xml" ? 0 : 1); var builder = ImmutableDictionary.CreateBuilder(); foreach (var file in orderedFiles) { - var text = file.GetText()?.ToString(); - if (string.IsNullOrEmpty(text)) continue; + 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 line in text!.Replace("\r\n", "\n").Split('\n')) + foreach (var textLine in text.Lines) { + var line = textLine.ToString(); var trimmed = line.TrimStart(); if (currentName == null) { @@ -126,6 +125,10 @@ internal static class DocFormatting /// public static ImmutableArray ToDocLines(XElement member, ImmutableArray parameterNames) { + // Work on a copy: the element may be a cached BclDocIndex entry shared across runs, and this method + // renames its param elements in place. + member = new XElement(member); + var documentedParameters = new HashSet(StringComparer.Ordinal); var elements = new List(); foreach (var element in member.Elements()) diff --git a/TruePath.PathIoGenerator/GeneratorDiagnostics.cs b/TruePath.PathIoGenerator/GeneratorDiagnostics.cs index 0da2c2a..e402c35 100644 --- a/TruePath.PathIoGenerator/GeneratorDiagnostics.cs +++ b/TruePath.PathIoGenerator/GeneratorDiagnostics.cs @@ -26,6 +26,14 @@ internal static class GeneratorDiagnostics defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); + public static readonly DiagnosticDescriptor MarkedClassIsNested = new( + id: "TPG004", + title: "Marked class is nested", + messageFormat: "The class '{0}' is marked with GenerateSystemIoExtensionsAttribute, but it is nested inside another type, which is not supported", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + public static readonly DiagnosticDescriptor InvalidExclusionEntry = new( id: "TPG003", title: "Invalid exclusion list entry", diff --git a/TruePath.PathIoGenerator/MarkedType.cs b/TruePath.PathIoGenerator/MarkedType.cs new file mode 100644 index 0000000..150ebdc --- /dev/null +++ b/TruePath.PathIoGenerator/MarkedType.cs @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2026 TruePath contributors +// +// SPDX-License-Identifier: MIT + +namespace TruePath.PathIoGenerator; + +/// +/// The equatable payload the pipeline computes for every marked class: its display name, whether it is nested +/// (rejected by with a diagnostic), and the hand-written member +/// analysis. Being equatable lets the incremental pipeline cache it across unrelated edits. +/// +internal sealed class MarkedType(string typeName, bool isNested, HandWrittenInfo handWritten) +{ + public string TypeName { get; } = typeName; + public bool IsNested { get; } = isNested; + public HandWrittenInfo HandWritten { get; } = handWritten; + + public override bool Equals(object? obj) + { + return obj is MarkedType other + && TypeName == other.TypeName + && IsNested == other.IsNested + && HandWritten.Equals(other.HandWritten); + } + + public override int GetHashCode() + { + return TypeName.GetHashCode(); + } +} diff --git a/TruePath.PathIoGenerator/PathIoGeneratorCore.cs b/TruePath.PathIoGenerator/PathIoGeneratorCore.cs index 03d28a8..6831b75 100644 --- a/TruePath.PathIoGenerator/PathIoGeneratorCore.cs +++ b/TruePath.PathIoGenerator/PathIoGeneratorCore.cs @@ -45,11 +45,19 @@ public static class PathIoGeneratorCore public static GenerationResult Generate( Compilation compilation, string markedTypeMetadataName, + bool markedTypeIsNested, + HandWrittenInfo handWritten, ImmutableHashSet excludedDocIds, BclDocIndex docIndex) { var diagnostics = ImmutableArray.CreateBuilder(); + if (markedTypeIsNested) + { + diagnostics.Add(Diagnostic.Create(GeneratorDiagnostics.MarkedClassIsNested, Location.None, markedTypeMetadataName)); + return new GenerationResult(null, diagnostics.ToImmutable()); + } + var fileType = compilation.GetTypeByMetadataName("System.IO.File"); var directoryType = compilation.GetTypeByMetadataName("System.IO.Directory"); var absolutePathType = compilation.GetTypeByMetadataName(PathIoRules.AbsolutePathTypeName); @@ -77,7 +85,6 @@ public static GenerationResult Generate( } var renderer = TypeRenderer.Create(compilation); - var handWritten = CollectHandWrittenInfo(compilation, markedType!, fileType!, directoryType!, absolutePathType!, renderer); var generatedMethods = new List(); var generatedKeys = new HashSet(); var skippedWithoutPath = new SortedSet(); @@ -124,14 +131,19 @@ private static bool IsDeclaredPartial(INamedTypeSymbol type) .Any(syntax => syntax.Modifiers.Any(SyntaxKind.PartialKeyword)); } - private static HandWrittenInfo CollectHandWrittenInfo( - Compilation compilation, - INamedTypeSymbol markedType, - INamedTypeSymbol fileType, - INamedTypeSymbol directoryType, - INamedTypeSymbol absolutePathType, - TypeRenderer renderer) + /// + /// Collects the exclusion-relevant facts about the hand-written members of the marked type: their rewritten + /// signatures, and the IDs of the BCL overloads they forward to. Called from the syntax-anchored part of + /// the incremental pipeline, so it only re-runs when the marked type's syntax changes. + /// + public static HandWrittenInfo CollectHandWrittenInfo(Compilation compilation, INamedTypeSymbol markedType) { + var fileType = compilation.GetTypeByMetadataName("System.IO.File"); + var directoryType = compilation.GetTypeByMetadataName("System.IO.Directory"); + var absolutePathType = compilation.GetTypeByMetadataName(PathIoRules.AbsolutePathTypeName); + if (fileType == null || directoryType == null || absolutePathType == null) return HandWrittenInfo.Empty; + + var renderer = TypeRenderer.Create(compilation); var signatures = new HashSet(); var forwardedDocIds = new HashSet(); var comparer = SymbolEqualityComparer.Default; @@ -161,7 +173,9 @@ private static HandWrittenInfo CollectHandWrittenInfo( } } - return new HandWrittenInfo(signatures, forwardedDocIds); + return new HandWrittenInfo( + signatures.OrderBy(signature => signature, StringComparer.Ordinal).ToImmutableArray(), + forwardedDocIds.OrderBy(docId => docId, StringComparer.Ordinal).ToImmutableArray()); } /// @@ -265,8 +279,7 @@ private static ImmutableArray BuildDocLines( ImmutableArray parameterNames) { if (docIndex.TryGetMethodMember(DocId.ForMethod(method), out var cachedMember)) - // The cached element is cloned because ToDocLines renames its param elements in place. - return DocFormatting.ToDocLines(new XElement(cachedMember), parameterNames); + return DocFormatting.ToDocLines(cachedMember, parameterNames); var xml = method.GetDocumentationCommentXml(); if (!string.IsNullOrWhiteSpace(xml)) @@ -371,10 +384,26 @@ private static string RenderFile(INamedTypeSymbol markedType, } } -internal sealed class HandWrittenInfo(HashSet signatures, HashSet forwardedDocIds) +public sealed class HandWrittenInfo( + ImmutableArray signatures, + ImmutableArray forwardedDocIds) { - public HashSet Signatures { get; } = signatures; - public HashSet ForwardedDocIds { get; } = forwardedDocIds; + public static HandWrittenInfo Empty { get; } = new(ImmutableArray.Empty, ImmutableArray.Empty); + + public ImmutableArray Signatures { get; } = signatures; + public ImmutableArray ForwardedDocIds { get; } = forwardedDocIds; + + public override bool Equals(object? obj) + { + return obj is HandWrittenInfo other + && Signatures.SequenceEqual(other.Signatures) + && ForwardedDocIds.SequenceEqual(other.ForwardedDocIds); + } + + public override int GetHashCode() + { + return Signatures.Length; + } } internal sealed class GeneratedMethod( diff --git a/TruePath.PathIoGenerator/PathIoSourceGenerator.cs b/TruePath.PathIoGenerator/PathIoSourceGenerator.cs index af71f2a..f4aabd8 100644 --- a/TruePath.PathIoGenerator/PathIoSourceGenerator.cs +++ b/TruePath.PathIoGenerator/PathIoSourceGenerator.cs @@ -19,40 +19,61 @@ public sealed class PathIoSourceGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) { - var markedTypes = context.SyntaxProvider.ForAttributeWithMetadataName( + // The expensive per-type analysis runs in the syntax-anchored transform, so it is cached across edits in + // unrelated files; everything else that is independent of the compilation (exclusion texts, the XML + // documentation index) is also computed in nodes of its own instead of inside the output stage. + var markedTypes = context.SyntaxProvider.ForAttributeWithMetadataName( PathIoRules.MarkerAttributeName, static (node, _) => node is ClassDeclarationSyntax, - static (attributeContext, _) => attributeContext.TargetSymbol.ToDisplayString()); + static (attributeContext, _) => new MarkedType( + attributeContext.TargetSymbol.ToDisplayString(), + attributeContext.TargetSymbol.ContainingType != null, + PathIoGeneratorCore.CollectHandWrittenInfo( + attributeContext.SemanticModel.Compilation, + (INamedTypeSymbol)attributeContext.TargetSymbol))); - var additionalTexts = context.AdditionalTextsProvider.Collect(); - var docIndex = additionalTexts.Select(static (files, _) => BclDocIndex.Build(files)); + var exclusionTexts = context.AdditionalTextsProvider + .Where(static file => Path.GetFileName(file.Path) == PathIoRules.ExclusionFileName) + .Select(static (file, _) => file.GetText()?.ToString() ?? string.Empty) + .Collect(); + + var docIndex = context.AdditionalTextsProvider + .Where(static file => file.Path.EndsWith(".xml", StringComparison.Ordinal)) + .Collect() + .Select(static (files, _) => BclDocIndex.Build(files)); var input = markedTypes.Collect() + .Combine(exclusionTexts) .Combine(docIndex) - .Combine(additionalTexts) .Combine(context.CompilationProvider); context.RegisterSourceOutput(input, static (productionContext, tuple) => { - var (((typeNames, docIndex), additionalTexts), compilation) = tuple; + var (((typeInfos, exclusionTexts), docIndex), compilation) = tuple; - var exclusions = Exclusions.Parse(additionalTexts - .Where(file => file.Path.EndsWith(PathIoRules.ExclusionFileName, StringComparison.Ordinal)) - .Select(file => file.GetText()?.ToString() ?? string.Empty) - .ToImmutableArray()); + var exclusions = Exclusions.Parse(exclusionTexts); foreach (var diagnostic in exclusions.Diagnostics) productionContext.ReportDiagnostic(diagnostic); - foreach (var typeName in typeNames.Sort(StringComparer.Ordinal)) + foreach (var typeInfo in typeInfos.Sort((left, right) => string.CompareOrdinal(left.TypeName, right.TypeName))) { - var result = PathIoGeneratorCore.Generate(compilation, typeName, exclusions.DocIds, docIndex); + var result = PathIoGeneratorCore.Generate( + compilation, typeInfo.TypeName, typeInfo.IsNested, typeInfo.HandWritten, exclusions.DocIds, docIndex); foreach (var diagnostic in result.Diagnostics) productionContext.ReportDiagnostic(diagnostic); if (result.Source is { } source) { - var hintName = typeName.Substring(typeName.LastIndexOf('.') + 1) + ".Generated.g.cs"; + var hintName = SanitizeHintName(typeInfo.TypeName) + ".Generated.g.cs"; productionContext.AddSource(hintName, SourceText.From(source, Encoding.UTF8)); } } }); } + + private static string SanitizeHintName(string typeName) + { + var characters = new char[typeName.Length]; + for (var index = 0; index < typeName.Length; index++) + characters[index] = char.IsLetterOrDigit(typeName[index]) ? typeName[index] : '_'; + return new string(characters); + } } diff --git a/TruePath.Tests/PathIoCompletenessTests.cs b/TruePath.Tests/PathIoCompletenessTests.cs index 9528f50..210a20b 100644 --- a/TruePath.Tests/PathIoCompletenessTests.cs +++ b/TruePath.Tests/PathIoCompletenessTests.cs @@ -115,7 +115,7 @@ private static string RepositoryRoot() directory = directory.Parent; if (directory == null) Assert.Skip("Repository root with TruePath.sln not found."); - return directory!.FullName; + return directory.FullName; } private static string DocId(MethodInfo method) From d64286bec178eadd65e6b4c2a32f7c1585472b16 Mon Sep 17 00:00:00 2001 From: ghostflyby Date: Wed, 21 Oct 2026 18:15:29 +0800 Subject: [PATCH 6/6] fix(PathIoGenerator): complete special-type keywords, restore short hint names - Render every special type with its C# keyword inside generic arguments too: net10 span-based APIs like ReadOnlySpan previously rendered as ReadOnlySpan because System_Char was missing from the table. - Emit the generated file under the simple type name again (PathIo.Generated.g.cs): the full-name sanitization was only needed for nested marked types, which are now rejected with TPG004. Both verified by temporarily building TruePath.SystemIo against net10.0, which generates 112 methods including the new span- and Memory-based overloads without any rule changes. --- TruePath.PathIoGenerator/PathIoSourceGenerator.cs | 12 +++--------- TruePath.PathIoGenerator/TypeRenderer.cs | 13 +++++++++++-- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/TruePath.PathIoGenerator/PathIoSourceGenerator.cs b/TruePath.PathIoGenerator/PathIoSourceGenerator.cs index f4aabd8..421d0a2 100644 --- a/TruePath.PathIoGenerator/PathIoSourceGenerator.cs +++ b/TruePath.PathIoGenerator/PathIoSourceGenerator.cs @@ -62,18 +62,12 @@ public void Initialize(IncrementalGeneratorInitializationContext context) if (result.Source is { } source) { - var hintName = SanitizeHintName(typeInfo.TypeName) + ".Generated.g.cs"; + // Nested marked types are rejected with TPG004, so the simple type name is unambiguous here. + // Should two same-named top-level types ever be marked, AddSource fails loudly. + var hintName = typeInfo.TypeName.Substring(typeInfo.TypeName.LastIndexOf('.') + 1) + ".Generated.g.cs"; productionContext.AddSource(hintName, SourceText.From(source, Encoding.UTF8)); } } }); } - - private static string SanitizeHintName(string typeName) - { - var characters = new char[typeName.Length]; - for (var index = 0; index < typeName.Length; index++) - characters[index] = char.IsLetterOrDigit(typeName[index]) ? typeName[index] : '_'; - return new string(characters); - } } diff --git a/TruePath.PathIoGenerator/TypeRenderer.cs b/TruePath.PathIoGenerator/TypeRenderer.cs index 35027a9..66f9699 100644 --- a/TruePath.PathIoGenerator/TypeRenderer.cs +++ b/TruePath.PathIoGenerator/TypeRenderer.cs @@ -57,10 +57,19 @@ public string Render(ITypeSymbol type) name = type.SpecialType switch { SpecialType.System_String => "string", - SpecialType.System_Int32 => "int", - SpecialType.System_Boolean => "bool", + SpecialType.System_Char => "char", SpecialType.System_Byte => "byte", + SpecialType.System_SByte => "sbyte", + SpecialType.System_Int16 => "short", + SpecialType.System_UInt16 => "ushort", + SpecialType.System_Int32 => "int", + SpecialType.System_UInt32 => "uint", SpecialType.System_Int64 => "long", + SpecialType.System_UInt64 => "ulong", + SpecialType.System_Single => "float", + SpecialType.System_Double => "double", + SpecialType.System_Decimal => "decimal", + SpecialType.System_Boolean => "bool", SpecialType.System_Object => "object", SpecialType.System_Void => "void", _ => name,