diff --git a/TruePath.PathIoGenerator.Tests/PathIoGeneratorTests.cs b/TruePath.PathIoGenerator.Tests/PathIoGeneratorTests.cs new file mode 100644 index 0000000..8655bcc --- /dev/null +++ b/TruePath.PathIoGenerator.Tests/PathIoGeneratorTests.cs @@ -0,0 +1,315 @@ +// 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 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) + { + // 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)) }, + Basic.Reference.Assemblies.Net100.References.All, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + } + + public static string Generate(CSharpCompilation compilation, params AdditionalText[] additionalTexts) + { + GeneratorDriver driver = CSharpGeneratorDriver.Create( + new[] { new PathIoSourceGenerator().AsSourceGenerator() }, + additionalTexts.ToImmutableArray()); + driver = driver.RunGenerators(compilation); + 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(); + } +} + +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.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] + 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", 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() + { + 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, TestContext.Current.CancellationToken); + var updated = compilation.AddSyntaxTrees(driver.GetRunResult().GeneratedTrees); + + Assert.Empty(updated.GetDiagnostics(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); + } +} diff --git a/TruePath.PathIoGenerator.Tests/TruePath.PathIoGenerator.Tests.csproj b/TruePath.PathIoGenerator.Tests/TruePath.PathIoGenerator.Tests.csproj new file mode 100644 index 0000000..c75d251 --- /dev/null +++ b/TruePath.PathIoGenerator.Tests/TruePath.PathIoGenerator.Tests.csproj @@ -0,0 +1,27 @@ + + + + + + net10.0 + Exe + false + true + false + + + + + + + + + + + + + diff --git a/TruePath.PathIoGenerator/BclDocIndex.cs b/TruePath.PathIoGenerator/BclDocIndex.cs new file mode 100644 index 0000000..b097955 --- /dev/null +++ b/TruePath.PathIoGenerator/BclDocIndex.cs @@ -0,0 +1,195 @@ +// 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 _members; + + private BclDocIndex(ImmutableArray files, ImmutableDictionary members) + { + _files = files; + _members = members; + } + + public bool TryGetMethodMember(string docId, out XElement member) + { + return _members.TryGetValue(docId, out 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.OrderBy(file => Path.GetFileName(file.Path) == "netstandard.xml" ? 0 : 1); + + var builder = ImmutableDictionary.CreateBuilder(); + foreach (var file in orderedFiles) + { + var text = file.GetText(); + if (text == null) continue; + + // Some of the BCL documentation files (netstandard.xml in particular) contain invalid XML, so member + // elements are extracted line by line and parsed one by one: a broken member is then skipped instead + // of failing the whole file. + var current = new StringBuilder(); + string? currentName = null; + foreach (var textLine in text.Lines) + { + var line = textLine.ToString(); + var trimmed = line.TrimStart(); + if (currentName == null) + { + if (!trimmed.StartsWith("", StringComparison.Ordinal)) + { + try + { + builder[currentName] = XElement.Parse(current.ToString()); + } + 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) + { + // 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()) + { + 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..e402c35 --- /dev/null +++ b/TruePath.PathIoGenerator/GeneratorDiagnostics.cs @@ -0,0 +1,44 @@ +// 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 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", + 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/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 new file mode 100644 index 0000000..6831b75 --- /dev/null +++ b/TruePath.PathIoGenerator/PathIoGeneratorCore.cs @@ -0,0 +1,431 @@ +// 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, + 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); + 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 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(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)); + } + + /// + /// 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; + + 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.OrderBy(signature => signature, StringComparer.Ordinal).ToImmutableArray(), + forwardedDocIds.OrderBy(docId => docId, StringComparer.Ordinal).ToImmutableArray()); + } + + /// + /// 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.TryGetMethodMember(DocId.ForMethod(method), out var cachedMember)) + return DocFormatting.ToDocLines(cachedMember, parameterNames); + + 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(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(); + // 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(); + 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(); + } +} + +public sealed class HandWrittenInfo( + ImmutableArray signatures, + ImmutableArray 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( + 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..421d0a2 --- /dev/null +++ b/TruePath.PathIoGenerator/PathIoSourceGenerator.cs @@ -0,0 +1,73 @@ +// 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) + { + // 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, _) => new MarkedType( + attributeContext.TargetSymbol.ToDisplayString(), + attributeContext.TargetSymbol.ContainingType != null, + PathIoGeneratorCore.CollectHandWrittenInfo( + attributeContext.SemanticModel.Compilation, + (INamedTypeSymbol)attributeContext.TargetSymbol))); + + 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(context.CompilationProvider); + + context.RegisterSourceOutput(input, static (productionContext, tuple) => + { + var (((typeInfos, exclusionTexts), docIndex), compilation) = tuple; + + var exclusions = Exclusions.Parse(exclusionTexts); + foreach (var diagnostic in exclusions.Diagnostics) productionContext.ReportDiagnostic(diagnostic); + + foreach (var typeInfo in typeInfos.Sort((left, right) => string.CompareOrdinal(left.TypeName, right.TypeName))) + { + 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) + { + // 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)); + } + } + }); + } +} 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..66f9699 --- /dev/null +++ b/TruePath.PathIoGenerator/TypeRenderer.cs @@ -0,0 +1,102 @@ +// 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_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, + }; + + 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..58b2a07 100644 --- a/TruePath.SystemIo/TruePath.SystemIo.csproj +++ b/TruePath.SystemIo/TruePath.SystemIo.csproj @@ -7,7 +7,7 @@ SPDX-License-Identifier: MIT - netstandard2.0;net8.0 + netstandard2.0;net8.0;net10.0 true Adapters to use System.IO.File and System.IO.Directory APIs together with TruePath. TruePath.SystemIO @@ -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.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 new file mode 100644 index 0000000..210a20b --- /dev/null +++ b/TruePath.Tests/PathIoCompletenessTests.cs @@ -0,0 +1,141 @@ +// SPDX-FileCopyrightText: 2026 TruePath contributors +// +// SPDX-License-Identifier: MIT + +using System.Reflection; +using System.Runtime.Versioning; +using System.Text.RegularExpressions; +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 forwardedCalls = LoadForwardedCalls(); + 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; + // 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) + ? "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() + { + 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 directory.FullName; + } + + 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!; + 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..be4ccdf 100644 --- a/TruePath.Tests/TruePath.Tests.csproj +++ b/TruePath.Tests/TruePath.Tests.csproj @@ -7,7 +7,7 @@ SPDX-License-Identifier: MIT - net8.0 + net8.0;net10.0 Exe false true @@ -24,6 +24,8 @@ SPDX-License-Identifier: MIT + + diff --git a/TruePath.sln b/TruePath.sln index d0459f9..f24d186 100644 --- a/TruePath.sln +++ b/TruePath.sln @@ -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