From 57ca79d18fca4a00a241848b80fa95c3a3c9bbc4 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 15 Oct 2026 00:59:04 -0400 Subject: [PATCH 01/15] feat(core): add SymbolNameRegistry, FileNameRules and LinkPath Single source of truth for generated file names: register all symbols, resolve names once with tiered precedence and a last-resort -N suffix, then read approved names. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ApiMark.Core/FileNameRules.cs | 152 +++++++++ src/ApiMark.Core/LinkPath.cs | 51 +++ src/ApiMark.Core/SymbolNameRegistry.cs | 296 ++++++++++++++++++ test/ApiMark.Core.Tests/FileNameRulesTests.cs | 182 +++++++++++ test/ApiMark.Core.Tests/LinkPathTests.cs | 38 +++ .../SymbolNameRegistryTests.cs | 270 ++++++++++++++++ 6 files changed, 989 insertions(+) create mode 100644 src/ApiMark.Core/FileNameRules.cs create mode 100644 src/ApiMark.Core/LinkPath.cs create mode 100644 src/ApiMark.Core/SymbolNameRegistry.cs create mode 100644 test/ApiMark.Core.Tests/FileNameRulesTests.cs create mode 100644 test/ApiMark.Core.Tests/LinkPathTests.cs create mode 100644 test/ApiMark.Core.Tests/SymbolNameRegistryTests.cs diff --git a/src/ApiMark.Core/FileNameRules.cs b/src/ApiMark.Core/FileNameRules.cs new file mode 100644 index 0000000..ba5388f --- /dev/null +++ b/src/ApiMark.Core/FileNameRules.cs @@ -0,0 +1,152 @@ +using System.Text; + +namespace ApiMark.Core; + +/// +/// Character-level rules that turn a raw symbol name into a single file or folder name segment that is +/// legal on Windows, Linux, and macOS. This is the only place where a name is escaped. +/// +/// +/// +/// is the sole production caller of : every +/// name is escaped exactly once, when it is registered, and every writer and link resolver then +/// uses the registry's approved name unchanged. Escaping an already escaped name would change it +/// again, which is why no other code may call . +/// +/// +/// The rules are numbered in docs/design/api-mark-core/file-naming-rules.md (NR-06 to +/// NR-09). In short: { and } are structural notation that callers supply on purpose +/// (generic arity and type arguments); every other illegal or link-hostile character becomes +/// _; a trailing dot or space becomes _; a name that is empty becomes _; a +/// Windows reserved device name gets a _ suffix on the part before its first dot; names are +/// normalized to Unicode form C; and a name longer than characters is +/// truncated. +/// +/// +public static class FileNameRules +{ + /// The longest name, in UTF-16 code units, before the extension and any collision suffix. + public const int MaxNameLength = 200; + + /// The character that replaces every illegal or link-hostile character. + public const char ReplacementCharacter = '_'; + + /// Characters that are illegal in a Windows file name, plus link-hostile characters replaced for portability. + private static readonly HashSet ReplacedCharacters = + [ + '<', '>', ':', '"', '|', '?', '*', '\\', '/', '[', ']', '(', ')', '#', '%', '^', '`', ' ', + ]; + + /// Windows reserved device names that may not be used as a file name or its base name. + private static readonly HashSet ReservedNames = new(StringComparer.OrdinalIgnoreCase) + { + "CON", "PRN", "AUX", "NUL", + "COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", + "COM\u00B9", "COM\u00B2", "COM\u00B3", "LPT\u00B9", "LPT\u00B2", "LPT\u00B3", + }; + + /// + /// Escapes a raw name into a legal single path segment. + /// + /// + /// The raw name without an extension. Braces are accepted as structural notation (for example + /// Foo{2}) and are kept. + /// + /// + /// Set to when the result differs from or contains + /// structural braces, that is, when the name is not the plain "identity" name of the symbol. + /// + /// A legal, non-empty segment. + /// Thrown when is null. + public static string Escape(string rawName, out bool transformed) + { + ArgumentNullException.ThrowIfNull(rawName); + + var normalized = rawName.Normalize(NormalizationForm.FormC); + transformed = !string.Equals(normalized, rawName, StringComparison.Ordinal); + + var builder = new StringBuilder(normalized.Length); + foreach (var c in normalized) + { + if (c is '{' or '}') + { + builder.Append(c); + transformed = true; + } + else if (ReplacedCharacters.Contains(c) || c < 0x20 || c == 0x7F) + { + builder.Append(ReplacementCharacter); + transformed = true; + } + else + { + builder.Append(c); + } + } + + // Windows silently strips trailing dots and spaces, so they must not appear. + if (builder.Length > 0 && builder[^1] == '.') + { + builder[^1] = ReplacementCharacter; + transformed = true; + } + + if (builder.Length == 0) + { + builder.Append(ReplacementCharacter); + transformed = true; + } + + if (builder.Length > MaxNameLength) + { + var length = char.IsHighSurrogate(builder[MaxNameLength - 1]) ? MaxNameLength - 1 : MaxNameLength; + builder.Length = length; + transformed = true; + } + + var text = builder.ToString(); + + // A reserved device name is checked on the part before the first dot ("Con.Foo" is reserved). + var dot = text.IndexOf('.'); + var baseName = dot < 0 ? text : text[..dot]; + if (ReservedNames.Contains(baseName)) + { + text = dot < 0 ? text + ReplacementCharacter : text[..dot] + ReplacementCharacter + text[dot..]; + transformed = true; + } + + return text; + } + + /// + /// Determines whether a name is a legal single file or folder name segment on Windows and Linux. + /// + /// The segment to test (a file name without extension, or a folder name). + /// + /// when the segment is non-empty, contains none of + /// < > | ? * : " / \ or control characters, does not end in a dot or space, and is not a + /// reserved device name; otherwise . + /// + public static bool IsLegalSegment(string? name) + { + if (string.IsNullOrEmpty(name)) + { + return false; + } + + if (name.Any(c => c is '<' or '>' or '|' or '?' or '*' or ':' or '"' or '/' or '\\' || c < 0x20 || c == 0x7F)) + { + return false; + } + + if (name[^1] is '.' or ' ') + { + return false; + } + + var dot = name.IndexOf('.'); + var baseName = dot < 0 ? name : name[..dot]; + return !ReservedNames.Contains(baseName.TrimEnd(' ')); + } +} diff --git a/src/ApiMark.Core/LinkPath.cs b/src/ApiMark.Core/LinkPath.cs new file mode 100644 index 0000000..c9d98ab --- /dev/null +++ b/src/ApiMark.Core/LinkPath.cs @@ -0,0 +1,51 @@ +namespace ApiMark.Core; + +/// +/// Computes relative Markdown link destinations between pages with pure string arithmetic, so links are +/// identical on every operating system. +/// +public static class LinkPath +{ + /// + /// Computes the relative destination from a page in to a target. + /// + /// The directory (forward slashes, empty for the root) of the page containing the link. + /// The target path relative to the same root, including its extension. + /// A forward-slash relative path such as ../Other/Page.md or Page.md. + public static string Relative(string fromDirectory, string targetPath) + { + ArgumentNullException.ThrowIfNull(fromDirectory); + ArgumentNullException.ThrowIfNull(targetPath); + + var from = fromDirectory.Split('/', StringSplitOptions.RemoveEmptyEntries); + var to = targetPath.Split('/', StringSplitOptions.RemoveEmptyEntries); + + // Only directory segments of the target can be shared with the source directory. + var common = 0; + while (common < from.Length && common < to.Length - 1 && + string.Equals(from[common], to[common], StringComparison.Ordinal)) + { + common++; + } + + var parts = Enumerable.Repeat("..", from.Length - common).Concat(to.Skip(common)); + return string.Join('/', parts); + } + + /// + /// Percent-encodes the characters that would terminate or corrupt a Markdown inline link destination. + /// An existing % is left alone so an already encoded base URL is not encoded twice. + /// + /// The raw destination. + /// The destination with spaces, parentheses, and angle brackets encoded. + public static string Escape(string destination) + { + ArgumentNullException.ThrowIfNull(destination); + return destination + .Replace(" ", "%20", StringComparison.Ordinal) + .Replace("(", "%28", StringComparison.Ordinal) + .Replace(")", "%29", StringComparison.Ordinal) + .Replace("<", "%3C", StringComparison.Ordinal) + .Replace(">", "%3E", StringComparison.Ordinal); + } +} diff --git a/src/ApiMark.Core/SymbolNameRegistry.cs b/src/ApiMark.Core/SymbolNameRegistry.cs new file mode 100644 index 0000000..69ed6d3 --- /dev/null +++ b/src/ApiMark.Core/SymbolNameRegistry.cs @@ -0,0 +1,296 @@ +namespace ApiMark.Core; + +/// +/// The kind of a registered symbol; it decides which symbol keeps the plain name when two symbols in the +/// same directory claim the same name. +/// +public enum SymbolKind +{ + /// A structurally reserved page such as api or operators; always keeps its name. + Reserved = 0, + + /// A namespace (or package, library, or folder-like container). + Namespace = 1, + + /// A type or other primary symbol such as a class, enum, entity, or function. + Type = 2, + + /// A member of a type. + Member = 3, +} + +/// +/// The single source of truth for the file and folder names of every generated page. +/// +/// +/// +/// Use is a strict three-step process. First, every symbol that needs a +/// file; no name is assigned yet. Second, call once: every name is escaped +/// once and made unique inside its directory, comparing names case-insensitively. Third, writers +/// and link resolvers read the approved names with , , +/// and . Reading before , and registering after it, +/// is an error, so no page can be written or linked under a name the registry has not approved. +/// +/// +/// Uniqueness is decided per directory. Names are claimed in tiers: reserved structural names +/// first, then names that are exactly the symbol's own name (tier 0), then names that needed any +/// transformation (tier 1, for example Foo{2}). Inside a tier the claimant with the lowest +/// kind (namespace before type before member) and then the lowest ordinal key wins. A symbol that +/// loses its plain name is given a numeric suffix -N (N = 2, 3, ...), appended to its base +/// name, in the same order. The suffix is used only as the last resort. +/// +/// +/// A symbol's page {path}.md and its folder {path}/ share one name, and the folder of +/// a symbol is the directory of its children, so a displaced parent carries all its children along. +/// +/// +public sealed class SymbolNameRegistry +{ + /// The first numeric suffix used when a name is already taken. + private const int FirstCollisionNumber = 2; + + /// The registered symbols by key. + private readonly Dictionary _entries = new(StringComparer.Ordinal); + + /// Whether has completed. + private bool _resolved; + + /// Gets a value indicating whether has completed. + public bool IsResolved => _resolved; + + /// Gets the number of registered symbols. + public int Count => _entries.Count; + + /// + /// Registers a symbol that needs a file. No name is assigned until . + /// + /// The unique, ordinal key of the symbol (for example its fully qualified name). + /// The key of the symbol whose folder holds this symbol's page, or for the output root. + /// + /// The raw, unescaped name. Braces are structural notation and are kept; every other illegal + /// character is escaped by . + /// + /// The symbol kind, which decides precedence on a name collision. + /// Thrown after , for a duplicate key, or for an unregistered parent. + public void Register(string key, string? parentKey, string rawName, SymbolKind kind) + { + ArgumentNullException.ThrowIfNull(key); + ArgumentNullException.ThrowIfNull(rawName); + + if (_resolved) + { + throw new InvalidOperationException("Symbols cannot be registered after the registry has been resolved."); + } + + if (_entries.ContainsKey(key)) + { + throw new InvalidOperationException($"Symbol '{key}' is already registered."); + } + + if (parentKey != null && !_entries.ContainsKey(parentKey)) + { + throw new InvalidOperationException($"Parent '{parentKey}' of symbol '{key}' is not registered."); + } + + _entries[key] = new Entry(key, parentKey, rawName, kind); + } + + /// + /// Determines whether a symbol key is registered. + /// + /// The symbol key. + /// when the key is registered. + public bool Contains(string key) => _entries.ContainsKey(key); + + /// + /// Assigns every registered symbol its final, collision-free name. May be called once. + /// + /// Thrown when the registry has already been resolved. + public void Resolve() + { + if (_resolved) + { + throw new InvalidOperationException("The registry has already been resolved."); + } + + // Parents are always resolved before their children because a child's directory is its parent's path. + var byDepth = _entries.Values.GroupBy(e => DepthOf(e)).OrderBy(g => g.Key); + foreach (var level in byDepth) + { + foreach (var directory in level.GroupBy(e => e.ParentKey)) + { + var parentPath = directory.Key == null ? string.Empty : _entries[directory.Key].Path!; + ResolveDirectory(directory.ToList(), parentPath); + } + } + + _resolved = true; + } + + /// + /// Gets the approved name (one path segment, no extension) of a symbol. + /// + /// The symbol key. + /// The final file and folder name. + /// Thrown before or for an unregistered key. + public string GetName(string key) => Get(key).Name!; + + /// + /// Gets the approved page path (forward slashes, no extension) of a symbol, relative to the output root. + /// + /// The symbol key. + /// The path; the page file is {path}.md and the folder of its children is {path}/. + /// Thrown before or for an unregistered key. + public string GetPath(string key) => Get(key).Path!; + + /// + /// Gets the directory that holds the page of a symbol (the path of its parent, empty for the root). + /// + /// The symbol key. + /// The directory path, or an empty string for the output root. + /// Thrown before or for an unregistered key. + public string GetDirectory(string key) + { + var entry = Get(key); + return entry.ParentKey == null ? string.Empty : _entries[entry.ParentKey].Path!; + } + + /// + /// Gets the page paths of every registered symbol. + /// + /// The approved page paths (no extension), in ordinal key order. + /// Thrown before . + public IReadOnlyList GetAllPaths() + { + EnsureResolved(); + return _entries.Values.OrderBy(e => e.Key, StringComparer.Ordinal).Select(e => e.Path!).ToList(); + } + + /// Gets a resolved entry. + /// The symbol key. + /// The entry. + private Entry Get(string key) + { + EnsureResolved(); + if (!_entries.TryGetValue(key, out var entry)) + { + throw new InvalidOperationException($"Symbol '{key}' is not registered."); + } + + return entry; + } + + /// Throws unless has completed. + private void EnsureResolved() + { + if (!_resolved) + { + throw new InvalidOperationException("Names are not available until the registry has been resolved."); + } + } + + /// Computes the number of ancestors of an entry. + /// The entry. + /// The depth; zero for an entry in the output root. + private int DepthOf(Entry entry) + { + var depth = 0; + for (var parent = entry.ParentKey; parent != null; parent = _entries[parent].ParentKey) + { + depth++; + } + + return depth; + } + + /// Assigns the names of every entry in one directory. + /// The entries of the directory. + /// The path of the directory, empty for the root. + private static void ResolveDirectory(List entries, string parentPath) + { + foreach (var entry in entries) + { + entry.BaseName = FileNameRules.Escape(entry.RawName, out var transformed); + entry.Tier = TierOf(entry.Kind, transformed); + } + + var ordered = entries + .OrderBy(e => e.Tier) + .ThenBy(e => (int)e.Kind) + .ThenBy(e => e.Key, StringComparer.Ordinal) + .ToList(); + + var used = new HashSet(StringComparer.OrdinalIgnoreCase); + var displaced = new List(); + foreach (var entry in ordered) + { + if (used.Add(entry.BaseName!)) + { + entry.Name = entry.BaseName; + } + else + { + displaced.Add(entry); + } + } + + // Last resort: numeric suffix, in the same deterministic order. + foreach (var entry in displaced) + { + var number = FirstCollisionNumber; + while (!used.Add($"{entry.BaseName}-{number}")) + { + number++; + } + + entry.Name = $"{entry.BaseName}-{number}"; + } + + foreach (var entry in entries) + { + entry.Path = parentPath.Length > 0 ? $"{parentPath}/{entry.Name}" : entry.Name; + } + } + + /// Computes the claim tier of a symbol. + /// The symbol kind. + /// Whether the name needed any transformation. + /// -1 for a reserved page, 0 for an identity name, 1 for a transformed name. + private static int TierOf(SymbolKind kind, bool transformed) + { + if (kind == SymbolKind.Reserved) + { + return -1; + } + + return transformed ? 1 : 0; + } + + /// A registered symbol and its resolution state. + private sealed class Entry(string key, string? parentKey, string rawName, SymbolKind kind) + { + /// Gets the symbol key. + public string Key { get; } = key; + + /// Gets the parent symbol key, or for the output root. + public string? ParentKey { get; } = parentKey; + + /// Gets the raw name. + public string RawName { get; } = rawName; + + /// Gets the symbol kind. + public SymbolKind Kind { get; } = kind; + + /// Gets or sets the escaped name before collision handling. + public string? BaseName { get; set; } + + /// Gets or sets the claim tier (-1 reserved, 0 identity, 1 transformed). + public int Tier { get; set; } + + /// Gets or sets the final name. + public string? Name { get; set; } + + /// Gets or sets the final page path. + public string? Path { get; set; } + } +} diff --git a/test/ApiMark.Core.Tests/FileNameRulesTests.cs b/test/ApiMark.Core.Tests/FileNameRulesTests.cs new file mode 100644 index 0000000..c98ef47 --- /dev/null +++ b/test/ApiMark.Core.Tests/FileNameRulesTests.cs @@ -0,0 +1,182 @@ +using ApiMark.Core; +using Xunit; + +namespace ApiMark.Core.Tests; + +/// Verifies escapes names exactly as the numbered naming rules state. +public class FileNameRulesTests +{ + /// Validates that a plain identifier is unchanged and reported as an identity name. + /// The legal name. + [Theory] + [InlineData("MyClass")] + [InlineData("Foo-2")] + [InlineData("Ns.Sub.Type")] + [InlineData("caf\u00e9")] + [InlineData(".ctor")] + public void FileNameRules_Escape_LegalName_IsUnchangedAndNotTransformed(string name) + { + // Arrange / Act + var result = FileNameRules.Escape(name, out var transformed); + + // Assert + Assert.Equal(name, result); + Assert.False(transformed); + } + + /// Validates that structural braces are kept and mark the name as transformed. + [Fact] + public void FileNameRules_Escape_StructuralBraces_AreKeptAndTransformed() + { + // Arrange / Act + var result = FileNameRules.Escape("IPair{int-string}.Get", out var transformed); + + // Assert + Assert.Equal("IPair{int-string}.Get", result); + Assert.True(transformed); + } + + /// Validates that every illegal character becomes an underscore, without a hash. + /// The raw name containing an illegal character. + /// The expected escaped name. + [Theory] + [InlineData("a:b", "a_b")] + [InlineData("a\"b", "a_b")] + [InlineData("a|b", "a_b")] + [InlineData("a?b", "a_b")] + [InlineData("a*b", "a_b")] + [InlineData("a/b", "a_b")] + [InlineData("a\\b", "a_b")] + [InlineData("a\tb", "a_b")] + [InlineData("a\u007Fb", "a_b")] + [InlineData("int[]", "int__")] + [InlineData("a b", "a_b")] + [InlineData("a", "a_b_")] + [InlineData("a#b%c^d`e(f)", "a_b_c_d_e_f_")] + public void FileNameRules_Escape_IllegalCharacter_BecomesUnderscore(string name, string expected) + { + // Arrange / Act + var result = FileNameRules.Escape(name, out var transformed); + + // Assert + Assert.Equal(expected, result); + Assert.True(transformed); + Assert.True(FileNameRules.IsLegalSegment(result)); + } + + /// Validates that a trailing dot is replaced. + [Fact] + public void FileNameRules_Escape_TrailingDot_IsReplaced() + { + // Arrange / Act + var result = FileNameRules.Escape("Name.", out var transformed); + + // Assert + Assert.Equal("Name_", result); + Assert.True(transformed); + } + + /// Validates that Windows reserved device names get an underscore suffix before the first dot. + /// The reserved name. + /// The expected escaped name. + [Theory] + [InlineData("CON", "CON_")] + [InlineData("aux", "aux_")] + [InlineData("Com1", "Com1_")] + [InlineData("LPT9", "LPT9_")] + [InlineData("Con.Foo", "Con_.Foo")] + [InlineData("COM\u00B2", "COM\u00B2_")] + public void FileNameRules_Escape_ReservedDeviceName_GetsUnderscoreSuffix(string name, string expected) + { + // Arrange / Act + var result = FileNameRules.Escape(name, out var transformed); + + // Assert + Assert.Equal(expected, result); + Assert.True(transformed); + Assert.True(FileNameRules.IsLegalSegment(result)); + } + + /// Validates that an empty name becomes an underscore. + [Fact] + public void FileNameRules_Escape_EmptyName_ReturnsUnderscore() + { + // Arrange / Act + var result = FileNameRules.Escape(string.Empty, out var transformed); + + // Assert + Assert.Equal("_", result); + Assert.True(transformed); + } + + /// Validates that a name is normalized to Unicode form C. + [Fact] + public void FileNameRules_Escape_DecomposedName_IsNormalizedToFormC() + { + // Arrange + var decomposed = "cafe\u0301"; + + // Act + var result = FileNameRules.Escape(decomposed, out var transformed); + + // Assert + Assert.Equal("caf\u00e9", result); + Assert.True(transformed); + } + + /// Validates that an over-long name is truncated without splitting a surrogate pair. + [Fact] + public void FileNameRules_Escape_OverLongName_IsTruncated() + { + // Arrange + var name = new string('a', FileNameRules.MaxNameLength - 1) + "\U0001F600" + "tail"; + + // Act + var result = FileNameRules.Escape(name, out var transformed); + + // Assert + Assert.True(result.Length <= FileNameRules.MaxNameLength); + Assert.Equal(new string('a', FileNameRules.MaxNameLength - 1), result); + Assert.True(transformed); + } + + /// Validates that a null name is rejected. + [Fact] + public void FileNameRules_Escape_NullName_ThrowsArgumentNullException() + { + // Arrange / Act / Assert + Assert.Throws(() => FileNameRules.Escape(null!, out _)); + } + + /// Validates that illegal names are reported as illegal. + /// The illegal name. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("aValidates that legal names are reported as legal. + /// The legal name. + [Theory] + [InlineData("Foo")] + [InlineData("Foo{2}")] + [InlineData("IFoo{int}.Get")] + [InlineData("CON_")] + [InlineData("Console")] + public void FileNameRules_IsLegalSegment_LegalName_ReturnsTrue(string name) + { + // Arrange / Act / Assert + Assert.True(FileNameRules.IsLegalSegment(name)); + } +} diff --git a/test/ApiMark.Core.Tests/LinkPathTests.cs b/test/ApiMark.Core.Tests/LinkPathTests.cs new file mode 100644 index 0000000..bcfe57a --- /dev/null +++ b/test/ApiMark.Core.Tests/LinkPathTests.cs @@ -0,0 +1,38 @@ +using ApiMark.Core; +using Xunit; + +namespace ApiMark.Core.Tests; + +/// Verifies computes relative link destinations with pure string arithmetic. +public class LinkPathTests +{ + /// Validates relative destinations between pages. + /// The source directory. + /// The target path including extension. + /// The expected relative destination. + [Theory] + [InlineData("", "api.md", "api.md")] + [InlineData("A", "api.md", "../api.md")] + [InlineData("A/B", "api.md", "../../api.md")] + [InlineData("A", "A/B.md", "B.md")] + [InlineData("A/B", "A/B.md", "../B.md")] + [InlineData("A/B", "A/C/D.md", "../C/D.md")] + [InlineData("A", "A.md", "../A.md")] + [InlineData("A/B", "A/B/C.md", "C.md")] + public void LinkPath_Relative_Pages_ReturnsForwardSlashRelativePath(string from, string target, string expected) + { + // Arrange / Act / Assert + Assert.Equal(expected, LinkPath.Relative(from, target)); + } + + /// Validates that characters that would break a Markdown link are encoded. + [Fact] + public void LinkPath_Escape_LinkHostileCharacters_AreEncoded() + { + // Arrange / Act + var result = LinkPath.Escape("a b(c)%20"); + + // Assert + Assert.Equal("a%20b%28c%29%3Cd%3E%20", result); + } +} diff --git a/test/ApiMark.Core.Tests/SymbolNameRegistryTests.cs b/test/ApiMark.Core.Tests/SymbolNameRegistryTests.cs new file mode 100644 index 0000000..438ebab --- /dev/null +++ b/test/ApiMark.Core.Tests/SymbolNameRegistryTests.cs @@ -0,0 +1,270 @@ +using ApiMark.Core; +using Xunit; + +namespace ApiMark.Core.Tests; + +/// Verifies assigns unique, predictable names before any file is written. +public class SymbolNameRegistryTests +{ + /// Builds and resolves a registry from a list of (key, parent, raw, kind) tuples. + /// The symbols to register, in the given order. + /// The resolved registry. + private static SymbolNameRegistry Resolved(params (string Key, string? Parent, string Raw, SymbolKind Kind)[] symbols) + { + var registry = new SymbolNameRegistry(); + foreach (var (key, parent, raw, kind) in symbols) + { + registry.Register(key, parent, raw, kind); + } + + registry.Resolve(); + return registry; + } + + /// Validates that a unique name is kept unchanged and its path includes the parent folder. + [Fact] + public void SymbolNameRegistry_Resolve_UniqueNames_AreUnchanged() + { + // Arrange / Act + var registry = Resolved( + ("ns", null, "A", SymbolKind.Namespace), + ("t", "ns", "Widget{2}", SymbolKind.Type), + ("m", "t", "Run", SymbolKind.Member)); + + // Assert + Assert.Equal("A", registry.GetPath("ns")); + Assert.Equal("A/Widget{2}", registry.GetPath("t")); + Assert.Equal("A/Widget{2}/Run", registry.GetPath("m")); + Assert.Equal("A/Widget{2}", registry.GetDirectory("m")); + Assert.Equal(string.Empty, registry.GetDirectory("ns")); + Assert.Equal("Widget{2}", registry.GetName("t")); + } + + /// Validates that of two case-twin names the lowest ordinal key keeps the plain name and the other gets -2. + [Fact] + public void SymbolNameRegistry_Resolve_CaseTwins_LowestKeyWinsAndOtherGetsSuffix() + { + // Arrange / Act + var registry = Resolved( + ("B.item", null, "item", SymbolKind.Type), + ("A.Item", null, "Item", SymbolKind.Type)); + + // Assert + Assert.Equal("Item", registry.GetName("A.Item")); + Assert.Equal("item-2", registry.GetName("B.item")); + } + + /// Validates that a tier 0 identity name beats a tier 1 transformed name that collides with it. + [Fact] + public void SymbolNameRegistry_Resolve_IdentityName_BeatsTransformedName() + { + // Arrange / Act (the transformed name sorts first by key but loses the tier) + var registry = Resolved( + ("a", null, "a:b", SymbolKind.Type), + ("z", null, "a_b", SymbolKind.Type)); + + // Assert + Assert.Equal("a_b", registry.GetName("z")); + Assert.Equal("a_b-2", registry.GetName("a")); + } + + /// Validates that a namespace keeps the plain name when a type claims the same name. + [Fact] + public void SymbolNameRegistry_Resolve_NamespaceAndType_NamespaceWins() + { + // Arrange / Act + var registry = Resolved( + ("root", null, "A", SymbolKind.Namespace), + ("A.B", "root", "B", SymbolKind.Namespace), + ("A.B(type)", "root", "B", SymbolKind.Type), + ("A.B.C", "A.B", "C", SymbolKind.Type)); + + // Assert + Assert.Equal("A/B", registry.GetPath("A.B")); + Assert.Equal("A/B-2", registry.GetPath("A.B(type)")); + Assert.Equal("A/B/C", registry.GetPath("A.B.C")); + } + + /// Validates that a displaced parent carries its children along to its new folder. + [Fact] + public void SymbolNameRegistry_Resolve_DisplacedParent_ChildrenFollow() + { + // Arrange / Act + var registry = Resolved( + ("n1", null, "N", SymbolKind.Namespace), + ("n1.b", "n1", "b", SymbolKind.Type), + ("n1.B", "n1", "B", SymbolKind.Type), + ("n1.b.m", "n1.b", "Run", SymbolKind.Member), + ("n1.B.m", "n1.B", "Run", SymbolKind.Member)); + + // Assert + Assert.Equal("N/B/Run", registry.GetPath("n1.B.m")); + Assert.Equal("N/b-2/Run", registry.GetPath("n1.b.m")); + } + + /// Validates that a reserved structural page always keeps its name. + [Fact] + public void SymbolNameRegistry_Resolve_ReservedName_KeepsNameAndDisplacesSymbol() + { + // Arrange / Act + var registry = Resolved( + ("reserved", null, "api", SymbolKind.Reserved), + ("ns", null, "API", SymbolKind.Namespace), + ("t", null, "Aa", SymbolKind.Type)); + + // Assert + Assert.Equal("api", registry.GetName("reserved")); + Assert.Equal("API-2", registry.GetName("ns")); + Assert.Equal("Aa", registry.GetName("t")); + } + + /// Validates that the numeric suffix skips names that are already taken. + [Fact] + public void SymbolNameRegistry_Resolve_SuffixCandidateTaken_UsesNextNumber() + { + // Arrange / Act + var registry = Resolved( + ("1", null, "Foo", SymbolKind.Type), + ("2", null, "foo", SymbolKind.Type), + ("3", null, "FOO", SymbolKind.Type), + ("4", null, "Foo-2", SymbolKind.Type)); + + // Assert + Assert.Equal("Foo", registry.GetName("1")); + Assert.Equal("Foo-2", registry.GetName("4")); + Assert.Equal("FOO-4", registry.GetName("3")); + Assert.Equal("foo-3", registry.GetName("2")); + } + + /// Validates that the result does not depend on the registration order. + [Fact] + public void SymbolNameRegistry_Resolve_RegistrationOrder_DoesNotMatter() + { + // Arrange + var symbols = new (string Key, string? Parent, string Raw, SymbolKind Kind)[] + { + ("ns", null, "N", SymbolKind.Namespace), + ("a", "ns", "Foo", SymbolKind.Type), + ("b", "ns", "foo", SymbolKind.Type), + ("c", "ns", "FOO", SymbolKind.Member), + ("d", "ns", "Foo{1}", SymbolKind.Type), + ("e", "ns", "foo{1}", SymbolKind.Type), + }; + + // Act + var forward = Resolved(symbols); + var reversed = Resolved(symbols.Reverse().OrderBy(s => s.Parent == null ? 0 : 1).ToArray()); + + // Assert + foreach (var (key, _, _, _) in symbols) + { + Assert.Equal(forward.GetPath(key), reversed.GetPath(key)); + } + } + + /// Validates that resolving twice gives the same names (idempotent inputs) and the second call throws. + [Fact] + public void SymbolNameRegistry_Resolve_CalledTwice_Throws() + { + // Arrange + var registry = Resolved(("a", null, "A", SymbolKind.Type)); + + // Act / Assert + Assert.Throws(registry.Resolve); + } + + /// Validates that no name can be read before the registry is resolved. + [Fact] + public void SymbolNameRegistry_GetName_BeforeResolve_Throws() + { + // Arrange + var registry = new SymbolNameRegistry(); + registry.Register("a", null, "A", SymbolKind.Type); + + // Act / Assert + Assert.Throws(() => registry.GetName("a")); + Assert.Throws(() => registry.GetPath("a")); + Assert.Throws(() => registry.GetDirectory("a")); + Assert.Throws(registry.GetAllPaths); + } + + /// Validates that no symbol can be registered after the registry is resolved. + [Fact] + public void SymbolNameRegistry_Register_AfterResolve_Throws() + { + // Arrange + var registry = Resolved(("a", null, "A", SymbolKind.Type)); + + // Act / Assert + Assert.Throws(() => registry.Register("b", null, "B", SymbolKind.Type)); + } + + /// Validates the registration error cases. + [Fact] + public void SymbolNameRegistry_Register_InvalidInput_Throws() + { + // Arrange + var registry = new SymbolNameRegistry(); + registry.Register("a", null, "A", SymbolKind.Type); + + // Act / Assert + Assert.Throws(() => registry.Register("a", null, "A", SymbolKind.Type)); + Assert.Throws(() => registry.Register("b", "missing", "B", SymbolKind.Type)); + Assert.Throws(() => registry.Register(null!, null, "B", SymbolKind.Type)); + Assert.Throws(() => registry.Register("c", null, null!, SymbolKind.Type)); + } + + /// Validates that an unknown key is rejected after resolution. + [Fact] + public void SymbolNameRegistry_GetName_UnknownKey_Throws() + { + // Arrange + var registry = Resolved(("a", null, "A", SymbolKind.Type)); + + // Act / Assert + Assert.False(registry.Contains("zzz")); + Assert.True(registry.Contains("a")); + Assert.Throws(() => registry.GetName("zzz")); + } + + /// Validates that unicode-equivalent names collide after normalization. + [Fact] + public void SymbolNameRegistry_Resolve_CanonicallyEquivalentNames_AreDistinguished() + { + // Arrange / Act + var registry = Resolved( + ("a", null, "caf\u00e9", SymbolKind.Type), + ("b", null, "cafe\u0301", SymbolKind.Type)); + + // Assert + Assert.Equal("caf\u00e9", registry.GetName("a")); + Assert.Equal("caf\u00e9-2", registry.GetName("b")); + } + + /// Validates that every resolved path of a large generated symbol set is legal and unique ignoring case. + [Fact] + public void SymbolNameRegistry_Resolve_GeneratedSymbolSet_PathsAreLegalAndCaseInsensitivelyUnique() + { + // Arrange + var random = new Random(12345); + var alphabet = new[] { "a", "A", "b", "B", "con", "CON", ":", "{1}", ".", "x y", "-2", "api", "Item", "item" }; + var registry = new SymbolNameRegistry(); + var parents = new List { null }; + for (var i = 0; i < 2000; i++) + { + var raw = string.Concat(Enumerable.Range(0, random.Next(1, 4)).Select(_ => alphabet[random.Next(alphabet.Length)])); + var parent = parents[random.Next(parents.Count)]; + var key = $"k{i}"; + registry.Register(key, parent, raw, (SymbolKind)random.Next(1, 4)); + parents.Add(key); + } + + // Act + registry.Resolve(); + var paths = registry.GetAllPaths(); + + // Assert + Assert.Equal(paths.Count, paths.Distinct(StringComparer.OrdinalIgnoreCase).Count()); + Assert.All(paths, p => Assert.All(p.Split('/'), s => Assert.True(FileNameRules.IsLegalSegment(s), s))); + } +} From 0ad836a8bb1cd56db61ccd9e66ac0d357fcf3330 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 15 Oct 2026 01:09:28 -0400 Subject: [PATCH 02/15] feat(dotnet): derive all generated file names from the symbol-name registry Namespaces, types, nested types, members and operators pages are registered up front for the whole module (independent of visibility) and resolved once. Generic types use brace arity (Foo{1}); explicit interface members keep type arguments; case twins get separate pages; links read the approved names. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ApiMark.DotNet/DotNetEmitter.cs | 149 ++------ .../DotNetEmitterGradualDisclosure.cs | 8 +- src/ApiMark.DotNet/DotNetGenerator.cs | 4 + src/ApiMark.DotNet/DotNetNames.cs | 335 ++++++++++++++++++ src/ApiMark.DotNet/TypeLinkResolver.cs | 5 +- src/ApiMark.DotNet/TypeNameSimplifier.cs | 18 +- .../DotNetEmitterBreadcrumbTests.cs | 1 + .../DotNetEmitterExternalNestedLinkTests.cs | 4 +- .../DotNetEmitterExternalSignatureTests.cs | 2 +- .../DotNetEmitterGenericNamingTests.cs | 30 +- ...etEmitterGradualDisclosureGenericsTests.cs | 22 +- .../DotNetEmitterGradualDisclosureTests.cs | 8 +- .../DotNetEmitterInheritedMembersTests.cs | 2 +- .../DotNetEmitterTests.cs | 6 +- .../DotNetFileNamingTests.cs | 314 ++++++++++++++++ .../DotNetGeneratorTests.cs | 55 ++- .../TypeLinkResolverTests.cs | 2 +- 17 files changed, 758 insertions(+), 207 deletions(-) create mode 100644 src/ApiMark.DotNet/DotNetNames.cs create mode 100644 test/ApiMark.DotNet.Tests/DotNetFileNamingTests.cs diff --git a/src/ApiMark.DotNet/DotNetEmitter.cs b/src/ApiMark.DotNet/DotNetEmitter.cs index 3782693..d7dcda6 100644 --- a/src/ApiMark.DotNet/DotNetEmitter.cs +++ b/src/ApiMark.DotNet/DotNetEmitter.cs @@ -116,7 +116,15 @@ internal static string GetNamespaceDisplayName(string namespaceName) => /// internal static string GetNamespaceFolderPath(string namespaceName, IReadOnlyList rootNamespaces) { - // Types declared without a namespace live on the synthetic global-namespace page. + // The approved name map of the documented assembly is the single source of truth. + var approved = DotNetNames.Find(rootNamespaces)?.TryGetNamespacePath(namespaceName); + if (approved != null) + { + return approved; + } + + // Predictable fallback for namespaces without an approved name (for example namespaces of an + // external assembly): the same rules, but without collision resolution. if (namespaceName.Length == 0) { return GlobalNamespaceFolder; @@ -124,35 +132,21 @@ internal static string GetNamespaceFolderPath(string namespaceName, IReadOnlyLis var root = rootNamespaces.FirstOrDefault(r => string.Equals(r, namespaceName, StringComparison.Ordinal) || - namespaceName.StartsWith(r + ".", StringComparison.Ordinal)); + namespaceName.StartsWith(r + ".", StringComparison.Ordinal)) ?? namespaceName; - if (root == null) + var rootSegment = FileNameRules.Escape(root, out _); + if (string.Equals(rootSegment, "api", StringComparison.OrdinalIgnoreCase)) { - return SanitizeRootSegment(namespaceName); // fallback: no known root, use full name + rootSegment += "-2"; // same displacement the name registry applies to the reserved api page } - if (string.Equals(namespaceName, root, StringComparison.Ordinal)) + var segments = new List { rootSegment }; + if (namespaceName.Length > root.Length) { - return SanitizeRootSegment(root); // root namespace: full dotted name is the single path segment + segments.AddRange(namespaceName[(root.Length + 1)..].Split('.').Select(s => FileNameRules.Escape(s, out _))); } - // Child namespace: keep root intact, replace subsequent dots with slashes; every segment is - // sanitized so reserved device names and illegal characters never reach the file system. - var suffix = namespaceName.Substring(root.Length); // e.g. ".Inner" or ".Inner.Sub" - return SanitizeRootSegment(root) + "/" + - string.Join('/', suffix.Substring(1).Split('.').Select(FileNameSanitizer.SanitizeSegment)); - } - - /// - /// Sanitizes a root-namespace path segment; a name equal to the reserved api index page is - /// moved aside so a root namespace page can never overwrite api.md. - /// - /// The raw root namespace name. - /// The legal segment. - private static string SanitizeRootSegment(string name) - { - var sanitized = FileNameSanitizer.SanitizeSegment(name); - return string.Equals(sanitized, "api", StringComparison.OrdinalIgnoreCase) ? sanitized + "-" : sanitized; + return string.Join('/', segments); } /// @@ -732,71 +726,40 @@ internal static string GetMemberDisplayName(IMemberDefinition member) /// A string suitable for use as a file name (without extension). internal static string GetSanitizedMemberFileName(IMemberDefinition member, TypeDefinition declaringType) { - // Methods are already sanitized by BuildMethodFileName; sanitizing twice would hash the result. - if (member is MethodDefinition method) - { - return BuildMethodFileName(method, declaringType); - } - - var raw = member switch - { - PropertyDefinition p => p.Name, - FieldDefinition f => f.Name, - EventDefinition e => e.Name, - _ => member.Name, - }; - - // Explicit interface implementations are named like Ns.IFoo.Method, so the raw - // name is always run through the shared sanitizer. - return ToMemberFileName(raw); - } - - /// - /// Converts a raw member name to a legal member page name; a name equal to the reserved operators - /// page is moved aside so a member can never overwrite the type's operators page. - /// - /// The raw member name. - /// The legal file name without extension. - private static string ToMemberFileName(string raw) - { - var sanitized = FileNameSanitizer.SanitizeSegment(raw); - return string.Equals(sanitized, OperatorsPageName, StringComparison.OrdinalIgnoreCase) ? sanitized + "-" : sanitized; + // The approved name comes from the module's name map and is escaped exactly once there. + // An unregistered member falls back to the predictable escaped name. + return DotNetNames.For(declaringType.Module).TryGetMemberName(member, declaringType) + ?? FileNameRules.Escape(DotNetNames.RawMemberName(member, declaringType), out _); } /// The file name (without extension) of a type's shared operators page. internal const string OperatorsPageName = "operators"; - /// Caches the computed page file name of each type definition. - private static readonly System.Runtime.CompilerServices.ConditionalWeakTable TypeFileNameCache = new(); - /// /// Returns the file name (without extension) of the page, and of the folder, of a type. /// /// - /// The name is the sanitized, arity-flattened type name. For a type of the documented assembly the - /// name is made unique among its siblings when compared case-insensitively: member page names and - /// the reserved operators page of the declaring type take priority, and of several sibling types - /// whose names differ only in case the first in ordinal full-name order keeps its name while the - /// others receive a - and an eight-character hash of their full name. The result does not - /// depend on the visibility filter, so it is identical wherever the type is named or linked. + /// The name is read from the module's approved name map (see ), so it + /// is collision-free and independent of the visibility filter. A type of another assembly gets + /// its predictable name: the escaped name with the generic arity in braces (Foo{2}). /// /// The type (or type reference) to name. /// A legal, deterministic file name without extension. internal static string GetTypeFileName(TypeReference type) { - var baseName = FileNameSanitizer.SanitizeSegment(FlattenArity(type.Name)); if (type is not TypeDefinition definition) { if (type.Scope is not ModuleDefinition || type.GetElementType() is not TypeReference element || !TryResolveLocal(element, out var local)) { - return baseName; + return DotNetNames.PredictableTypeName(type.Name); } definition = local!; } - return TypeFileNameCache.GetValue(definition, ComputeTypeFileName); + return DotNetNames.For(definition.Module).TryGetTypeName(definition) + ?? DotNetNames.PredictableTypeName(definition.Name); } /// Resolves an intra-assembly type reference to its definition without throwing. @@ -817,52 +780,6 @@ private static bool TryResolveLocal(TypeReference reference, out TypeDefinition? return definition != null; } - /// Computes the collision-free file name of a type definition among its siblings. - /// The type definition. - /// The file name without extension. - private static string ComputeTypeFileName(TypeDefinition type) - { - static string KeyOf(TypeDefinition t) => FileNameSanitizer.SanitizeSegment(FlattenArity(t.Name)); - - var baseName = KeyOf(type); - - // Names owned by the declaring type's member pages and operators page always win. - if (type.DeclaringType != null) - { - var reserved = new HashSet(StringComparer.OrdinalIgnoreCase) { OperatorsPageName }; - foreach (var member in GetPageMembers(type.DeclaringType)) - { - reserved.Add(GetSanitizedMemberFileName(member, type.DeclaringType)); - } - - if (reserved.Contains(baseName)) - { - return $"{baseName}-{FileNameSanitizer.Hash(type.FullName)}"; - } - } - - // Among case-insensitive twins the first in ordinal full-name order keeps its plain name. - var siblings = type.DeclaringType != null - ? type.DeclaringType.NestedTypes - : type.Module.Types.Where(t => string.Equals(t.Namespace, type.Namespace, StringComparison.Ordinal)); - var first = siblings - .Where(t => string.Equals(KeyOf(t), baseName, StringComparison.OrdinalIgnoreCase)) - .OrderBy(t => t.FullName, StringComparer.Ordinal) - .First(); - return ReferenceEquals(first, type) || string.Equals(first.FullName, type.FullName, StringComparison.Ordinal) - ? baseName - : $"{baseName}-{FileNameSanitizer.Hash(type.FullName)}"; - } - - /// Enumerates every member of a type that can own a member page. - /// The declaring type. - /// The fields, properties, events, and non-accessor methods of the type. - private static IEnumerable GetPageMembers(TypeDefinition type) => - type.Fields.Cast() - .Concat(type.Properties) - .Concat(type.Events) - .Concat(type.Methods.Where(m => !m.IsGetter && !m.IsSetter && !m.IsAddOn && !m.IsRemoveOn)); - // ========================================================================= // XML doc identifiers // ========================================================================= @@ -2389,9 +2306,7 @@ internal static string GetMemberRowName(IMemberDefinition member) => member is M /// A filesystem-safe name string suitable for use as a Markdown file name. internal static string BuildMethodFileName(MethodDefinition method, TypeDefinition declaringType) { - return ToMemberFileName(method.Name == ConstructorMethodName - ? StripArity(declaringType.Name) - : method.Name); + return GetSanitizedMemberFileName(method, declaringType); } /// @@ -2837,10 +2752,10 @@ internal static string StripArity(string name) } /// - /// Converts the IL backtick arity suffix to a hyphenated numeric suffix for file-system-safe names. - /// For example, Foo`2 becomes Foo-2; Foo is unchanged. + /// Converts the IL backtick arity suffix to the brace notation used in generated file names. + /// For example, Foo`2 becomes Foo{2}; Foo is unchanged. /// /// The raw IL type name that may contain a backtick arity suffix. - /// The name with the backtick replaced by a hyphen and the arity count preserved. - internal static string FlattenArity(string name) => TypeNameSimplifier.FlattenArity(name); + /// The name with the arity in braces. + internal static string FlattenArity(string name) => DotNetNames.ArityBraces(name); } diff --git a/src/ApiMark.DotNet/DotNetEmitterGradualDisclosure.cs b/src/ApiMark.DotNet/DotNetEmitterGradualDisclosure.cs index 2155581..5fe6da0 100644 --- a/src/ApiMark.DotNet/DotNetEmitterGradualDisclosure.cs +++ b/src/ApiMark.DotNet/DotNetEmitterGradualDisclosure.cs @@ -427,7 +427,7 @@ private static void WriteNamespaceBreadcrumb( IReadOnlyList rootNamespaces) { string Relative(string key) => - Path.GetRelativePath(currentFolder.Length > 0 ? currentFolder : ".", key + ".md").Replace('\\', '/'); + LinkPath.Relative(currentFolder, key + ".md"); var crumbs = new List { $"[API Reference]({Relative("api")})" }; crumbs.AddRange(allNamespaces @@ -469,7 +469,7 @@ private static void WriteBreadcrumb(IMarkdownWriter writer, string typeFolderPat var nsFolder = string.Join('/', segments.Take(Math.Max(0, segments.Count - (chain.Count - 1)))); string Relative(string key) => - Path.GetRelativePath(currentFolder.Length > 0 ? currentFolder : ".", key + ".md").Replace('\\', '/'); + LinkPath.Relative(currentFolder, key + ".md"); var crumbs = new List { $"[API Reference]({Relative("api")})" }; if (nsFolder.Length > 0) @@ -656,7 +656,7 @@ private static void ProcessTypeMembers(TypePageWriteContext ctx, List> GroupMembersByFileNam var groups = new Dictionary>(StringComparer.Ordinal); foreach (var member in members) { - var lowerKey = GetSanitizedMemberFileName(member, type).ToLowerInvariant(); + var lowerKey = GetSanitizedMemberFileName(member, type); if (!groups.TryGetValue(lowerKey, out var list)) { list = []; diff --git a/src/ApiMark.DotNet/DotNetGenerator.cs b/src/ApiMark.DotNet/DotNetGenerator.cs index 397c27e..9e0ec8c 100644 --- a/src/ApiMark.DotNet/DotNetGenerator.cs +++ b/src/ApiMark.DotNet/DotNetGenerator.cs @@ -231,6 +231,10 @@ public IApiEmitter Parse(IContext context) // Root namespaces: those not prefixed by any other namespace present in the assembly var rootNamespaces = ComputeRootNamespaces(allNamespaces); + // Register every symbol and resolve all file names up front (independent of visibility). + // The emitters and link resolvers only read the approved names. + DotNetNames.Attach(rootNamespaces, DotNetNames.For(assembly.MainModule)); + var resolver = new TypeLinkResolver( rootNamespaces, generateLinks: true, diff --git a/src/ApiMark.DotNet/DotNetNames.cs b/src/ApiMark.DotNet/DotNetNames.cs new file mode 100644 index 0000000..13a8e28 --- /dev/null +++ b/src/ApiMark.DotNet/DotNetNames.cs @@ -0,0 +1,335 @@ +// Copyright (c) DEMA Consulting +// SPDX-License-Identifier: MIT + +using System.Runtime.CompilerServices; +using ApiMark.Core; +using Mono.Cecil; + +namespace ApiMark.DotNet; + +/// +/// The approved file names of every symbol of a documented .NET module. +/// +/// +/// Every namespace, type (including nested, internal and documentation-carrier types), member page +/// and operators page of the module is registered with a exactly once +/// and resolved up front, independent of the visibility filter, so a symbol has the same file name +/// wherever it is written or linked. The emitters and link resolvers only read names from this map; +/// they never sanitize a name themselves. The rules are documented in +/// docs/design/api-mark-core/file-naming-rules.md. +/// +internal sealed class DotNetNames +{ + /// Name of the synthetic folder and page of the global namespace. + private const string GlobalNamespaceName = "global-namespace"; + + /// Name of the reserved assembly index page. + private const string ApiPageName = "api"; + + /// Name of the reserved per-type operators page. + private const string OperatorsPageName = "operators"; + + /// Per-module name maps. + private static readonly ConditionalWeakTable ModuleNames = new(); + + /// Name maps registered for a root-namespace list instance (see ). + private static readonly ConditionalWeakTable ListNames = new(); + + /// C# keyword aliases used to spell type arguments of explicit interface implementations. + private static readonly Dictionary Aliases = new(StringComparer.Ordinal) + { + ["System.Boolean"] = "bool", + ["System.Byte"] = "byte", + ["System.SByte"] = "sbyte", + ["System.Char"] = "char", + ["System.Decimal"] = "decimal", + ["System.Double"] = "double", + ["System.Single"] = "float", + ["System.Int16"] = "short", + ["System.UInt16"] = "ushort", + ["System.Int32"] = "int", + ["System.UInt32"] = "uint", + ["System.Int64"] = "long", + ["System.UInt64"] = "ulong", + ["System.Object"] = "object", + ["System.String"] = "string", + ["System.IntPtr"] = "nint", + ["System.UIntPtr"] = "nuint", + }; + + /// The resolved registry. + private readonly SymbolNameRegistry _registry = new(); + + /// Initializes a new instance of the class. + private DotNetNames() + { + } + + /// Gets the registry that holds the approved names. + internal SymbolNameRegistry Registry => _registry; + + /// Returns the approved names of , building them on first use. + /// The documented module. + /// The resolved name map. + internal static DotNetNames For(ModuleDefinition module) => ModuleNames.GetValue(module, Build); + + /// + /// Associates with a root-namespace list so that namespace paths can be + /// looked up from the list the emitters already pass around. + /// + /// The root-namespace list instance of the documented assembly. + /// The approved names of the same assembly. + internal static void Attach(object rootNamespaces, DotNetNames names) + { + ListNames.Remove(rootNamespaces); + ListNames.Add(rootNamespaces, names); + } + + /// Looks up the names attached to a root-namespace list. + /// The root-namespace list instance. + /// The attached names, or when none were attached. + internal static DotNetNames? Find(object rootNamespaces) => + ListNames.TryGetValue(rootNamespaces, out var names) ? names : null; + + /// Returns the approved folder path of a namespace, or when unregistered. + /// The full namespace name (empty for the global namespace). + /// The path without extension. + internal string? TryGetNamespacePath(string namespaceName) => + _registry.Contains(NamespaceKey(namespaceName)) ? _registry.GetPath(NamespaceKey(namespaceName)) : null; + + /// Returns the approved file name of a type definition, or when unregistered. + /// The type definition. + /// The name without extension. + internal string? TryGetTypeName(TypeDefinition type) => + _registry.Contains(TypeKey(type)) ? _registry.GetName(TypeKey(type)) : null; + + /// Returns the approved file name of a member page, or when unregistered. + /// The member. + /// The declaring type. + /// The name without extension. + internal string? TryGetMemberName(IMemberDefinition member, TypeDefinition declaringType) + { + var key = MemberKey(declaringType, RawMemberName(member, declaringType)); + return _registry.Contains(key) ? _registry.GetName(key) : null; + } + + /// Returns the predictable file name of a type that is not part of the documented module. + /// The Cecil type name, possibly with a backtick arity. + /// The escaped name with the arity in braces. + internal static string PredictableTypeName(string cecilName) => + FileNameRules.Escape(ArityBraces(cecilName), out _); + + /// Converts a Cecil name such as Foo`2 to its registry form Foo{2}. + /// The Cecil name. + /// The name with the arity in braces. + internal static string ArityBraces(string name) + { + var tick = name.IndexOf('`'); + if (tick < 0 || tick == name.Length - 1) + { + return name; + } + + var digits = name[(tick + 1)..]; + return digits.All(char.IsAsciiDigit) ? $"{name[..tick]}{{{digits}}}" : name; + } + + /// Computes the raw (unescaped) member page name that the registry receives. + /// The member. + /// The declaring type. + /// The raw name; constructors use the type name without arity. + internal static string RawMemberName(IMemberDefinition member, TypeDefinition declaringType) + { + if (member is MethodDefinition { Name: DotNetEmitter.ConstructorMethodName }) + { + return DotNetEmitter.StripArity(declaringType.Name); + } + + var raw = member.Name; + var interfaceType = ExplicitInterface(member); + var dot = LastTopLevelDot(raw); + if (dot < 0) + { + return raw; + } + + var simple = raw[(dot + 1)..]; + return interfaceType == null ? simple : $"{InterfaceName(interfaceType)}.{simple}"; + } + + /// Builds the registry for . + /// The module. + /// The resolved names. + private static DotNetNames Build(ModuleDefinition module) + { + var names = new DotNetNames(); + var registry = names._registry; + registry.Register("R:" + ApiPageName, null, ApiPageName, SymbolKind.Reserved); + + var types = module.Types.Where(t => !DotNetEmitter.IsCompilerGenerated(t)).ToList(); + var namespaces = types.Select(t => t.Namespace).Distinct(StringComparer.Ordinal).ToList(); + var roots = DotNetGenerator.ComputeRootNamespaces(namespaces); + foreach (var ns in namespaces) + { + RegisterNamespace(registry, ns, roots); + } + + foreach (var type in types) + { + RegisterType(registry, type, NamespaceKey(type.Namespace)); + } + + registry.Resolve(); + return names; + } + + /// Registers a namespace and the folder-only namespaces above it. + /// The registry. + /// The namespace. + /// The root namespaces of the module. + private static void RegisterNamespace(SymbolNameRegistry registry, string ns, IReadOnlyList roots) + { + if (ns.Length == 0) + { + registry.Register(NamespaceKey(ns), null, GlobalNamespaceName, SymbolKind.Namespace); + return; + } + + var root = roots.FirstOrDefault(r => ns == r || ns.StartsWith(r + ".", StringComparison.Ordinal)) ?? ns; + if (!registry.Contains(NamespaceKey(root))) + { + registry.Register(NamespaceKey(root), null, root, SymbolKind.Namespace); + } + + var parent = root; + foreach (var segment in ns.Length > root.Length ? ns[(root.Length + 1)..].Split('.') : []) + { + var current = parent + "." + segment; + if (!registry.Contains(NamespaceKey(current))) + { + registry.Register(NamespaceKey(current), NamespaceKey(parent), segment, SymbolKind.Namespace); + } + + parent = current; + } + } + + /// Registers a type, its operators page, its member pages and its nested types. + /// The registry. + /// The type. + /// The key of the namespace or declaring type. + private static void RegisterType(SymbolNameRegistry registry, TypeDefinition type, string parentKey) + { + var key = TypeKey(type); + registry.Register(key, parentKey, ArityBraces(type.Name), SymbolKind.Type); + registry.Register("O:" + type.FullName, key, OperatorsPageName, SymbolKind.Reserved); + + foreach (var member in DotNetEmitter.GetCandidateMembers(type)) + { + if (member is MethodDefinition method && DotNetEmitter.IsOperator(method)) + { + continue; + } + + var raw = RawMemberName(member, type); + var memberKey = MemberKey(type, raw); + if (!registry.Contains(memberKey)) + { + registry.Register(memberKey, key, raw, SymbolKind.Member); + } + } + + foreach (var nested in type.NestedTypes.Where(t => !DotNetEmitter.IsCompilerGenerated(t))) + { + RegisterType(registry, nested, key); + } + } + + /// Gets the registry key of a namespace. + /// The namespace. + /// The key. + private static string NamespaceKey(string ns) => "N:" + ns; + + /// Gets the registry key of a type. + /// The type. + /// The key. + private static string TypeKey(TypeDefinition type) => "T:" + type.FullName; + + /// Gets the registry key of a member page. + /// The declaring type. + /// The raw page name. + /// The key. + private static string MemberKey(TypeDefinition type, string rawName) => "M:" + type.FullName + "|" + rawName; + + /// Finds the interface of an explicit interface implementation. + /// The member. + /// The implemented interface type, or . + private static TypeReference? ExplicitInterface(IMemberDefinition member) + { + var method = member switch + { + MethodDefinition m => m, + PropertyDefinition p => p.GetMethod ?? p.SetMethod, + EventDefinition e => e.AddMethod, + _ => null, + }; + + return method is { HasOverrides: true } ? method.Overrides[0].DeclaringType : null; + } + + /// Finds the last dot that is not inside angle brackets. + /// The raw name. + /// The index, or -1. + private static int LastTopLevelDot(string name) + { + var depth = 0; + var result = -1; + for (var i = 0; i < name.Length; i++) + { + switch (name[i]) + { + case '<': + depth++; + break; + case '>': + depth--; + break; + case '.' when depth == 0: + result = i; + break; + } + } + + return result; + } + + /// Spells an interface for an explicit-implementation page name, e.g. IFoo{int}. + /// The interface type. + /// The name without namespace; type arguments in braces joined by -. + private static string InterfaceName(TypeReference type) + { + if (type is not GenericInstanceType instance) + { + return ArityBraces(type.Name); + } + + var name = DotNetEmitter.StripArity(instance.ElementType.Name); + if (instance.GenericArguments.All(a => a is GenericParameter)) + { + return $"{name}{{{instance.GenericArguments.Count}}}"; + } + + return $"{name}{{{string.Join('-', instance.GenericArguments.Select(ArgumentName))}}}"; + } + + /// Spells one type argument. + /// The argument. + /// The spelling. + private static string ArgumentName(TypeReference type) => type switch + { + GenericParameter p => p.Name, + ArrayType a => $"Array{{{ArgumentName(a.ElementType)}}}", + GenericInstanceType g => $"{DotNetEmitter.StripArity(g.ElementType.Name)}{{{string.Join('-', g.GenericArguments.Select(ArgumentName))}}}", + _ => Aliases.TryGetValue(type.FullName, out var alias) ? alias : ArityBraces(type.Name), + }; +} diff --git a/src/ApiMark.DotNet/TypeLinkResolver.cs b/src/ApiMark.DotNet/TypeLinkResolver.cs index 292c227..e1a05f1 100644 --- a/src/ApiMark.DotNet/TypeLinkResolver.cs +++ b/src/ApiMark.DotNet/TypeLinkResolver.cs @@ -494,7 +494,7 @@ internal string LinkifyResolvedMember( // The segments after the namespace form the nested-type chain; flatten each name as the other run did var namespaceSegmentCount = bestNamespace.Split('.').Length; - var path = string.Join('/', segments.Skip(namespaceSegmentCount).Select(s => ApiMark.Core.FileNameSanitizer.SanitizeSegment(TypeNameSimplifier.FlattenArity(s)))); + var path = string.Join('/', segments.Skip(namespaceSegmentCount).Select(s => DotNetNames.PredictableTypeName(s))); var folder = DotNetEmitter.GetNamespaceFolderPath(bestNamespace, best.RootNamespaces); return BuildExternalLink(best, folder.Length > 0 ? $"{folder}/{path}" : path, currentFolder, displayText); } @@ -731,8 +731,7 @@ private static string BuildTypePageKey(TypeReference typeRef, IReadOnlyList private static string ComputeRelativePath(string currentFolder, string targetKey) { - var from = currentFolder.Length > 0 ? currentFolder : "."; - return Path.GetRelativePath(from, targetKey + ".md").Replace('\\', '/'); + return ApiMark.Core.LinkPath.Relative(currentFolder, targetKey + ".md"); } /// diff --git a/src/ApiMark.DotNet/TypeNameSimplifier.cs b/src/ApiMark.DotNet/TypeNameSimplifier.cs index e9066ad..434c94d 100644 --- a/src/ApiMark.DotNet/TypeNameSimplifier.cs +++ b/src/ApiMark.DotNet/TypeNameSimplifier.cs @@ -296,22 +296,16 @@ internal static string StripArity(string name) } /// - /// Converts the IL backtick arity suffix to a hyphenated numeric suffix, producing a - /// file-system-safe name that still distinguishes generic types by parameter count. + /// Converts the IL backtick arity suffix to the brace notation used in generated file names. /// /// - /// For example, Foo`2 becomes Foo-2 and Foo is unchanged. - /// A hyphen cannot appear in a C# identifier, so Foo-1 never collides with a real - /// type named Foo1, and the arity keeps Foo, Foo<T> and - /// Foo<T1, T2> distinct. The name needs no URL escaping. + /// For example, Foo`2 becomes Foo{2} and Foo is unchanged. Braces cannot + /// appear in a C# identifier, so Foo{1} never collides with a real type named Foo1, + /// and the arity keeps Foo, Foo<T> and Foo<T1, T2> distinct. /// /// The raw IL type name that may contain a backtick arity suffix. - /// The name with the backtick replaced by a hyphen and the arity count preserved. - internal static string FlattenArity(string name) - { - var tick = name.IndexOf('`'); - return tick >= 0 ? string.Concat(name.AsSpan(0, tick), "-", name.AsSpan(tick + 1)) : name; - } + /// The name with the arity in braces. + internal static string FlattenArity(string name) => DotNetNames.ArityBraces(name); /// /// Returns the shortest unambiguous name for relative to diff --git a/test/ApiMark.DotNet.Tests/DotNetEmitterBreadcrumbTests.cs b/test/ApiMark.DotNet.Tests/DotNetEmitterBreadcrumbTests.cs index 219f919..a09159d 100644 --- a/test/ApiMark.DotNet.Tests/DotNetEmitterBreadcrumbTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetEmitterBreadcrumbTests.cs @@ -43,6 +43,7 @@ private static string Resolve(string key, string link) { var folder = key.Contains('/') ? key[..key.LastIndexOf('/')] : string.Empty; var path = new Uri(new Uri("http://x/" + (folder.Length > 0 ? folder + "/" : string.Empty)), link).AbsolutePath.TrimStart('/'); + path = Uri.UnescapeDataString(path); return path[..^3]; } diff --git a/test/ApiMark.DotNet.Tests/DotNetEmitterExternalNestedLinkTests.cs b/test/ApiMark.DotNet.Tests/DotNetEmitterExternalNestedLinkTests.cs index 051a49c..531a3f7 100644 --- a/test/ApiMark.DotNet.Tests/DotNetEmitterExternalNestedLinkTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetEmitterExternalNestedLinkTests.cs @@ -49,7 +49,7 @@ public void DotNetEmitterGradualDisclosure_Emit_ExternalNestedTypeCrefs_LinkToNe .Single(t => t.Contains("Refers to nested", StringComparison.Ordinal)).Replace('\n', ' '); Assert.Contains($"]({Root}/NestedHost/Inner.md)", summary, StringComparison.Ordinal); Assert.Contains($"]({Root}/NestedHost/Deep/Leaf.md)", summary, StringComparison.Ordinal); - Assert.Contains($"]({Root}/NestedGenericHost-1/Inner-1.md)", summary, StringComparison.Ordinal); + Assert.Contains($"]({Root}/NestedGenericHost{{1}}/Inner{{1}}.md)", summary, StringComparison.Ordinal); Assert.DoesNotContain("`1", summary, StringComparison.Ordinal); } @@ -63,6 +63,6 @@ public void DotNetEmitterGradualDisclosure_Emit_ExternalNestedPropertyTypes_Link // Assert var cells = factory.Writers[ConsumerKey].Operations.OfType().SelectMany(t => t.Rows.SelectMany(r => r)).ToList(); Assert.Contains(cells, c => c.Contains($"]({Root}/NestedHost/Inner.md)", StringComparison.Ordinal)); - Assert.Contains(cells, c => c.Contains($"]({Root}/NestedGenericHost-1/Inner-1.md)", StringComparison.Ordinal)); + Assert.Contains(cells, c => c.Contains($"]({Root}/NestedGenericHost{{1}}/Inner{{1}}.md)", StringComparison.Ordinal)); } } diff --git a/test/ApiMark.DotNet.Tests/DotNetEmitterExternalSignatureTests.cs b/test/ApiMark.DotNet.Tests/DotNetEmitterExternalSignatureTests.cs index 5996cb4..129d9d4 100644 --- a/test/ApiMark.DotNet.Tests/DotNetEmitterExternalSignatureTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetEmitterExternalSignatureTests.cs @@ -15,7 +15,7 @@ namespace ApiMark.DotNet.Tests; public class DotNetEmitterExternalSignatureTests { /// The fixture page folder of the type under test. - private const string TypeFolder = "ApiMark.DotNet.Fixtures/ExternalSignatureShapes-1"; + private const string TypeFolder = "ApiMark.DotNet.Fixtures/ExternalSignatureShapes{1}"; /// Emits the fixture assembly with the external assembly as a reference. /// The populated factory. diff --git a/test/ApiMark.DotNet.Tests/DotNetEmitterGenericNamingTests.cs b/test/ApiMark.DotNet.Tests/DotNetEmitterGenericNamingTests.cs index 253e87c..3af733c 100644 --- a/test/ApiMark.DotNet.Tests/DotNetEmitterGenericNamingTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetEmitterGenericNamingTests.cs @@ -10,7 +10,7 @@ namespace ApiMark.DotNet.Tests; /// -/// Exact-output tests for the generic type file-name scheme (Foo-1.md style): distinct files for +/// Exact-output tests for the generic type file-name scheme (Foo{1}.md style): distinct files for /// Foo, Foo1, Foo<T> and Foo<T1, T2>, and links that resolve to them. /// public class DotNetEmitterGenericNamingTests @@ -58,15 +58,15 @@ public void DotNetEmitterGradualDisclosure_Emit_SameNamedGenericTypes_ProduceDis new[] { Ns + "/Foo", - Ns + "/Foo-1", - Ns + "/Foo-2", Ns + "/Foo1", - Ns + "/Outer-1", + Ns + "/Foo{1}", + Ns + "/Foo{2}", + Ns + "/Outer{1}", }, keys); - Assert.Equal("public class Foo", factory.Writers[Ns + "/Foo-1"].Operations.OfType().Single().Code); + Assert.Equal("public class Foo", factory.Writers[Ns + "/Foo{1}"].Operations.OfType().Single().Code); Assert.Equal("public class Foo1", factory.Writers[Ns + "/Foo1"].Operations.OfType().Single().Code); - Assert.Equal("public class Foo : Foo", factory.Writers[Ns + "/Foo-2"].Operations.OfType().Single().Code); + Assert.Equal("public class Foo : Foo", factory.Writers[Ns + "/Foo{2}"].Operations.OfType().Single().Code); } /// Validates the namespace index links use the hyphenated-arity file names. @@ -80,9 +80,9 @@ public void DotNetEmitterGradualDisclosure_Emit_NamespaceIndex_LinksHyphenatedAr var links = factory.Writers[Ns].Operations.OfType().SelectMany(t => t.Rows).Select(r => r[0]).ToList(); Assert.Contains($"[Foo](Naming/Foo.md)", links); Assert.Contains($"[Foo1](Naming/Foo1.md)", links); - Assert.Contains($"[Foo\\](Naming/Foo-1.md)", links); - Assert.Contains($"[Foo\\](Naming/Foo-2.md)", links); - Assert.Contains($"[Outer\\](Naming/Outer-1.md)", links); + Assert.Contains("[Foo\\](Naming/Foo{1}.md)", links); + Assert.Contains("[Foo\\](Naming/Foo{2}.md)", links); + Assert.Contains("[Outer\\](Naming/Outer{1}.md)", links); } /// Validates nested generic type pages live in the hyphenated outer folder. @@ -93,8 +93,8 @@ public void DotNetEmitterGradualDisclosure_Emit_NestedGenericType_UsesHyphenated var factory = EmitGradual(); // Assert - Assert.Contains(Texts(factory.Writers[Ns + "/Outer-1"]), t => t.Contains("(Outer-1/Inner-1.md)", StringComparison.Ordinal)); - Assert.Contains(factory.Writers[Ns + "/Outer-1/Inner-1"].Operations.OfType(), s => s.Code.Contains("Inner", StringComparison.Ordinal)); + Assert.Contains(Texts(factory.Writers[Ns + "/Outer{1}"]), t => t.Contains("(Outer{1}/Inner{1}.md)", StringComparison.Ordinal)); + Assert.Contains(factory.Writers[Ns + "/Outer{1}/Inner{1}"].Operations.OfType(), s => s.Code.Contains("Inner", StringComparison.Ordinal)); } /// Validates no emitted page key or link uses the old digit-suffix form for generic types. @@ -105,9 +105,9 @@ public void DotNetEmitterGradualDisclosure_Emit_AllPages_NoDigitSuffixFileNamesF var factory = EmitGradual(); // Assert: PropertyEditorRegistry etc. use the hyphen form - Assert.Contains("ApiMark.DotNet.Fixtures/PropertyEditorRegistry-1", factory.Writers.Keys); + Assert.Contains("ApiMark.DotNet.Fixtures/PropertyEditorRegistry{1}", factory.Writers.Keys); Assert.DoesNotContain("ApiMark.DotNet.Fixtures/PropertyEditorRegistry1", factory.Writers.Keys); - Assert.Contains("ApiMark.DotNet.Fixtures/KeyedPair-2", factory.Writers.Keys); + Assert.Contains("ApiMark.DotNet.Fixtures/KeyedPair{2}", factory.Writers.Keys); Assert.DoesNotContain("ApiMark.DotNet.Fixtures/KeyedPair2", factory.Writers.Keys); } @@ -147,9 +147,9 @@ public void DotNetEmitterGradualDisclosure_Emit_CrefAndBaseTypeLinks_UseHyphenat var factory = EmitGradual(); // Assert - var foo1Texts = Texts(factory.Writers[Ns + "/Foo-1/Link"]).ToList(); + var foo1Texts = Texts(factory.Writers[Ns + "/Foo{1}/Link"]).ToList(); Assert.Contains(foo1Texts, t => t.Contains("(../Foo1.md)", StringComparison.Ordinal)); - Assert.Contains(foo1Texts, t => t.Contains("(../Foo-2.md)", StringComparison.Ordinal)); + Assert.Contains(foo1Texts, t => t.Contains("(../Foo{2}.md)", StringComparison.Ordinal)); } /// Resolves a relative link from a folder against the page-key namespace. diff --git a/test/ApiMark.DotNet.Tests/DotNetEmitterGradualDisclosureGenericsTests.cs b/test/ApiMark.DotNet.Tests/DotNetEmitterGradualDisclosureGenericsTests.cs index 7a1ff11..22d4385 100644 --- a/test/ApiMark.DotNet.Tests/DotNetEmitterGradualDisclosureGenericsTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetEmitterGradualDisclosureGenericsTests.cs @@ -82,7 +82,7 @@ private static List Paragraphs(InMemoryMarkdownWriterFactory factory, st [InlineData("ApiMark.DotNet.Fixtures/OverloadMatrix", "[ToAvalonia (4 overloads)]", "decimal, float, long, char[]")] [InlineData("ApiMark.DotNet.Fixtures/OverloadMatrix", "[Scale (2 overloads)]", "double")] [InlineData("ApiMark.DotNet.Fixtures/OverloadMatrix", "[Transact (2 overloads)]", "int, T")] - [InlineData("ApiMark.DotNet.Fixtures/OverloadMatrix-1", "[Transact (2 overloads)]", "TItem, T")] + [InlineData("ApiMark.DotNet.Fixtures/OverloadMatrix{1}", "[Transact (2 overloads)]", "TItem, T")] public void DotNetEmitterGradualDisclosure_Emit_OverloadGroupRow_ListsDistinctReturnTypesInOrder( string typeKey, string memberLabel, @@ -108,7 +108,7 @@ public void DotNetEmitterGradualDisclosure_Emit_OverloadPage_EscapesGenericHeadi Headings(factory, "ApiMark.DotNet.Fixtures/OverloadMatrix/Transact")); Assert.Equal( new[] { "# Transact", "## Transact(Func\\)", "## Transact\\(Func\\)" }, - Headings(factory, "ApiMark.DotNet.Fixtures/OverloadMatrix-1/Transact")); + Headings(factory, "ApiMark.DotNet.Fixtures/OverloadMatrix{1}/Transact")); } /// @@ -119,7 +119,7 @@ public void DotNetEmitterGradualDisclosure_Emit_OverloadPage_EscapesGenericHeadi /// The expected example code. [Theory] [InlineData("ApiMark.DotNet.Fixtures/OverloadMatrix/Transact", "var text = matrix.Transact(() => \"generic\");")] - [InlineData("ApiMark.DotNet.Fixtures/OverloadMatrix-1/Transact", "var count = matrix.Transact(() => 3);")] + [InlineData("ApiMark.DotNet.Fixtures/OverloadMatrix{1}/Transact", "var count = matrix.Transact(() => 3);")] public void DotNetEmitterGradualDisclosure_Emit_OverloadPage_ExampleAttachedToDocumentingOverload(string typeKey, string code) { // Arrange / Act @@ -154,7 +154,7 @@ public void DotNetEmitterGradualDisclosure_Emit_GenericTypePage_ShowsEscapedHead { // Arrange / Act var factory = EmitGradual(); - const string key = "ApiMark.DotNet.Fixtures/PropertyEditorRegistry-1"; + const string key = "ApiMark.DotNet.Fixtures/PropertyEditorRegistry{1}"; // Assert: file name keeps the Name{arity} form, H1 is escaped Assert.Equal(new[] { "# PropertyEditorRegistry\\", "## Type Parameters", "## Constructors", "## Methods" }, Headings(factory, key)); @@ -167,7 +167,7 @@ public void DotNetEmitterGradualDisclosure_Emit_GenericTypePage_ShowsEscapedHead // Assert: the namespace page link text is escaped and points at the arity file var nsRow = Row(factory, "ApiMark.DotNet.Fixtures", "[PropertyEditorRegistry"); - Assert.Equal("[PropertyEditorRegistry\\](ApiMark.DotNet.Fixtures/PropertyEditorRegistry-1.md)", nsRow[0]); + Assert.Equal("[PropertyEditorRegistry\\](ApiMark.DotNet.Fixtures/PropertyEditorRegistry{1}.md)", nsRow[0]); } /// Validates an undocumented type parameter gets the placeholder and every constraint is rendered. @@ -176,7 +176,7 @@ public void DotNetEmitterGradualDisclosure_Emit_MultiParameterGenericType_ShowsP { // Arrange / Act var factory = EmitGradual(); - const string key = "ApiMark.DotNet.Fixtures/KeyedPair-2"; + const string key = "ApiMark.DotNet.Fixtures/KeyedPair{2}"; // Assert Assert.Equal("# KeyedPair\\", Headings(factory, key)[0]); @@ -194,7 +194,7 @@ public void DotNetEmitterGradualDisclosure_Emit_GenericMethodPage_ShowsConstrain { // Arrange / Act var factory = EmitGradual(); - const string key = "ApiMark.DotNet.Fixtures/PropertyEditorRegistry-1/Convert"; + const string key = "ApiMark.DotNet.Fixtures/PropertyEditorRegistry{1}/Convert"; // Assert Assert.Equal("# Convert\\(TEditor)", Headings(factory, key)[0]); @@ -203,7 +203,7 @@ public void DotNetEmitterGradualDisclosure_Emit_GenericMethodPage_ShowsConstrain Assert.Equal(new[] { "Type Parameter", "Description" }, table.Headers); Assert.Equal(new[] { "TResult", "The value type to produce." }, Assert.Single(table.Rows)); Assert.Equal( - "[API Reference](../../api.md) › [ApiMark.DotNet.Fixtures](../../ApiMark.DotNet.Fixtures.md) › [PropertyEditorRegistry\\](../PropertyEditorRegistry-1.md)", + "[API Reference](../../api.md) › [ApiMark.DotNet.Fixtures](../../ApiMark.DotNet.Fixtures.md) › [PropertyEditorRegistry\\](../PropertyEditorRegistry{1}.md)", Paragraphs(factory, key)[0]); } @@ -215,9 +215,9 @@ public void DotNetEmitterGradualDisclosure_Emit_NestedTypeOfGenericHost_HasNoOwn var factory = EmitGradual(); // Assert - Assert.DoesNotContain("## Type Parameters", Headings(factory, "ApiMark.DotNet.Fixtures/GenericHost-1/Nested")); - Assert.Equal("public class Nested", Page(factory, "ApiMark.DotNet.Fixtures/GenericHost-1/Nested").Operations.OfType().Single().Code); - Assert.Contains("## Type Parameters", Headings(factory, "ApiMark.DotNet.Fixtures/GenericHost-1")); + Assert.DoesNotContain("## Type Parameters", Headings(factory, "ApiMark.DotNet.Fixtures/GenericHost{1}/Nested")); + Assert.Equal("public class Nested", Page(factory, "ApiMark.DotNet.Fixtures/GenericHost{1}/Nested").Operations.OfType().Single().Code); + Assert.Contains("## Type Parameters", Headings(factory, "ApiMark.DotNet.Fixtures/GenericHost{1}")); } /// Validates no emitted heading or table link label anywhere contains an unescaped angle bracket. diff --git a/test/ApiMark.DotNet.Tests/DotNetEmitterGradualDisclosureTests.cs b/test/ApiMark.DotNet.Tests/DotNetEmitterGradualDisclosureTests.cs index 839094d..ac53324 100644 --- a/test/ApiMark.DotNet.Tests/DotNetEmitterGradualDisclosureTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetEmitterGradualDisclosureTests.cs @@ -812,15 +812,15 @@ public void DotNetEmitterGradualDisclosure_Emit_OverloadPage_HasBreadcrumb() FirstParagraph(factory, key)); } - /// Validates breadcrumbs on a combined (case-collision) page. + /// Validates breadcrumbs on a case-twin member page that received the collision suffix. [Fact] - public void DotNetEmitterGradualDisclosure_Emit_CombinedPage_HasBreadcrumb() + public void DotNetEmitterGradualDisclosure_Emit_CaseTwinMemberPage_HasBreadcrumb() { // Arrange / Act var factory = EmitFixture(); // Assert - var key = "ApiMark.DotNet.Fixtures/CaseCollisionClass/name"; + var key = "ApiMark.DotNet.Fixtures/CaseCollisionClass/name-2"; Assert.Equal( "[API Reference](../../api.md) › [ApiMark.DotNet.Fixtures](../../ApiMark.DotNet.Fixtures.md) › [CaseCollisionClass](../CaseCollisionClass.md)", FirstParagraph(factory, key)); @@ -861,7 +861,7 @@ public void DotNetEmitterGradualDisclosure_Emit_GenericMethod_SignatureHasTypePa [InlineData("RecordStructSample", "public record struct RecordStructSample")] [InlineData("ReadOnlyRecordStructSample", "public readonly record struct ReadOnlyRecordStructSample")] [InlineData("ReadOnlyStructSample", "public readonly struct ReadOnlyStructSample")] - [InlineData("GenericRecordSample-1", "public record GenericRecordSample")] + [InlineData("GenericRecordSample{1}", "public record GenericRecordSample")] public void DotNetEmitterGradualDisclosure_Emit_RecordAndStructKinds_ShowKindKeywords(string typeName, string expectedPrefix) { // Arrange / Act diff --git a/test/ApiMark.DotNet.Tests/DotNetEmitterInheritedMembersTests.cs b/test/ApiMark.DotNet.Tests/DotNetEmitterInheritedMembersTests.cs index 26ea01b..ca7732d 100644 --- a/test/ApiMark.DotNet.Tests/DotNetEmitterInheritedMembersTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetEmitterInheritedMembersTests.cs @@ -225,7 +225,7 @@ public void DotNetEmitterGradualDisclosure_Emit_GenericAndNestedBases_RenderExpe // Assert Assert.Equal( - new[] { "### Inherited from [InheritedGenericBase](InheritedGenericBase-1.md)\\", "Take | Method" }, + new[] { "### Inherited from [InheritedGenericBase](InheritedGenericBase{1}.md)\\", "Take | Method" }, InheritedLines(factory, "InheritedFromGeneric")); Assert.Equal( new[] { "### Inherited from [NestedBase](InheritedNestedHost/NestedBase.md)", "NestedOp | Method" }, diff --git a/test/ApiMark.DotNet.Tests/DotNetEmitterTests.cs b/test/ApiMark.DotNet.Tests/DotNetEmitterTests.cs index 19bb40d..b907928 100644 --- a/test/ApiMark.DotNet.Tests/DotNetEmitterTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetEmitterTests.cs @@ -1292,9 +1292,9 @@ public void DotNetEmitter_BuildTypeSignature_SelfEquatable_SuppressedOnlyForReco [Fact] public void DotNetEmitter_GetNamespaceFolderPath_NamespaceNamedApi_IsMovedAsideFromApiIndexPage() { - Assert.Equal("api-", DotNetEmitter.GetNamespaceFolderPath("api", ["api"])); - Assert.Equal("Api-", DotNetEmitter.GetNamespaceFolderPath("Api", ["Api"])); - Assert.Equal("api-/Inner", DotNetEmitter.GetNamespaceFolderPath("api.Inner", ["api"])); + Assert.Equal("api-2", DotNetEmitter.GetNamespaceFolderPath("api", ["api"])); + Assert.Equal("Api-2", DotNetEmitter.GetNamespaceFolderPath("Api", ["Api"])); + Assert.Equal("api-2/Inner", DotNetEmitter.GetNamespaceFolderPath("api.Inner", ["api"])); Assert.Equal("api.Inner", DotNetEmitter.GetNamespaceFolderPath("api.Inner", ["api.Inner"])); } diff --git a/test/ApiMark.DotNet.Tests/DotNetFileNamingTests.cs b/test/ApiMark.DotNet.Tests/DotNetFileNamingTests.cs new file mode 100644 index 0000000..db29347 --- /dev/null +++ b/test/ApiMark.DotNet.Tests/DotNetFileNamingTests.cs @@ -0,0 +1,314 @@ +// Copyright (c) DEMA Consulting +// SPDX-License-Identifier: MIT + +using System.Text.RegularExpressions; +using ApiMark.Core; +using ApiMark.Core.TestHelpers; +using ApiMark.DotNet; +using Mono.Cecil; +using Xunit; + +namespace ApiMark.DotNet.Tests; + +/// +/// Tests for the symbol-name registry applied to .NET output: exact-name rules, every collision scenario, +/// and whole-output properties (unique, legal, visibility-independent names whose links all resolve). +/// +public class DotNetFileNamingTests +{ + /// Matches a Markdown link destination ending in .md, with an optional anchor. + private static readonly Regex LinkPattern = new(@"\]\(([^)\s#]+\.md)(?:#[^)\s]*)?\)", RegexOptions.Compiled); + + /// Creates an in-memory module whose types are created by . + /// Callback that adds types to the module. + /// The resolved names of the module. + private static DotNetNames NamesOf(Action build) + { + var assembly = AssemblyDefinition.CreateAssembly(new AssemblyNameDefinition("Synthetic", new Version(1, 0)), "Synthetic", ModuleKind.Dll); + build(assembly.MainModule); + return DotNetNames.For(assembly.MainModule); + } + + /// Adds a public class to . + /// The module. + /// The namespace. + /// The Cecil type name. + /// The new type. + private static TypeDefinition AddType(ModuleDefinition module, string ns, string name) + { + var type = new TypeDefinition(ns, name, TypeAttributes.Public | TypeAttributes.Class, module.TypeSystem.Object); + module.Types.Add(type); + return type; + } + + /// Adds a public field to . + /// The module. + /// The declaring type. + /// The field name. + /// The field. + private static FieldDefinition AddField(ModuleDefinition module, TypeDefinition type, string name) + { + var field = new FieldDefinition(name, FieldAttributes.Public, module.TypeSystem.Int32); + type.Fields.Add(field); + return field; + } + + /// NR-namespace: a namespace keeps the plain name and the same-named type is displaced. + [Fact] + public void DotNetNames_NamespaceAndTypeShareAPath_NamespaceWinsAndTypeIsSuffixed() + { + var names = NamesOf(m => + { + AddType(m, "A", "B"); + AddType(m, "A.B", "C"); + }); + + Assert.Equal("A/B", names.TryGetNamespacePath("A.B")); + Assert.Equal("A", names.TryGetNamespacePath("A")); + var typeB = names.Registry.GetPath("T:A.B"); + Assert.Equal("A/B-2", typeB); + Assert.Equal("A/B/C", names.Registry.GetPath("T:A.B.C")); + } + + /// Case-twin namespaces get separate paths; the ordinally first keeps the plain name. + [Fact] + public void DotNetNames_CaseTwinNamespaces_SecondGetsSuffix() + { + var names = NamesOf(m => + { + AddType(m, "R", "X"); + AddType(m, "R.Twin", "Y"); + AddType(m, "R.twin", "Z"); + }); + + Assert.Equal("R/Twin", names.TryGetNamespacePath("R.Twin")); + Assert.Equal("R/twin-2", names.TryGetNamespacePath("R.twin")); + Assert.Equal("R/twin-2/Z", names.Registry.GetPath("T:R.twin.Z")); + } + + /// A root namespace named like the reserved index page is displaced; api.md is never overwritten. + [Fact] + public void DotNetNames_RootNamespaceNamedApi_IsDisplacedFromReservedPage() + { + var names = NamesOf(m => + { + AddType(m, "api", "X"); + AddType(m, "Api", "Y"); + }); + + Assert.Equal("Api-2", names.TryGetNamespacePath("Api")); + Assert.Equal("api-3", names.TryGetNamespacePath("api")); + Assert.Equal("api", names.Registry.GetPath("R:api")); + } + + /// Generic types use brace arity and never collide with a type of a different arity. + [Fact] + public void DotNetNames_GenericTypes_UseBraceArity() + { + var names = NamesOf(m => + { + AddType(m, "N", "Foo"); + AddType(m, "N", "Foo`1"); + AddType(m, "N", "Foo`2"); + AddType(m, "N", "Foo1"); + }); + + Assert.Equal("Foo", names.Registry.GetName("T:N.Foo")); + Assert.Equal("Foo{1}", names.Registry.GetName("T:N.Foo`1")); + Assert.Equal("Foo{2}", names.Registry.GetName("T:N.Foo`2")); + Assert.Equal("Foo1", names.Registry.GetName("T:N.Foo1")); + } + + /// Members named like the reserved operators page, in any case, are displaced. + [Fact] + public void DotNetNames_MembersNamedOperators_AreDisplaced() + { + TypeDefinition? type = null; + FieldDefinition? lower = null; + FieldDefinition? upper = null; + var names = NamesOf(m => + { + type = AddType(m, "N", "T"); + lower = AddField(m, type, "operators"); + upper = AddField(m, type, "Operators"); + }); + + Assert.Equal("operators", names.Registry.GetName("O:N.T")); + Assert.Equal("Operators-2", names.TryGetMemberName(upper!, type!)); + Assert.Equal("operators-3", names.TryGetMemberName(lower!, type!)); + } + + /// Case-twin members get separate pages: the plain name, then the last-resort suffix. + [Fact] + public void DotNetNames_CaseTwinMembers_GetSeparatePages() + { + TypeDefinition? type = null; + FieldDefinition? lower = null; + FieldDefinition? upper = null; + var names = NamesOf(m => + { + type = AddType(m, "N", "T"); + lower = AddField(m, type, "value"); + upper = AddField(m, type, "Value"); + }); + + Assert.Equal("Value", names.TryGetMemberName(upper!, type!)); + Assert.Equal("value-2", names.TryGetMemberName(lower!, type!)); + } + + /// A nested type and a member of the declaring type with the same name: the type keeps the name. + [Fact] + public void DotNetNames_NestedTypeAndMemberSameName_TypeWins() + { + TypeDefinition? outer = null; + FieldDefinition? field = null; + var names = NamesOf(m => + { + outer = AddType(m, "N", "Outer"); + var nested = new TypeDefinition(string.Empty, "Item", TypeAttributes.NestedPublic | TypeAttributes.Class, m.TypeSystem.Object); + outer.NestedTypes.Add(nested); + field = AddField(m, outer, "Item"); + }); + + Assert.Equal("Item", names.Registry.GetName("T:N.Outer/Item")); + Assert.Equal("Item-2", names.TryGetMemberName(field!, outer!)); + } + + /// Every path in the registry is legal and unique case-insensitively, whatever the type names. + [Fact] + public void DotNetNames_HostileNames_ProduceUniqueLegalPaths() + { + var names = NamesOf(m => + { + foreach (var name in new[] { "CON", "con", "Aux.txt", "ab", "A_B", "a:b", "x.", "COM1" }) + { + AddType(m, "Hostile", name); + } + }); + + var paths = names.Registry.GetAllPaths(); + Assert.Equal(paths.Count, paths.Select(p => p.ToLowerInvariant()).Distinct().Count()); + Assert.All(paths, p => Assert.All(p.Split('/'), s => Assert.True(FileNameRules.IsLegalSegment(s), s))); + } + + /// Explicit interface implementations keep their type arguments in the page name. + [Fact] + public void DotNetGenerator_Emit_ExplicitGenericInterfaceMembers_KeepTypeArguments() + { + using var module = ModuleDefinition.ReadModule(FixturePaths.GetFixtureDll()); + var type = module.GetType("ApiMark.DotNet.Fixtures.LegalNames.ExplicitGenericImpl"); + var names = DotNetNames.For(module); + var keys = DotNetEmitter.GetCandidateMembers(type) + .Select(m => names.TryGetMemberName(m, type)) + .ToList(); + + Assert.Contains("IGenericSource{int}.GetItem", keys); + Assert.Contains("IGenericSource{string}.GetItem", keys); + Assert.Contains("IGenericSource{Array{int}}.GetItem", keys); + Assert.Contains("IGenericSource{List{Dictionary{string-int}}}.Current", keys); + } + + /// Generic types use the brace arity in their page names. + [Fact] + public void DotNetGenerator_Emit_GenericType_UsesBraceArity() + { + var factory = Emit(FixturePaths.GetFixtureDll(), FixturePaths.GetFixtureXmlDoc(), ApiVisibility.Public); + + Assert.Contains("ApiMark.DotNet.Fixtures/PropertyEditorRegistry{1}", factory.Writers.Keys); + Assert.Contains("ApiMark.DotNet.Fixtures/KeyedPair{2}", factory.Writers.Keys); + } + + /// Every generated path is legal and unique case-insensitively, and every link resolves. + /// The fixture assembly selector. + /// The visibility filter. + [Theory] + [InlineData("main", ApiVisibility.Public)] + [InlineData("main", ApiVisibility.PublicAndProtected)] + [InlineData("main", ApiVisibility.All)] + [InlineData("global", ApiVisibility.Public)] + [InlineData("global", ApiVisibility.All)] + [InlineData("external", ApiVisibility.All)] + public void DotNetGenerator_Emit_Fixtures_PathsAreUniqueLegalAndLinksResolve(string fixture, ApiVisibility visibility) + { + var factory = EmitFixture(fixture, visibility); + + var keys = factory.Writers.Keys.ToList(); + Assert.NotEmpty(keys); + Assert.Equal(keys.Count, keys.Select(k => k.ToLowerInvariant()).Distinct().Count()); + + // A page and a folder of the same name may coexist, but no folder may shadow a file of another case. + Assert.All(keys, k => Assert.All(k.Split('/'), s => Assert.True(FileNameRules.IsLegalSegment(s), k))); + + var broken = new List(); + foreach (var (key, writer) in factory.Writers) + { + var texts = writer.Operations.OfType().Select(p => p.Text) + .Concat(writer.Operations.OfType().SelectMany(t => t.Rows.SelectMany(r => r))); + foreach (var link in texts.SelectMany(t => LinkPattern.Matches(t).Select(m => m.Groups[1].Value))) + { + if (link.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var target = Resolve(key, link); + if (!factory.Writers.ContainsKey(target)) + { + broken.Add($"{key} -> {link} ({target})"); + } + } + } + + Assert.True(broken.Count == 0, string.Join(Environment.NewLine, broken.Take(20))); + } + + /// A symbol has the same file name whatever the visibility filter. + [Fact] + public void DotNetGenerator_Emit_Fixtures_NamesAreIndependentOfVisibility() + { + var publicKeys = EmitFixture("main", ApiVisibility.Public).Writers.Keys.ToHashSet(StringComparer.Ordinal); + var allKeys = EmitFixture("main", ApiVisibility.All).Writers.Keys.ToHashSet(StringComparer.Ordinal); + + var missing = publicKeys.Where(k => !allKeys.Contains(k)).ToList(); + Assert.True(missing.Count == 0, string.Join(", ", missing.Take(10))); + } + + /// Emits one of the fixture assemblies. + /// The fixture selector. + /// The visibility filter. + /// The populated factory. + private static InMemoryMarkdownWriterFactory EmitFixture(string fixture, ApiVisibility visibility) => fixture switch + { + "global" => Emit(FixturePaths.GetGlobalFixtureDll(), FixturePaths.GetGlobalFixtureXmlDoc(), visibility), + "external" => Emit(FixturePaths.GetExternalFixtureDll(), FixturePaths.GetExternalFixtureXmlDoc(), visibility), + _ => Emit(FixturePaths.GetFixtureDll(), FixturePaths.GetFixtureXmlDoc(), visibility), + }; + + /// Generates gradual-disclosure output for an assembly. + /// The assembly path. + /// The XML documentation path. + /// The visibility filter. + /// The populated factory. + private static InMemoryMarkdownWriterFactory Emit(string dll, string xml, ApiVisibility visibility) + { + var factory = new InMemoryMarkdownWriterFactory(); + new DotNetGenerator(new DotNetGeneratorOptions + { + AssemblyPath = dll, + XmlDocPath = xml, + Visibility = visibility, + }).Parse(new InMemoryContext()).Emit(factory, new EmitConfig(), new InMemoryContext()); + return factory; + } + + /// Resolves a relative link against the page that contains it. + /// The page key of the containing page. + /// The link destination. + /// The target page key without extension. + private static string Resolve(string key, string link) + { + var folder = key.Contains('/') ? key[..key.LastIndexOf('/')] : string.Empty; + var path = new Uri(new Uri("http://x/" + (folder.Length > 0 ? folder + "/" : string.Empty)), link).AbsolutePath.TrimStart('/'); + return Uri.UnescapeDataString(path)[..^3]; + } +} diff --git a/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs b/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs index ca076d5..1ecef8a 100644 --- a/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs @@ -1283,11 +1283,11 @@ public void DotNetGenerator_Generate_RemarksWithBulletList_RendersListInMarkdown } /// - /// Validates that two members whose names differ only in case are combined onto a - /// single page named after the lowercase key. + /// Validates that two members whose names differ only in case get separate pages: the + /// ordinally first keeps its plain name and the other receives the -2 collision suffix. /// [Fact] - public void DotNetGenerator_Generate_CaseCollisionClass_CreatesCombinedPage() + public void DotNetGenerator_Generate_CaseCollisionClass_CreatesSeparatePages() { // Arrange var factory = new InMemoryMarkdownWriterFactory(); @@ -1296,18 +1296,16 @@ public void DotNetGenerator_Generate_CaseCollisionClass_CreatesCombinedPage() // Act generator.Parse(new InMemoryContext()).Emit(factory, new EmitConfig(), new InMemoryContext()); - // Assert: the combined page is written at the lowercase key path - Assert.True( - factory.Writers.ContainsKey("ApiMark.DotNet.Fixtures/CaseCollisionClass/name"), - "Expected a combined page at the lowercase key 'name' for the colliding members"); + // Assert: each member has its own page; the case twin takes the last-resort suffix + Assert.True(factory.Writers.ContainsKey("ApiMark.DotNet.Fixtures/CaseCollisionClass/Name")); + Assert.True(factory.Writers.ContainsKey("ApiMark.DotNet.Fixtures/CaseCollisionClass/name-2")); } /// - /// Validates that the generator does not create a separate page for the upper-case - /// member when a case-insensitive collision exists. + /// Validates that no page is written whose name differs from an existing page only in case. /// [Fact] - public void DotNetGenerator_Generate_CaseCollisionClass_DoesNotCreateSeparateCasedPage() + public void DotNetGenerator_Generate_CaseCollisionClass_NoPagesDifferOnlyInCase() { // Arrange var factory = new InMemoryMarkdownWriterFactory(); @@ -1316,19 +1314,16 @@ public void DotNetGenerator_Generate_CaseCollisionClass_DoesNotCreateSeparateCas // Act generator.Parse(new InMemoryContext()).Emit(factory, new EmitConfig(), new InMemoryContext()); - // Assert: no page should be created using the exact-case name "Name" when a collision - // exists — the combined lowercase page replaces all individual pages for the group - Assert.False( - factory.Writers.ContainsKey("ApiMark.DotNet.Fixtures/CaseCollisionClass/Name"), - "Expected no separate page for 'Name' when a case-insensitive collision exists"); + // Assert: page names are unique when compared case-insensitively + var names = factory.Writers.Keys.Where(k => k.StartsWith("ApiMark.DotNet.Fixtures/CaseCollisionClass/", StringComparison.Ordinal)).ToList(); + Assert.Equal(names.Count, names.Select(n => n.ToLowerInvariant()).Distinct().Count()); } /// - /// Validates that the combined collision page contains H2 headings for both the - /// field name and the property Name. + /// Validates that the field name and the property Name each have their own page. /// [Fact] - public void DotNetGenerator_Generate_CaseCollisionClass_CombinedPageContainsBothMembers() + public void DotNetGenerator_Generate_CaseCollisionClass_EachMemberHasOwnPage() { // Arrange var factory = new InMemoryMarkdownWriterFactory(); @@ -1337,17 +1332,11 @@ public void DotNetGenerator_Generate_CaseCollisionClass_CombinedPageContainsBoth // Act generator.Parse(new InMemoryContext()).Emit(factory, new EmitConfig(), new InMemoryContext()); - // Assert: combined page exists - Assert.True(factory.Writers.TryGetValue("ApiMark.DotNet.Fixtures/CaseCollisionClass/name", out var writer)); - - // Assert: both members appear as distinct H2 headings on the combined page - var level2Headings = writer.Operations - .OfType() - .Where(h => h.Level == 2) - .Select(h => h.Text) - .ToList(); - Assert.Contains(level2Headings, h => h.StartsWith("name", StringComparison.Ordinal)); - Assert.Contains(level2Headings, h => h.StartsWith("Name", StringComparison.Ordinal)); + // Assert: both members have a page, with H1 headings naming the member + Assert.True(factory.Writers.TryGetValue("ApiMark.DotNet.Fixtures/CaseCollisionClass/Name", out var upper)); + Assert.True(factory.Writers.TryGetValue("ApiMark.DotNet.Fixtures/CaseCollisionClass/name-2", out var lower)); + Assert.Contains(upper.Operations.OfType(), h => h.Level == 1 && h.Text.Contains("Name", StringComparison.Ordinal)); + Assert.Contains(lower.Operations.OfType(), h => h.Level == 1 && h.Text.Contains("name", StringComparison.Ordinal)); } /// @@ -1544,7 +1533,7 @@ public void DotNetGenerator_Generate_ExternalLinksMapped_LinksTypeAndSkipsExtern var rows = writer.Operations.OfType().SelectMany(t => t.Rows).ToList(); Assert.Contains( rows, - row => row.Any(cell => cell.Contains("[ILogger](../../../OtherLib/api/Microsoft.Extensions.Logging/ILogger-1.md)", StringComparison.Ordinal))); + row => row.Any(cell => cell.Contains("[ILogger](../../../OtherLib/api/Microsoft.Extensions.Logging/ILogger{1}.md)", StringComparison.Ordinal))); Assert.DoesNotContain( writer.Operations.OfType(), h => h.Text == "External Types"); @@ -1612,7 +1601,7 @@ public void DotNetGenerator_Generate_ExternalLinksHttpsUrl_EmitsAbsoluteLink() var rows = writer.Operations.OfType().SelectMany(t => t.Rows).ToList(); Assert.Contains( rows, - row => row.Any(cell => cell.Contains("[ILogger](https://docs.example.com/other/api/Microsoft.Extensions.Logging/ILogger-1.md)", StringComparison.Ordinal))); + row => row.Any(cell => cell.Contains("[ILogger](https://docs.example.com/other/api/Microsoft.Extensions.Logging/ILogger{1}.md)", StringComparison.Ordinal))); } /// Validates that the mapping key is matched case-insensitively. @@ -1825,9 +1814,9 @@ public void DotNetGenerator_Generate_GenericDelegateType_IncludesTypeParameters( // Act generator.Parse(new InMemoryContext()).Emit(factory, new EmitConfig(), new InMemoryContext()); - // Assert: type page must exist (arity flattened: SampleTransform`2 → SampleTransform-2) + // Assert: type page must exist (arity flattened: SampleTransform`2 → SampleTransform{2}) Assert.True( - factory.Writers.TryGetValue("ApiMark.DotNet.Fixtures/SampleTransform-2", out var writer), + factory.Writers.TryGetValue("ApiMark.DotNet.Fixtures/SampleTransform{2}", out var writer), "Expected type page for SampleTransform generic delegate"); var signature = writer.Operations.OfType().FirstOrDefault(); Assert.NotNull(signature); diff --git a/test/ApiMark.DotNet.Tests/TypeLinkResolverTests.cs b/test/ApiMark.DotNet.Tests/TypeLinkResolverTests.cs index be548e5..ebbff43 100644 --- a/test/ApiMark.DotNet.Tests/TypeLinkResolverTests.cs +++ b/test/ApiMark.DotNet.Tests/TypeLinkResolverTests.cs @@ -678,7 +678,7 @@ public void TypeLinkResolver_Linkify_MappedGenericType_LinksContainerWithArgumen var result = resolver.Linkify(generic, "", "ApiMark.DotNet.Fixtures", externalTypes); // Assert - Assert.Equal("[Box](../Other/api/Acme.Widgets/Box-1.md)\\", result); + Assert.Equal("[Box](../Other/api/Acme.Widgets/Box{1}.md)\\", result); Assert.Empty(externalTypes); } From 22b9da1d0d76243d90cf11d966833c76de01bbd2 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 15 Oct 2026 01:23:09 -0400 Subject: [PATCH 03/15] Migrate C++ generator file naming to SymbolNameRegistry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ApiMark.Cpp/CppEmitter.cs | 127 +++---- .../CppEmitterGradualDisclosure.cs | 256 +++++++------- src/ApiMark.Cpp/CppGenerator.cs | 29 +- src/ApiMark.Cpp/CppNames.cs | 261 ++++++++++++++ src/ApiMark.Cpp/CppTypeLinkResolver.cs | 12 +- .../CppEmitterGradualDisclosureTests.cs | 101 +++--- test/ApiMark.Cpp.Tests/CppEmitterTests.cs | 102 ++---- test/ApiMark.Cpp.Tests/CppFileNamingTests.cs | 327 ++++++++++++++++++ .../CppFixtureFileNamingTests.cs | 43 +++ .../CppTypeLinkResolverTests.cs | 31 +- 10 files changed, 903 insertions(+), 386 deletions(-) create mode 100644 src/ApiMark.Cpp/CppNames.cs create mode 100644 test/ApiMark.Cpp.Tests/CppFileNamingTests.cs create mode 100644 test/ApiMark.Cpp.Tests/CppFixtureFileNamingTests.cs diff --git a/src/ApiMark.Cpp/CppEmitter.cs b/src/ApiMark.Cpp/CppEmitter.cs index 1e434f2..5bb45d2 100644 --- a/src/ApiMark.Cpp/CppEmitter.cs +++ b/src/ApiMark.Cpp/CppEmitter.cs @@ -36,14 +36,20 @@ internal sealed class CppEmitter : IApiEmitter /// Generator configuration options. /// Namespace declarations collected during parse. /// Type link resolver built from the parsed namespace declarations. + /// + /// The approved file names shared with ; built from + /// when omitted. + /// internal CppEmitter( CppGeneratorOptions options, SortedDictionary namespaceDecls, - CppTypeLinkResolver cppResolver) + CppTypeLinkResolver cppResolver, + CppNames? names = null) { _options = options; _namespaceDecls = namespaceDecls; _cppResolver = cppResolver; + Names = names ?? CppNames.Build(namespaceDecls); _normalizedPublicIncludeRoots = options.PublicIncludeRoots .Select(root => PathHelpers.NormalizeCaseDirectory(root, _includeRootDirectoryEntryCache)) .ToList(); @@ -52,6 +58,9 @@ internal CppEmitter( /// Gets the generator configuration options. internal CppGeneratorOptions Options => _options; + /// Gets the approved file names of every page of the documentation tree. + internal CppNames Names { get; } + /// /// Emits the complete Markdown documentation tree in the format specified by /// . @@ -78,38 +87,6 @@ public void Emit(IMarkdownWriterFactory factory, EmitConfig config, IContext con // Ownership and include-path helpers // ========================================================================= - /// - /// Sanitizes a C++ declaration name for use as a file-system file name by replacing any - /// characters that are invalid in file names on Windows or Unix with an underscore. - /// - /// - /// Non-operator C++ declaration names — class names, type aliases, free function names, - /// and field names — may contain characters such as <, >, and : - /// (from template specializations or qualified names) that are forbidden in Windows file - /// names. Operator names (e.g. operator*, operator<<) are - /// never passed to this method — they are partitioned upstream and written to a - /// shared operators.md page, so file-name collisions between operators never arise. - /// Note: the mapping is delegated to so it is - /// deterministic and identical on every platform, rather than depending on the OS-specific - /// . - /// - /// The C++ declaration name to sanitize. Must not be null. - /// - /// A file name segment that is legal on Windows and Linux (see ). - /// - internal static string SanitizeFileName(string name) - { - ArgumentNullException.ThrowIfNull(name); - var sanitized = FileNameSanitizer.SanitizeSegment(name); - - // A declaration or member named like the reserved shared operators page is moved aside - // (mirroring the DotNet emitter) so it can never overwrite the operators.md of its folder. - return string.Equals(sanitized, OperatorsPageName, StringComparison.OrdinalIgnoreCase) ? sanitized + "-" : sanitized; - } - - /// The file name (without extension) of the shared operator-overload page of a folder. - internal const string OperatorsPageName = "operators"; - /// /// Determines whether a C++ function name denotes an operator overload (operator+, /// operator(), operator bool, ...) rather than an ordinary identifier that merely @@ -129,35 +106,6 @@ internal static bool IsOperatorName(string name) return name.Length == prefix.Length || !(char.IsLetterOrDigit(name[prefix.Length]) || name[prefix.Length] == '_'); } - /// The file name (without extension) of the root index page. - internal const string ApiPageName = "api"; - - /// - /// Converts a namespace key into the file name (without extension) of its root-level namespace - /// page; a name equal to the reserved api index page is moved aside. - /// - /// The raw namespace key. - /// A legal root-level page file name that never equals the api index page. - internal static string GetNamespacePageName(string nsKey) - { - var sanitized = SanitizeFileName(nsKey); - return string.Equals(sanitized, ApiPageName, StringComparison.OrdinalIgnoreCase) ? sanitized + "-" : sanitized; - } - - /// - /// Sanitizes every /-separated segment of a folder or page-key path so that it is legal - /// on Windows and Linux. Empty segments are dropped. - /// - /// The raw path (for example ns/Foo<int>); may be empty. - /// The sanitized path, or an empty string when has no segments. - internal static string SanitizePath(string path) - { - ArgumentNullException.ThrowIfNull(path); - return string.Join( - "/", - path.Split('/', StringSplitOptions.RemoveEmptyEntries).Select(SanitizeFileName)); - } - /// /// Directory-entry cache shared across all calls for the /// lifetime of this emitter, since and @@ -548,35 +496,29 @@ internal static string SimplifyTypeName(string typeName) } // ========================================================================= - // File name disambiguation + // Shared member pages // ========================================================================= /// - /// Writes a combined Markdown page for a group of C++ members whose names collide on - /// case-insensitive file systems, placing all members on a single page named after - /// the shared lowercase key. + /// Writes a combined Markdown page for a group of C++ members that share one name + /// (overloaded methods or constructors), placing all of them on a single page. /// /// - /// This handles the case where a method Name() and a field name would - /// map to the same file name on case-insensitive file systems. - /// All colliding members are documented together under H2 sub-headings that show - /// both the exact display name and the member kind (e.g., Name (Method)). + /// The page path is the approved path of the member page in . + /// All members are documented together under H2 sub-headings that show the exact + /// display name, its parameter types and the member kind (e.g., Name(int) (Method)). /// /// Factory for creating the output writer. - /// The namespace key used as the parent folder for this type's directory. + /// The approved file names of the documentation tree. + /// The registry key of the declaring class. /// /// The C++ qualified namespace name forwarded to /// so it can emit fully-qualified signature comments. /// /// The declaring class. - /// - /// The shared lowercase file name key. Used as both the page file name and the H1 - /// page heading so the combined page has a stable, predictable address. - /// /// - /// The ordered list of members whose base names collide on case-insensitive file - /// systems. Elements must be or . - /// Must contain at least two elements. + /// The ordered list of members that share one name. Elements must be + /// or . Must contain at least two elements. /// /// Type link resolver used to linkify parameter type cells. /// @@ -585,10 +527,10 @@ internal static string SimplifyTypeName(string typeName) /// internal static void WriteCombinedMemberPage( IMarkdownWriterFactory factory, - string nsKey, + CppNames names, + string classKey, string nsDisplayName, CppClass cls, - string lowerKey, IReadOnlyList members, CppTypeLinkResolver cppResolver) { @@ -597,12 +539,12 @@ internal static void WriteCombinedMemberPage( throw new ArgumentException("WriteCombinedMemberPage requires at least two members.", nameof(members)); } - var combinedCurrentFolder = SanitizePath($"{nsKey}/{cls.Name}"); - using var writer = factory.CreateMarkdown(combinedCurrentFolder, SanitizeFileName(lowerKey)); + var pageName = GetMemberBaseName(members[0], cls.Name); + var memberKey = CppNames.MemberKey(classKey, pageName); + var combinedCurrentFolder = names.Directory(memberKey); + using var writer = factory.CreateMarkdown(combinedCurrentFolder, names.Name(memberKey)); - // The shared lowercase key serves as the page heading so every member in the group - // can be found at the same predictable path regardless of filesystem case-sensitivity - writer.WriteHeading(1, lowerKey); + writer.WriteHeading(1, pageName); // Accumulate external types across all members on this shared page var externalTypes = new SortedSet(); @@ -735,10 +677,21 @@ public NamespaceDeclarations(string displayName, CppDocComment? doc = null) /// internal sealed record CppTypePageWriteContext( IMarkdownWriterFactory Factory, - string NsKey, + string ParentKey, string NsDisplayName, CppClass Class, - CppTypeLinkResolver CppResolver); + CppTypeLinkResolver CppResolver, + CppNames Names) +{ + /// Gets the registry key of the class. + public string ClassKey => CppNames.ClassKey(ParentKey, Class.Name); + + /// Gets the approved folder holding the class page. + public string PageDirectory => Names.Directory(ClassKey); + + /// Gets the relative link from the class page to the page of . + public string Link(string targetKey) => Names.Link(PageDirectory, targetKey); +} /// /// Bundles the per-function documentation writing context passed to diff --git a/src/ApiMark.Cpp/CppEmitterGradualDisclosure.cs b/src/ApiMark.Cpp/CppEmitterGradualDisclosure.cs index 446fbef..d4f482b 100644 --- a/src/ApiMark.Cpp/CppEmitterGradualDisclosure.cs +++ b/src/ApiMark.Cpp/CppEmitterGradualDisclosure.cs @@ -14,8 +14,9 @@ internal sealed class CppEmitterGradualDisclosure /// /// Row data for the file-naming and path-convention table on the index page. Every - /// {...} segment is passed through so it is legal on - /// Windows and Linux (for example Foo<int> becomes Foo{int}). + /// {...} segment is the name approved by the shared symbol-name registry, so it is legal on + /// Windows and Linux and unique inside its folder (for example Foo<int> becomes + /// Foo{int}). /// private static readonly string[][] PathConventionRows = [ @@ -45,6 +46,9 @@ internal sealed class CppEmitterGradualDisclosure /// Parent emitter providing options and shared helper methods. private readonly CppEmitter _emitter; + /// The approved file names of every page; the only source of file and link paths. + private readonly CppNames _names; + /// Namespace declarations collected during parse, sorted by namespace key. private readonly SortedDictionary _namespaceDecls; @@ -61,6 +65,7 @@ internal CppEmitterGradualDisclosure( CppTypeLinkResolver cppResolver) { _emitter = emitter; + _names = emitter.Names; _namespaceDecls = namespaceDecls; _cppResolver = cppResolver; } @@ -95,41 +100,43 @@ private void EmitGradualDisclosure(IMarkdownWriterFactory factory) // one detail page per owned free function, and one detail page per owned enum foreach (var (nsKey, nsDecls) in _namespaceDecls) { + var nsRegistryKey = CppNames.NamespaceKey(nsKey); WriteNamespacePage(factory, nsKey, nsDecls, _cppResolver); foreach (var cls in nsDecls.Classes) { - WriteTypePage(new CppTypePageWriteContext(factory, nsKey, nsDecls.DisplayName, cls, _cppResolver)); - WriteNestedTypePages(factory, nsKey, nsDecls.DisplayName, cls, _cppResolver); + WriteTypePage(new CppTypePageWriteContext(factory, nsRegistryKey, nsDecls.DisplayName, cls, _cppResolver, _names)); + WriteNestedTypePages(factory, nsRegistryKey, nsDecls.DisplayName, cls, _cppResolver); } - // Partition free functions into regular functions and operator overloads; - // operator names such as operator+, operator-, and operator<< all sanitize - // to the same file name so all operators share a single operators.md page - // instead of producing individual colliding files + // Partition free functions into regular functions and operator overloads; all operator + // overloads share a single operators.md page instead of producing individual files var nsOperatorFunctions = nsDecls.FreeFunctions .Where(fn => IsOperatorName(fn.Name)) .ToList(); - foreach (var fn in nsDecls.FreeFunctions - .Where(fn => !IsOperatorName(fn.Name))) + + // Overloads of one function share one page named after the function + foreach (var overloads in nsDecls.FreeFunctions + .Where(fn => !IsOperatorName(fn.Name)) + .GroupBy(fn => fn.Name, StringComparer.Ordinal)) { - WriteFreeFunctionPage(factory, nsKey, nsDecls.DisplayName, fn, _cppResolver); + WriteFreeFunctionPage(factory, nsRegistryKey, nsDecls.DisplayName, overloads.ToList(), _cppResolver); } if (nsOperatorFunctions.Count > 0) { - WriteNamespaceOperatorsPage(factory, nsKey, nsDecls.DisplayName, nsOperatorFunctions, _cppResolver); + WriteNamespaceOperatorsPage(factory, nsRegistryKey, nsDecls.DisplayName, nsOperatorFunctions, _cppResolver); } // Write one enum detail page per owned enum declared in this namespace foreach (var en in nsDecls.Enums) { - WriteEnumPage(factory, nsKey, nsDecls.DisplayName, en); + WriteEnumPage(factory, nsRegistryKey, nsDecls.DisplayName, en); } // Write one type alias page per owned using-alias declared in this namespace foreach (var alias in nsDecls.TypeAliases) { - WriteTypeAliasPage(factory, nsKey, nsDecls.DisplayName, alias, _cppResolver); + WriteTypeAliasPage(factory, nsRegistryKey, nsDecls.DisplayName, alias, _cppResolver); } } } @@ -171,13 +178,13 @@ private void WriteApiPage( var nsDecls = kv.Value; var declarationCount = nsDecls.Classes.Count + nsDecls.Enums.Count + nsDecls.FreeFunctions.Count + nsDecls.TypeAliases.Count; var description = CppEmitter.GetNamespaceDescription(nsDecls); - return new[] { $"[{nsDecls.DisplayName}]({CppEmitter.GetNamespacePageName(kv.Key)}.md)", declarationCount.ToString(), description }; + return new[] { $"[{nsDecls.DisplayName}]({_names.Link(string.Empty, CppNames.NamespaceKey(kv.Key))})", declarationCount.ToString(), description }; }); writer.WriteTable(headers, rows); // Path convention appendix — helps AI agents navigate without a separate resolver writer.WriteHeading(2, "File Naming and Path Convention"); - writer.WriteParagraph("Documentation paths are derived deterministically from fully-qualified symbol names. Namespace separators (`::`) are replaced with `.` in file and folder names."); + writer.WriteParagraph("Documentation paths are derived deterministically from fully-qualified symbol names. Namespace separators (`::`) are replaced with `.` in file and folder names. When two symbols in one folder would share a name (ignoring case), the first keeps the plain name and the other receives a numeric suffix such as `-2`."); writer.WriteTable(PathConventionHeaders, PathConventionRows); } @@ -200,7 +207,7 @@ private void WriteTypePage(CppTypePageWriteContext ctx) ? displayName : $"{ctx.NsDisplayName}::{displayName}"; - using var writer = ctx.Factory.CreateMarkdown(CppEmitter.SanitizePath(ctx.NsKey), CppEmitter.SanitizeFileName(ctx.Class.Name)); + using var writer = ctx.Factory.CreateMarkdown(ctx.PageDirectory, _names.Name(ctx.ClassKey)); writer.WriteHeading(1, displayName); WriteClassSignatureBlock(writer, ctx.Class, qualifiedClassName); @@ -256,22 +263,21 @@ private void WriteTypePage(CppTypePageWriteContext ctx) .Where(m => !IsOperatorName(m.Name)) .ToList(); - // Build a flat list of all visible members for case-insensitive collision detection + // Group the visible members by exact name: overloads share one page, while names that differ + // only in case get separate pages whose file names the registry has already made unique var allMembers = new List( visibleCtors.Cast() .Concat(regularMethods.Cast()) .Concat(visibleFields.Cast())); - // Case-insensitive map: lowercase member name → list of members sharing that lowercase name - var caseInsensitiveGroups = new Dictionary>(StringComparer.Ordinal); + var memberGroups = new Dictionary>(StringComparer.Ordinal); foreach (var member in allMembers) { var baseName = CppEmitter.GetMemberBaseName(member, ctx.Class.Name); - var lowerKey = baseName.ToLowerInvariant(); - if (!caseInsensitiveGroups.TryGetValue(lowerKey, out var list)) + if (!memberGroups.TryGetValue(baseName, out var list)) { list = []; - caseInsensitiveGroups[lowerKey] = list; + memberGroups[baseName] = list; } list.Add(member); @@ -284,17 +290,17 @@ private void WriteTypePage(CppTypePageWriteContext ctx) foreach (var ctor in visibleCtors) { - ProcessClassConstructorMember(ctx, ctor, caseInsensitiveGroups, writtenKeys, ctorRows); + ProcessClassConstructorMember(ctx, ctor, memberGroups, writtenKeys, ctorRows); } foreach (var method in regularMethods) { - ProcessClassMethodMember(ctx, method, caseInsensitiveGroups, writtenKeys, methodRows, externalTypes); + ProcessClassMethodMember(ctx, method, memberGroups, writtenKeys, methodRows, externalTypes); } foreach (var field in visibleFields) { - ProcessClassFieldMember(ctx, field, caseInsensitiveGroups, writtenKeys, fieldRows, externalTypes); + ProcessClassFieldMember(ctx, field, memberGroups, writtenKeys, fieldRows, externalTypes); } WriteClassMemberTables(writer, ctx, operatorMethods, ctorRows, methodRows, fieldRows, externalTypes); @@ -362,35 +368,45 @@ private static void WriteClassBaseTypesParagraph(IMarkdownWriter writer, CppClas private static void ProcessClassConstructorMember( CppTypePageWriteContext ctx, CppFunction ctor, - Dictionary> caseInsensitiveGroups, + Dictionary> memberGroups, HashSet writtenKeys, List ctorRows) { var baseName = CppEmitter.GetMemberBaseName(ctor, ctx.Class.Name); - var lowerKey = baseName.ToLowerInvariant(); - var group = caseInsensitiveGroups[lowerKey]; - var pageKey = group.Count == 1 ? baseName : lowerKey; - var pageFileName = CppEmitter.SanitizeFileName(pageKey); + var memberKey = WriteMemberGroupPage(ctx, baseName, memberGroups[baseName], writtenKeys); var ctorSummary = CppEmitter.GetSummary(ctor.Doc) ?? CppEmitter.NoDescriptionPlaceholder; // Show simplified parameter types in the link text so readers can // distinguish overloaded constructors at a glance var paramTypes = string.Join(", ", ctor.Parameters.Select(p => CppEmitter.SimplifyTypeName(p.TypeName))); - if (writtenKeys.Add(lowerKey)) + ctorRows.Add(new[] { $"[{ctx.Class.Name}({paramTypes})]({ctx.Link(memberKey)})", ctorSummary }); + } + + /// + /// Writes the page of a member name once, as a single-member page or, when overloads share + /// the name, as a combined page, and returns the registry key of that page. + /// + private static string WriteMemberGroupPage( + CppTypePageWriteContext ctx, + string baseName, + List group, + HashSet writtenKeys) + { + var memberKey = CppNames.MemberKey(ctx.ClassKey, baseName); + if (writtenKeys.Add(baseName)) { - // The page writers receive the raw key and sanitize it exactly once if (group.Count == 1) { - WriteMemberPage(ctx.Factory, ctx.NsKey, ctx.NsDisplayName, ctx.Class, ctor, pageKey, ctx.CppResolver); + WriteMemberPage(ctx.Factory, ctx.Names, memberKey, ctx.NsDisplayName, ctx.Class, group[0], ctx.CppResolver); } else { - CppEmitter.WriteCombinedMemberPage(ctx.Factory, ctx.NsKey, ctx.NsDisplayName, ctx.Class, pageKey, group, ctx.CppResolver); + CppEmitter.WriteCombinedMemberPage(ctx.Factory, ctx.Names, ctx.ClassKey, ctx.NsDisplayName, ctx.Class, group, ctx.CppResolver); } } - ctorRows.Add(new[] { $"[{ctx.Class.Name}({paramTypes})]({CppEmitter.SanitizeFileName(ctx.Class.Name)}/{pageFileName}.md)", ctorSummary }); + return memberKey; } /// @@ -400,37 +416,22 @@ private static void ProcessClassConstructorMember( private static void ProcessClassMethodMember( CppTypePageWriteContext ctx, CppFunction method, - Dictionary> caseInsensitiveGroups, + Dictionary> memberGroups, HashSet writtenKeys, List methodRows, SortedSet externalTypes) { var baseName = CppEmitter.GetMemberBaseName(method, ctx.Class.Name); - var lowerKey = baseName.ToLowerInvariant(); - var group = caseInsensitiveGroups[lowerKey]; - var pageKey = group.Count == 1 ? baseName : lowerKey; - var pageFileName = CppEmitter.SanitizeFileName(pageKey); + var memberKey = WriteMemberGroupPage(ctx, baseName, memberGroups[baseName], writtenKeys); var methodSummary = CppEmitter.GetSummary(method.Doc) ?? CppEmitter.NoDescriptionPlaceholder; - // Linkify the return type cell using the type link resolver (folder of the type page, already sanitized) - var returnType = ctx.CppResolver.Linkify(CppEmitter.SimplifyTypeName(method.ReturnTypeName), CppEmitter.SanitizePath(ctx.NsKey), externalTypes)!; - - if (writtenKeys.Add(lowerKey)) - { - if (group.Count == 1) - { - WriteMemberPage(ctx.Factory, ctx.NsKey, ctx.NsDisplayName, ctx.Class, method, pageKey, ctx.CppResolver); - } - else - { - CppEmitter.WriteCombinedMemberPage(ctx.Factory, ctx.NsKey, ctx.NsDisplayName, ctx.Class, pageKey, group, ctx.CppResolver); - } - } + // Linkify the return type cell using the type link resolver (folder of the type page) + var returnType = ctx.CppResolver.Linkify(CppEmitter.SimplifyTypeName(method.ReturnTypeName), ctx.PageDirectory, externalTypes)!; // Show simplified parameter types in the link text so readers can // distinguish overloaded methods at a glance var methodParamTypes = string.Join(", ", method.Parameters.Select(p => CppEmitter.SimplifyTypeName(p.TypeName))); - methodRows.Add(new[] { $"[{method.Name}({methodParamTypes})]({CppEmitter.SanitizeFileName(ctx.Class.Name)}/{pageFileName}.md)", returnType, methodSummary }); + methodRows.Add(new[] { $"[{method.Name}({methodParamTypes})]({ctx.Link(memberKey)})", returnType, methodSummary }); } /// @@ -440,34 +441,19 @@ private static void ProcessClassMethodMember( private static void ProcessClassFieldMember( CppTypePageWriteContext ctx, CppField field, - Dictionary> caseInsensitiveGroups, + Dictionary> memberGroups, HashSet writtenKeys, List fieldRows, SortedSet externalTypes) { var baseName = CppEmitter.GetMemberBaseName(field, ctx.Class.Name); - var lowerKey = baseName.ToLowerInvariant(); - var group = caseInsensitiveGroups[lowerKey]; - var pageKey = group.Count == 1 ? baseName : lowerKey; - var pageFileName = CppEmitter.SanitizeFileName(pageKey); + var memberKey = WriteMemberGroupPage(ctx, baseName, memberGroups[baseName], writtenKeys); var fieldSummary = CppEmitter.GetSummary(field.Doc) ?? CppEmitter.NoDescriptionPlaceholder; - // Linkify the field type cell using the type link resolver (folder of the type page, already sanitized) - var typeName = ctx.CppResolver.Linkify(CppEmitter.SimplifyTypeName(field.TypeName), CppEmitter.SanitizePath(ctx.NsKey), externalTypes)!; + // Linkify the field type cell using the type link resolver (folder of the type page) + var typeName = ctx.CppResolver.Linkify(CppEmitter.SimplifyTypeName(field.TypeName), ctx.PageDirectory, externalTypes)!; - if (writtenKeys.Add(lowerKey)) - { - if (group.Count == 1) - { - WriteMemberPage(ctx.Factory, ctx.NsKey, ctx.NsDisplayName, ctx.Class, field, pageKey, ctx.CppResolver); - } - else - { - CppEmitter.WriteCombinedMemberPage(ctx.Factory, ctx.NsKey, ctx.NsDisplayName, ctx.Class, pageKey, group, ctx.CppResolver); - } - } - - fieldRows.Add(new[] { $"[{field.Name}]({CppEmitter.SanitizeFileName(ctx.Class.Name)}/{pageFileName}.md)", typeName, fieldSummary }); + fieldRows.Add(new[] { $"[{field.Name}]({ctx.Link(memberKey)})", typeName, fieldSummary }); } /// @@ -506,11 +492,11 @@ private void WriteClassMemberTables( // a single page to prevent file-name collisions between operator+, operator-, etc. if (operatorMethods.Count > 0) { - WriteClassOperatorsPage(ctx.Factory, ctx.NsKey, ctx.NsDisplayName, ctx.Class, operatorMethods, ctx.CppResolver); + WriteClassOperatorsPage(ctx, operatorMethods); writer.WriteHeading(2, OperatorsLabel); writer.WriteTable( new[] { OperatorsLabel, CppEmitter.DescriptionColumnHeader }, - new[] { new[] { $"[operators]({CppEmitter.SanitizeFileName(ctx.Class.Name)}/operators.md)", "Operator overloads" } }); + new[] { new[] { $"[operators]({ctx.Link(CppNames.OperatorsKey(ctx.ClassKey))})", "Operator overloads" } }); } // Emit Nested Classes table so readers can discover inner types without browsing the header @@ -523,7 +509,7 @@ private void WriteClassMemberTables( .Select(nested => { var summary = CppEmitter.GetSummary(nested.Doc) ?? CppEmitter.NoDescriptionPlaceholder; - return new[] { $"[{nested.Name}]({CppEmitter.SanitizeFileName(ctx.Class.Name)}/{CppEmitter.SanitizeFileName(nested.Name)}.md)", summary }; + return new[] { $"[{nested.Name}]({ctx.Link(CppNames.ClassKey(ctx.ClassKey, nested.Name))})", summary }; }); writer.WriteTable(nestedHeaders, nestedRows); } @@ -538,8 +524,8 @@ private void WriteClassMemberTables( .Select(alias => { var summary = CppEmitter.GetSummary(alias.Doc) ?? CppEmitter.NoDescriptionPlaceholder; - var underlying = ctx.CppResolver.Linkify(CppEmitter.SimplifyTypeName(alias.UnderlyingTypeName), CppEmitter.SanitizePath(ctx.NsKey), externalTypes)!; - return new[] { $"[{alias.Name}]({CppEmitter.SanitizeFileName(ctx.Class.Name)}/{CppEmitter.SanitizeFileName(alias.Name)}.md)", underlying, summary }; + var underlying = ctx.CppResolver.Linkify(CppEmitter.SimplifyTypeName(alias.UnderlyingTypeName), ctx.PageDirectory, externalTypes)!; + return new[] { $"[{alias.Name}]({ctx.Link(CppNames.AliasKey(ctx.ClassKey, alias.Name))})", underlying, summary }; }); writer.WriteTable(aliasHeaders, aliasRows); } @@ -557,8 +543,8 @@ private void WriteNestedTypePages( CppClass cls, CppTypeLinkResolver cppResolver) { - // The class folder and display name form the base for all children of this class - var clsKey = $"{parentKey}/{cls.Name}"; + // The class key and display name form the base for all children of this class + var clsKey = CppNames.ClassKey(parentKey, cls.Name); var clsDisplayName = $"{parentDisplayName}::{cls.Name}"; // Write one type alias page per public class-scoped using-alias @@ -570,7 +556,7 @@ private void WriteNestedTypePages( // Write one type page per public nested class and recurse into its own children foreach (var nested in cls.NestedClasses) { - WriteTypePage(new CppTypePageWriteContext(factory, clsKey, clsDisplayName, nested, cppResolver)); + WriteTypePage(new CppTypePageWriteContext(factory, clsKey, clsDisplayName, nested, cppResolver, _names)); WriteNestedTypePages(factory, clsKey, clsDisplayName, nested, cppResolver); } } @@ -579,15 +565,14 @@ private void WriteNestedTypePages( /// Writes the combined operator overloads page for a class. /// private void WriteClassOperatorsPage( - IMarkdownWriterFactory factory, - string nsKey, - string nsDisplayName, - CppClass cls, - IReadOnlyList operators, - CppTypeLinkResolver cppResolver) + CppTypePageWriteContext ctx, + IReadOnlyList operators) { - var opsCurrentFolder = CppEmitter.SanitizePath($"{nsKey}/{cls.Name}"); - using var writer = factory.CreateMarkdown(opsCurrentFolder, "operators"); + var cls = ctx.Class; + var nsDisplayName = ctx.NsDisplayName; + var opsKey = CppNames.OperatorsKey(ctx.ClassKey); + var opsCurrentFolder = _names.Directory(opsKey); + using var writer = ctx.Factory.CreateMarkdown(opsCurrentFolder, _names.Name(opsKey)); writer.WriteHeading(1, OperatorsLabel); // Emit the qualified class name comment and #include directive from the first operator // that has source location information — gives readers context without browsing headers @@ -610,7 +595,7 @@ private void WriteClassOperatorsPage( { var paramTypes = string.Join(", ", op.Parameters.Select(p => CppEmitter.SimplifyTypeName(p.TypeName))); writer.WriteHeading(2, $"{op.Name}({paramTypes})"); - WriteFunctionContent(writer, op, new CppFunctionWriteContext(nsDisplayName, cls.Name, cppResolver, opsCurrentFolder, externalTypes, 3)); + WriteFunctionContent(writer, op, new CppFunctionWriteContext(nsDisplayName, cls.Name, ctx.CppResolver, opsCurrentFolder, externalTypes, 3)); } CppEmitter.WriteExternalTypesSection(writer, externalTypes); @@ -621,13 +606,14 @@ private void WriteClassOperatorsPage( /// private void WriteNamespaceOperatorsPage( IMarkdownWriterFactory factory, - string nsKey, + string nsRegistryKey, string nsDisplayName, IReadOnlyList operators, CppTypeLinkResolver cppResolver) { - var opsCurrentFolder = CppEmitter.SanitizePath(nsKey); - using var writer = factory.CreateMarkdown(opsCurrentFolder, "operators"); + var opsKey = CppNames.OperatorsKey(nsRegistryKey); + var opsCurrentFolder = _names.Directory(opsKey); + using var writer = factory.CreateMarkdown(opsCurrentFolder, _names.Name(opsKey)); writer.WriteHeading(1, OperatorsLabel); // Emit the qualified name comment and #include directive from the first operator that // has source location information so readers know which header to include @@ -662,16 +648,31 @@ private void WriteNamespaceOperatorsPage( /// private void WriteFreeFunctionPage( IMarkdownWriterFactory factory, - string nsKey, + string nsRegistryKey, string nsDisplayName, - CppFunction fn, + IReadOnlyList overloads, CppTypeLinkResolver cppResolver) { - var fnCurrentFolder = CppEmitter.SanitizePath(nsKey); - using var writer = factory.CreateMarkdown(fnCurrentFolder, CppEmitter.SanitizeFileName(fn.Name)); - writer.WriteHeading(1, fn.Name); + var fnKey = CppNames.FunctionKey(nsRegistryKey, overloads[0].Name); + var fnCurrentFolder = _names.Directory(fnKey); + using var writer = factory.CreateMarkdown(fnCurrentFolder, _names.Name(fnKey)); + writer.WriteHeading(1, overloads[0].Name); var externalTypes = new SortedSet(); - WriteFreeFunctionContent(writer, nsDisplayName, fn, cppResolver, fnCurrentFolder, externalTypes); + if (overloads.Count == 1) + { + WriteFreeFunctionContent(writer, nsDisplayName, overloads[0], cppResolver, fnCurrentFolder, externalTypes); + } + else + { + // Overloads share one page; each gets its own section + foreach (var fn in overloads) + { + var paramTypes = string.Join(", ", fn.Parameters.Select(p => CppEmitter.SimplifyTypeName(p.TypeName))); + writer.WriteHeading(2, $"{fn.Name}({paramTypes})"); + WriteFreeFunctionContent(writer, nsDisplayName, fn, cppResolver, fnCurrentFolder, externalTypes, parametersHeadingLevel: 3); + } + } + CppEmitter.WriteExternalTypesSection(writer, externalTypes); } @@ -759,15 +760,15 @@ private void WriteFreeFunctionContent( /// private static void WriteMemberPage( IMarkdownWriterFactory factory, - string nsKey, + CppNames names, + string memberKey, string nsDisplayName, CppClass cls, object member, - string fileName, CppTypeLinkResolver cppResolver) { - var memberCurrentFolder = CppEmitter.SanitizePath($"{nsKey}/{cls.Name}"); - using var memberWriter = factory.CreateMarkdown(memberCurrentFolder, CppEmitter.SanitizeFileName(fileName)); + var memberCurrentFolder = names.Directory(memberKey); + using var memberWriter = factory.CreateMarkdown(memberCurrentFolder, names.Name(memberKey)); var externalTypes = new SortedSet(); @@ -938,11 +939,12 @@ internal static void WriteFieldContent( /// private void WriteEnumPage( IMarkdownWriterFactory factory, - string nsKey, + string parentKey, string nsDisplayName, CppEnum cppEnum) { - using var writer = factory.CreateMarkdown(CppEmitter.SanitizePath(nsKey), CppEmitter.SanitizeFileName(cppEnum.Name)); + var enumKey = CppNames.EnumKey(parentKey, cppEnum.Name); + using var writer = factory.CreateMarkdown(_names.Directory(enumKey), _names.Name(enumKey)); writer.WriteHeading(1, cppEnum.Name); // Emit the fully-qualified name comment and optional #include directive so readers @@ -1005,12 +1007,14 @@ private void WriteEnumPage( /// private void WriteTypeAliasPage( IMarkdownWriterFactory factory, - string nsKey, + string parentKey, string nsDisplayName, CppTypeAlias alias, CppTypeLinkResolver cppResolver) { - using var writer = factory.CreateMarkdown(CppEmitter.SanitizePath(nsKey), CppEmitter.SanitizeFileName(alias.Name)); + var aliasKey = CppNames.AliasKey(parentKey, alias.Name); + var aliasFolder = _names.Directory(aliasKey); + using var writer = factory.CreateMarkdown(aliasFolder, _names.Name(aliasKey)); writer.WriteHeading(1, alias.Name); // Accumulate external type references found in the underlying type on this page @@ -1059,7 +1063,7 @@ private void WriteTypeAliasPage( } // Resolve the underlying type to track any non-std external type references - cppResolver.Linkify(simplifiedUnderlying, CppEmitter.SanitizePath(nsKey), externalTypes); + cppResolver.Linkify(simplifiedUnderlying, aliasFolder, externalTypes); CppEmitter.WriteExternalTypesSection(writer, externalTypes); } @@ -1067,16 +1071,17 @@ private void WriteTypeAliasPage( /// Writes the summary page for a single namespace, listing its owned types, /// enums, and free functions. /// - private static void WriteNamespacePage( + private void WriteNamespacePage( IMarkdownWriterFactory factory, string nsKey, CppEmitter.NamespaceDeclarations nsDecls, CppTypeLinkResolver cppResolver) { - // The page itself sits at the root under its (api-safe) page name, while the types, enums, - // aliases, and functions it links to live in the namespace's sanitized folder - var safeNs = CppEmitter.SanitizePath(nsKey); - using var writer = factory.CreateMarkdown("", CppEmitter.GetNamespacePageName(nsKey)); + // The page sits at the root under its approved name; the types, enums, aliases, and + // functions it links to live in the namespace's approved folder + var nsRegistryKey = CppNames.NamespaceKey(nsKey); + using var writer = factory.CreateMarkdown(_names.Directory(nsRegistryKey), _names.Name(nsRegistryKey)); + var nsDir = _names.Directory(nsRegistryKey); writer.WriteHeading(1, $"{nsDecls.DisplayName} Namespace"); // Accumulate external type references found in type-column cells on this page @@ -1092,7 +1097,7 @@ private static void WriteNamespacePage( .Select(cls => { var summary = CppEmitter.GetSummary(cls.Doc) ?? CppEmitter.NoDescriptionPlaceholder; - return new[] { $"[{cls.Name}]({safeNs}/{CppEmitter.SanitizeFileName(cls.Name)}.md)", summary }; + return new[] { $"[{cls.Name}]({_names.Link(nsDir, CppNames.ClassKey(nsRegistryKey, cls.Name))})", summary }; }); writer.WriteTable(typeHeaders, typeRows); } @@ -1107,7 +1112,7 @@ private static void WriteNamespacePage( .Select(en => { var summary = CppEmitter.GetSummary(en.Doc) ?? CppEmitter.NoDescriptionPlaceholder; - return new[] { $"[{en.Name}]({safeNs}/{CppEmitter.SanitizeFileName(en.Name)}.md)", summary }; + return new[] { $"[{en.Name}]({_names.Link(nsDir, CppNames.EnumKey(nsRegistryKey, en.Name))})", summary }; }); writer.WriteTable(enumHeaders, enumRows); } @@ -1123,7 +1128,7 @@ private static void WriteNamespacePage( { var summary = CppEmitter.GetSummary(alias.Doc) ?? CppEmitter.NoDescriptionPlaceholder; var underlying = cppResolver.Linkify(CppEmitter.SimplifyTypeName(alias.UnderlyingTypeName), string.Empty, externalTypes)!; - return new[] { $"[{alias.Name}]({safeNs}/{CppEmitter.SanitizeFileName(alias.Name)}.md)", underlying, summary }; + return new[] { $"[{alias.Name}]({_names.Link(nsDir, CppNames.AliasKey(nsRegistryKey, alias.Name))})", underlying, summary }; }); writer.WriteTable(aliasHeaders, aliasRows); } @@ -1143,6 +1148,11 @@ private static void WriteNamespacePage( { writer.WriteHeading(2, "Functions"); var fnHeaders = new[] { "Function", ReturnsLabel, CppEmitter.DescriptionColumnHeader }; + var overloadedNames = regularFreeFunctions + .GroupBy(f => f.Name, StringComparer.Ordinal) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .ToHashSet(StringComparer.Ordinal); var fnRows = regularFreeFunctions .OrderBy(f => f.Name, StringComparer.Ordinal) .Select(fn => @@ -1152,8 +1162,10 @@ private static void WriteNamespacePage( // Linkify the return type cell; namespace page folder is "" (root) var returnType = cppResolver.Linkify( CppEmitter.SimplifyTypeName(fn.ReturnTypeName), string.Empty, externalTypes)!; - var safeName = CppEmitter.SanitizeFileName(fn.Name); - return new[] { $"[{fn.Name}]({safeNs}/{safeName}.md)", returnType, summary }; + var linkText = overloadedNames.Contains(fn.Name) + ? $"{fn.Name}({string.Join(", ", fn.Parameters.Select(p => CppEmitter.SimplifyTypeName(p.TypeName)))})" + : fn.Name; + return new[] { $"[{linkText}]({_names.Link(nsDir, CppNames.FunctionKey(nsRegistryKey, fn.Name))})", returnType, summary }; }); writer.WriteTable(fnHeaders, fnRows); } @@ -1165,7 +1177,7 @@ private static void WriteNamespacePage( writer.WriteHeading(2, OperatorsLabel); writer.WriteTable( new[] { OperatorsLabel, CppEmitter.DescriptionColumnHeader }, - new[] { new[] { $"[operators]({safeNs}/operators.md)", "Operator overloads" } }); + new[] { new[] { $"[operators]({_names.Link(nsDir, CppNames.OperatorsKey(nsRegistryKey))})", "Operator overloads" } }); } CppEmitter.WriteExternalTypesSection(writer, externalTypes); diff --git a/src/ApiMark.Cpp/CppGenerator.cs b/src/ApiMark.Cpp/CppGenerator.cs index 59c3e10..b22a88f 100644 --- a/src/ApiMark.Cpp/CppGenerator.cs +++ b/src/ApiMark.Cpp/CppGenerator.cs @@ -133,36 +133,17 @@ public IApiEmitter Parse(IContext context) } // Build the intra-library type map for link resolution. - // nsKey uses "." separators for file paths; display names use "::" for C++ qualified names. - // FlattenClass recursively registers a class, its scoped type aliases, and any nested - // classes so that fully-qualified names like "fixtures::Outer::Inner" resolve correctly. - static IEnumerable<(string Key, string Value)> FlattenClass(string nsDisplay, string nsPath, CppClass cls) - { - var clsDisplay = $"{nsDisplay}::{cls.Name}"; - var clsPath = $"{nsPath}/{cls.Name}"; - return new[] { (Key: clsDisplay, Value: clsPath) } - .Concat(cls.TypeAliases.Select(alias => - (Key: $"{clsDisplay}::{alias.Name}", Value: $"{clsPath}/{alias.Name}"))) - .Concat(cls.NestedClasses.SelectMany(nested => - FlattenClass(clsDisplay, clsPath, nested))); - } - - var knownTypes = namespaceDecls.SelectMany(kv => - { - var nsDisplay = kv.Key.Replace(".", "::", StringComparison.Ordinal); - var nsPath = kv.Key; // preserve dot-separated key to match CreateMarkdown page keys - return kv.Value.Classes.SelectMany(cls => FlattenClass(nsDisplay, nsPath, cls)) - .Concat(kv.Value.Enums.Select(enm => (Key: $"{nsDisplay}::{enm.Name}", Value: $"{nsPath}/{enm.Name}"))) - .Concat(kv.Value.TypeAliases.Select(a => (Key: $"{nsDisplay}::{a.Name}", Value: $"{nsPath}/{a.Name}"))); - }).ToDictionary(x => x.Key, x => x.Value, StringComparer.Ordinal); - var cppResolver = new CppTypeLinkResolver(knownTypes); + // Register and resolve every page name once; the emitters and the link resolver read only + // the approved names + var names = CppNames.Build(namespaceDecls); + var cppResolver = new CppTypeLinkResolver(names.BuildKnownTypes(namespaceDecls)); // Cache the parsed namespace declarations so CheckDocumentationCoverage can be // called after Parse returns. _namespaceDecls = namespaceDecls; - return new CppEmitter(normalizedOptions, namespaceDecls, cppResolver); + return new CppEmitter(normalizedOptions, namespaceDecls, cppResolver, names); } /// diff --git a/src/ApiMark.Cpp/CppNames.cs b/src/ApiMark.Cpp/CppNames.cs new file mode 100644 index 0000000..b6cb71c --- /dev/null +++ b/src/ApiMark.Cpp/CppNames.cs @@ -0,0 +1,261 @@ +using ApiMark.Core; +using ApiMark.Cpp.CppAst; + +namespace ApiMark.Cpp; + +/// +/// The approved file names of every symbol of a documented C++ library. +/// +/// +/// Every namespace, class (including nested classes), enum, type alias, free-function page, class-member +/// page and operators page is registered with a exactly once and +/// resolved up front, independent of the visibility filter, so a symbol has the same file name wherever +/// it is written or linked. The emitters and the link resolver only read names from this map; they never +/// sanitize a name themselves. Overloaded functions and constructors share one page named after the +/// function. A class, enum, alias and function of the same name compete for the namespace folder in this +/// order: class, enum, alias, function; the first keeps the plain name and the others receive a numeric +/// suffix (-2, -3). The rules are documented in docs/design/api-mark-core/file-naming-rules.md. +/// +internal sealed class CppNames +{ + /// Name of the reserved library index page. + internal const string ApiPageName = "api"; + + /// Name of the reserved per-namespace and per-class operators page. + internal const string OperatorsPageName = "operators"; + + /// The resolved registry. + private readonly SymbolNameRegistry _registry = new(); + + /// Initializes a new instance of the class. + private CppNames() + { + } + + /// Gets the key of the reserved library index page. + internal static string ApiKey => "R:" + ApiPageName; + + /// Gets the registry that holds the approved names. + internal SymbolNameRegistry Registry => _registry; + + /// Builds and resolves the names of every declaration in . + /// The namespace declarations of the library, by namespace key. + /// The resolved names. + internal static CppNames Build(IReadOnlyDictionary namespaces) + { + ArgumentNullException.ThrowIfNull(namespaces); + + var names = new CppNames(); + var registry = names._registry; + registry.Register(ApiKey, null, ApiPageName, SymbolKind.Reserved); + + foreach (var (nsKey, decls) in namespaces) + { + var key = NamespaceKey(nsKey); + registry.Register(key, null, nsKey, SymbolKind.Namespace); + registry.Register(OperatorsKey(key), key, OperatorsPageName, SymbolKind.Reserved); + + foreach (var cls in decls.Classes) + { + RegisterClass(registry, key, cls); + } + + foreach (var name in decls.Enums.Select(en => en.Name)) + { + RegisterOnce(registry, EnumKey(key, name), key, name, SymbolKind.Type); + } + + foreach (var name in decls.TypeAliases.Select(alias => alias.Name)) + { + RegisterOnce(registry, AliasKey(key, name), key, name, SymbolKind.Type); + } + + foreach (var name in decls.FreeFunctions.Where(f => !CppEmitter.IsOperatorName(f.Name)).Select(f => f.Name)) + { + RegisterOnce(registry, FunctionKey(key, name), key, name, SymbolKind.Type); + } + } + + registry.Resolve(); + return names; + } + + /// Gets the registry key of a namespace. + /// The namespace key used by the emitters. + /// The key. + internal static string NamespaceKey(string nsKey) => "N:" + nsKey; + + /// Gets the registry key of a class. + /// The key of the namespace or enclosing class. + /// The class name. + /// The key. + internal static string ClassKey(string parentKey, string name) => $"{parentKey}/T1:{name}"; + + /// Gets the registry key of an enum. + /// The registry key of the namespace. + /// The enum name. + /// The key. + internal static string EnumKey(string namespaceKey, string name) => $"{namespaceKey}/T2:{name}"; + + /// Gets the registry key of a type alias. + /// The key of the namespace or class that declares the alias. + /// The alias name. + /// The key. + internal static string AliasKey(string parentKey, string name) => $"{parentKey}/T3:{name}"; + + /// Gets the registry key of the page of a free function (shared by all overloads). + /// The registry key of the namespace. + /// The function name. + /// The key. + internal static string FunctionKey(string namespaceKey, string name) => $"{namespaceKey}/T4:{name}"; + + /// Gets the registry key of a class-member page (shared by all overloads of one name). + /// The registry key of the class. + /// The member name; constructors use the class name. + /// The key. + internal static string MemberKey(string classKey, string memberName) => $"{classKey}/M:{memberName}"; + + /// Gets the registry key of the operators page of a namespace or class. + /// The registry key of the namespace or class. + /// The key. + internal static string OperatorsKey(string ownerKey) => $"{ownerKey}/R:{OperatorsPageName}"; + + /// Gets the approved page path (forward slashes, no extension) of a symbol. + /// The registry key. + /// The path relative to the output root. + internal string Path(string key) => _registry.GetPath(key); + + /// Gets the approved directory that holds the page of a symbol. + /// The registry key. + /// The directory relative to the output root; empty for the root. + internal string Directory(string key) => _registry.GetDirectory(key); + + /// Gets the approved file name (without extension) of a symbol. + /// The registry key. + /// The file name. + internal string Name(string key) => _registry.GetName(key); + + /// Gets the relative link destination from the page directory of one symbol to another symbol's page. + /// The directory of the page that holds the link. + /// The registry key of the target symbol. + /// A forward-slash relative path ending in .md. + internal string Link(string fromDirectory, string targetKey) => + LinkPath.Relative(fromDirectory, _registry.GetPath(targetKey) + ".md"); + + /// + /// Builds the map from fully qualified C++ type names to approved page paths used by + /// . + /// + /// The namespace declarations of the library, by namespace key. + /// The known intra-library types. + internal Dictionary BuildKnownTypes(IReadOnlyDictionary namespaces) + { + var known = new Dictionary(StringComparer.Ordinal); + foreach (var (nsKey, decls) in namespaces) + { + var display = nsKey.Replace(".", "::", StringComparison.Ordinal); + var key = NamespaceKey(nsKey); + foreach (var cls in decls.Classes) + { + AddClass(known, display, key, cls); + } + + foreach (var name in decls.Enums.Select(en => en.Name)) + { + known.TryAdd($"{display}::{name}", Path(EnumKey(key, name))); + } + + foreach (var name in decls.TypeAliases.Select(alias => alias.Name)) + { + known.TryAdd($"{display}::{name}", Path(AliasKey(key, name))); + } + } + + return known; + } + + /// Adds a class, its scoped aliases and its nested classes to the known-type map. + /// The map to extend. + /// The qualified display name of the parent. + /// The registry key of the parent. + /// The class. + private void AddClass(Dictionary known, string parentDisplay, string parentKey, CppClass cls) + { + var display = $"{parentDisplay}::{TemplateFreeName(cls.Name)}"; + var key = ClassKey(parentKey, cls.Name); + known.TryAdd(display, Path(key)); + + foreach (var name in cls.TypeAliases.Select(alias => alias.Name)) + { + known.TryAdd($"{display}::{name}", Path(AliasKey(key, name))); + } + + foreach (var nested in cls.NestedClasses) + { + AddClass(known, display, key, nested); + } + } + + /// + /// Gets a type name without its template arguments, the form in which the link resolver looks + /// types up (it strips template arguments from the references it resolves). + /// + /// The declared type name. + /// The name before the first <. + private static string TemplateFreeName(string name) + { + var lt = name.IndexOf('<', StringComparison.Ordinal); + return lt >= 0 ? name[..lt] : name; + } + + /// Registers a symbol unless the same key was already registered (for example an overload). + /// The registry. + /// The symbol key. + /// The parent key. + /// The raw name; template angle brackets are written as braces. + /// The symbol kind. + private static void RegisterOnce(SymbolNameRegistry registry, string key, string parentKey, string rawName, SymbolKind kind) + { + if (!registry.Contains(key)) + { + registry.Register(key, parentKey, rawName.Replace('<', '{').Replace('>', '}'), kind); + } + } + + /// Registers a class, its operators page, its member pages, its aliases and its nested classes. + /// The registry. + /// The key of the namespace or enclosing class. + /// The class. + private static void RegisterClass(SymbolNameRegistry registry, string parentKey, CppClass cls) + { + var key = ClassKey(parentKey, cls.Name); + if (registry.Contains(key)) + { + return; + } + + RegisterOnce(registry, key, parentKey, cls.Name, SymbolKind.Type); + registry.Register(OperatorsKey(key), key, OperatorsPageName, SymbolKind.Reserved); + + foreach (var name in cls.TypeAliases.Select(alias => alias.Name)) + { + RegisterOnce(registry, AliasKey(key, name), key, name, SymbolKind.Type); + } + + foreach (var nested in cls.NestedClasses) + { + RegisterClass(registry, key, nested); + } + + foreach (var member in cls.Members.Where(m => m.IsConstructor || !CppEmitter.IsOperatorName(m.Name))) + { + var name = CppEmitter.GetMemberBaseName(member, cls.Name); + RegisterOnce(registry, MemberKey(key, name), key, name, SymbolKind.Member); + } + + foreach (var name in cls.Fields.Select(field => field.Name)) + { + RegisterOnce(registry, MemberKey(key, name), key, name, SymbolKind.Member); + } + } +} diff --git a/src/ApiMark.Cpp/CppTypeLinkResolver.cs b/src/ApiMark.Cpp/CppTypeLinkResolver.cs index 65930ad..fbf33e0 100644 --- a/src/ApiMark.Cpp/CppTypeLinkResolver.cs +++ b/src/ApiMark.Cpp/CppTypeLinkResolver.cs @@ -1,6 +1,8 @@ // Copyright (c) DemaConsulting LLC. All rights reserved. // Licensed under the MIT License. +using ApiMark.Core; + namespace ApiMark.Cpp; /// @@ -87,8 +89,8 @@ public CppTypeLinkResolver(IReadOnlyDictionary knownTypes) /// /// The folder path of the Markdown file that will contain the link, relative to the /// documentation output root (e.g. "fixtures/SampleClass"), exactly as the emitter - /// writes it: it must already be sanitized (see ) and is - /// used as is, never sanitized again. The page key of the linked type is sanitized here, once. + /// writes it: it must already be an approved registry path and is used as is. + /// The linked type's page key is likewise an approved path, so nothing is mapped here. /// Used to compute relative path hrefs. Pass an empty string or /// for root-level files; a value is treated as an empty string. /// @@ -166,10 +168,8 @@ public CppTypeLinkResolver(IReadOnlyDictionary knownTypes) /// The type string with the base type token replaced by a Markdown link. private static string BuildIntraLibraryLink(string cppTypeString, string currentFolder, string stripped, string pageKey) { - // Sanitize the page key per segment exactly once; the current folder is already an output folder - // (sanitized by the emitter) and is used as is, so it is never mapped a second time - var from = currentFolder.Length > 0 ? currentFolder : "."; - var relativePath = Path.GetRelativePath(from, CppEmitter.SanitizePath(pageKey) + ".md").Replace('\\', '/'); + // Both the current folder and the page key are approved registry paths; no mapping happens here + var relativePath = LinkPath.Relative(currentFolder, pageKey + ".md"); var shortName = stripped.Contains("::", StringComparison.Ordinal) ? stripped[(stripped.LastIndexOf("::", StringComparison.Ordinal) + 2)..] : stripped; diff --git a/test/ApiMark.Cpp.Tests/CppEmitterGradualDisclosureTests.cs b/test/ApiMark.Cpp.Tests/CppEmitterGradualDisclosureTests.cs index 9ef52cf..39c3094 100644 --- a/test/ApiMark.Cpp.Tests/CppEmitterGradualDisclosureTests.cs +++ b/test/ApiMark.Cpp.Tests/CppEmitterGradualDisclosureTests.cs @@ -246,9 +246,9 @@ public void CppEmitterGradualDisclosure_Emit_NestedClass_CreatesNestedClassPage( Assert.True(factory.HasWriter("testlib/Widget", "Nested")); } - /// Validates that colliding member names are merged onto a single lowercase page. + /// Validates that members differing only by case get distinct pages; the first in ordinal order keeps the plain name. [Fact] - public void CppEmitterGradualDisclosure_Emit_CaseInsensitiveCollision_CreatesCombinedPage() + public void CppEmitterGradualDisclosure_Emit_CaseTwinMembers_GetDistinctPages() { // Arrange var factory = new InMemoryMarkdownWriterFactory(); @@ -258,8 +258,9 @@ public void CppEmitterGradualDisclosure_Emit_CaseInsensitiveCollision_CreatesCom new CppEmitterGradualDisclosure(emitter, nsDecls, resolver).Emit(factory, new EmitConfig(), new InMemoryContext()); // Assert - Assert.True(factory.HasWriter("testlib/CaseCollisionClass", "name")); - Assert.False(factory.HasWriter("testlib/CaseCollisionClass", "Name")); + Assert.True(factory.HasWriter("testlib/CaseCollisionClass", "Name")); + Assert.True(factory.HasWriter("testlib/CaseCollisionClass", "name-2")); + Assert.False(factory.HasWriter("testlib/CaseCollisionClass", "name")); } /// @@ -437,13 +438,7 @@ private static (CppEmitter emitter, SortedDictionary(StringComparer.Ordinal) - { - { "Aux", "testlib/Aux" }, - { "testlib::Aux", "testlib/Aux" }, - { "testlib::Foo", "testlib/Foo" }, - { "testlib::Foo::Aux", "testlib/Foo/Aux" }, - }); + var resolver = new CppTypeLinkResolver(CppNames.Build(nsDecls).BuildKnownTypes(nsDecls)); return (new CppEmitter(options, nsDecls, resolver), nsDecls, resolver); } @@ -470,30 +465,32 @@ public void CppEmitterGradualDisclosure_Emit_IllegalAndReservedNames_AllWrittenS { foreach (var segment in key.Split('/')) { - Assert.True(FileNameSanitizer.IsLegalSegment(segment), $"Illegal segment '{segment}' in '{key}'"); + Assert.True(FileNameRules.IsLegalSegment(segment), $"Illegal segment '{segment}' in '{key}'"); } } } - /// Validates that template, reserved, and namespace pages are created at their sanitized paths. + /// Validates that template, reserved, and namespace pages are created at their approved paths. [Fact] - public void CppEmitterGradualDisclosure_Emit_IllegalAndReservedNames_PagesUseSanitizedPaths() + public void CppEmitterGradualDisclosure_Emit_IllegalAndReservedNames_PagesUseApprovedPaths() { // Arrange / Act var factory = EmitIllegalNames(); // Assert Assert.True(factory.HasWriter("testlib", "Foo{int}")); - Assert.True(factory.HasWriter("testlib", "Aux-")); - Assert.True(factory.HasWriter("testlib", "CON-")); + Assert.True(factory.HasWriter("testlib", "Aux_")); + Assert.True(factory.HasWriter("testlib", "CON_")); + Assert.True(factory.HasWriter("testlib", "a_b")); Assert.True(factory.HasWriter("testlib/Foo{int}", "Get")); - Assert.True(factory.HasWriter("testlib/Foo{int}", "name")); + Assert.True(factory.HasWriter("testlib/Foo{int}", "Name")); + Assert.True(factory.HasWriter("testlib/Foo{int}", "name-2")); + Assert.False(factory.HasWriter("testlib/Foo{int}", "name")); Assert.True(factory.HasWriter("testlib/Foo{int}", "operators")); - Assert.True(factory.HasWriter("testlib/Foo{int}", "Aux-")); - Assert.True(factory.HasWriter("", "aux-")); - Assert.True(factory.HasWriter("aux-", "Plain")); - Assert.True(factory.HasWriter("testlib", CppEmitter.SanitizeFileName("a:b"))); - Assert.True(factory.HasWriter("testlib/Foo{int}", CppEmitter.SanitizeFileName("a:b"))); + Assert.True(factory.HasWriter("testlib/Foo{int}", "Aux_")); + Assert.True(factory.HasWriter("testlib/Foo{int}", "a_b")); + Assert.True(factory.HasWriter("", "aux_")); + Assert.True(factory.HasWriter("aux_", "Plain")); } /// Validates that every relative link emitted for illegal names resolves to a page that was written. @@ -562,23 +559,17 @@ private static (CppEmitter emitter, SortedDictionary", - [Fn("Self", "Foo"), Fn("MakeInner", "Inner"), Fn("Use", "int", new CppParameter("a", "Aux"), new CppParameter("c", "CON"))], + [Fn("Self", "a::b::Foo"), Fn("MakeInner", "Inner"), Fn("Use", "int", new CppParameter("a", "a::b::Aux"), new CppParameter("c", "a::b::CON"))], null, - [Cls("Aux")]); + [Cls("Inner")]); var oddNs = new CppEmitter.NamespaceDeclarations("a::b", null); oddNs.Classes.Add(generic); oddNs.Classes.Add(Cls("Aux")); oddNs.Enums.Add(new CppEnum("CON", [], false, null, null)); - oddNs.Classes.Add(Cls("Sibling", [Fn("Back", "Foo"), Fn("Other", "Aux")])); + oddNs.Classes.Add(Cls("Sibling", [Fn("Back", "a::b::Foo"), Fn("Other", "a::b::Aux")])); nsDecls["a::b"] = oddNs; - var resolver = new CppTypeLinkResolver(new Dictionary(StringComparer.Ordinal) - { - { "Foo", "a::b/Foo" }, - { "Inner", "a::b/Foo/Aux" }, - { "Aux", "a::b/Aux" }, - { "CON", "a::b/CON" }, - }); + var resolver = new CppTypeLinkResolver(CppNames.Build(nsDecls).BuildKnownTypes(nsDecls)); return (new CppEmitter(options, nsDecls, resolver), nsDecls, resolver); } @@ -609,16 +600,17 @@ public void CppEmitterGradualDisclosure_Emit_MemberNamedOperators_DoesNotOverwri var factory = EmitReservedPages(); // Assert: the operators page keeps its operator content, and the member page is moved aside - var operatorsPage = factory.GetWriter("api/Holder", "operators"); + var operatorsPage = factory.GetWriter("api-2/Holder", "operators"); Assert.Contains(operatorsPage.Operations.OfType(), h => h.Level == 2 && h.Text.StartsWith("operator+", StringComparison.Ordinal)); - Assert.True(factory.HasWriter("api/Holder", "operators-")); + Assert.True(factory.HasWriter("api-2/Holder", "operators-2")); Assert.Contains( - factory.GetWriter("api", "Holder").Operations.OfType().SelectMany(t => t.Rows), - r => r[0].Contains("(Holder/operators-.md)", StringComparison.Ordinal)); + factory.GetWriter("api-2", "Holder").Operations.OfType().SelectMany(t => t.Rows), + r => r[0].Contains("(Holder/operators-2.md)", StringComparison.Ordinal)); - // A case-colliding combined page is moved aside too - Assert.True(factory.HasWriter("api/Cased", "operators-")); - Assert.False(factory.HasWriter("api/Cased", "operators")); + // A case twin of the reserved name is moved aside too + Assert.True(factory.HasWriter("api-2/Cased", "Operators-2")); + Assert.False(factory.HasWriter("api-2/Cased", "operators")); + Assert.False(factory.HasWriter("api-2/Cased", "Operators")); } /// Validates that declarations and namespaces named like reserved pages are moved aside consistently. @@ -629,43 +621,44 @@ public void CppEmitterGradualDisclosure_Emit_DeclarationsNamedLikeReservedPages_ var factory = EmitReservedPages(); // Assert: class, nested class, and free function named operators; namespace named api - Assert.True(factory.HasWriter("api", "operators-")); - Assert.True(factory.HasWriter("api", "operators")); + Assert.True(factory.HasWriter("api-2", "operators-2")); + Assert.True(factory.HasWriter("api-2", "operators")); Assert.Contains( - factory.GetWriter("api", "operators").Operations.OfType(), + factory.GetWriter("api-2", "operators").Operations.OfType(), h => h.Level == 2 && h.Text.StartsWith("operator<<", StringComparison.Ordinal)); - Assert.True(factory.HasWriter("api/Outer", "operators-")); - Assert.True(factory.HasWriter("api/Outer", "operators")); - Assert.True(factory.HasWriter("other", "operators-")); + Assert.True(factory.HasWriter("api-2/Outer", "operators-2")); + Assert.True(factory.HasWriter("api-2/Outer", "operators")); + Assert.True(factory.HasWriter("other", "operators-2")); Assert.True(factory.HasWriter("other", "operators")); - Assert.True(factory.HasWriter("", "api-")); + Assert.True(factory.HasWriter("", "api-2")); + Assert.True(factory.HasWriter("", "api")); Assert.Contains( factory.GetWriter("", "api").Operations.OfType().SelectMany(t => t.Rows), - r => r[0].Contains("(api-.md)", StringComparison.Ordinal)); + r => r[0].Contains("(api-2.md)", StringComparison.Ordinal)); } /// Validates that links from member pages of generic and odd-named types use plain relative paths to written pages. [Fact] - public void CppEmitterGradualDisclosure_Emit_GenericAndOddNames_MemberPageLinksUseSingleSanitization() + public void CppEmitterGradualDisclosure_Emit_GenericAndOddNames_MemberPageLinksUseApprovedPaths() { // Arrange / Act var factory = EmitReservedPages(); - var oddFolder = CppEmitter.SanitizePath("a::b"); - var genericFolder = $"{oddFolder}/Foo{{int}}"; + const string oddFolder = "a__b"; + const string genericFolder = "a__b/Foo{int}"; // Assert: from the generic member page, the owning type and nested type resolve without detours var self = factory.GetWriter(genericFolder, "Self").Operations.OfType().Select(p => p.Text); Assert.Contains(self, t => t.Contains("(../Foo{int}.md)", StringComparison.Ordinal)); var inner = factory.GetWriter(genericFolder, "MakeInner").Operations.OfType().Select(p => p.Text); - Assert.Contains(inner, t => t.Contains("(Aux-.md)", StringComparison.Ordinal)); + Assert.Contains(inner, t => t.Contains("(Inner.md)", StringComparison.Ordinal)); // Assert: from sibling and reserved-name pages, the sibling, reserved class, and enum resolve directly var sibling = factory.GetWriter(oddFolder, "Sibling").Operations.OfType().SelectMany(t => t.Rows).Select(r => r.Length > 1 ? r[1] : string.Empty); Assert.Contains(sibling, c => c.Contains("(Foo{int}.md)", StringComparison.Ordinal)); - Assert.Contains(sibling, c => c.Contains("(Aux-.md)", StringComparison.Ordinal)); + Assert.Contains(sibling, c => c.Contains("(Aux_.md)", StringComparison.Ordinal)); var use = factory.GetWriter(genericFolder, "Use").Operations.OfType().SelectMany(t => t.Rows).Select(r => r[1]); - Assert.Contains(use, c => c.Contains("(../Aux-.md)", StringComparison.Ordinal)); - Assert.Contains(use, c => c.Contains("(../CON-.md)", StringComparison.Ordinal)); + Assert.Contains(use, c => c.Contains("(../Aux_.md)", StringComparison.Ordinal)); + Assert.Contains(use, c => c.Contains("(../CON_.md)", StringComparison.Ordinal)); } /// Validates that every link in every table cell and paragraph resolves to a page the emitter wrote. diff --git a/test/ApiMark.Cpp.Tests/CppEmitterTests.cs b/test/ApiMark.Cpp.Tests/CppEmitterTests.cs index 9082174..8d3075a 100644 --- a/test/ApiMark.Cpp.Tests/CppEmitterTests.cs +++ b/test/ApiMark.Cpp.Tests/CppEmitterTests.cs @@ -76,15 +76,15 @@ public void CppEmitter_Emit_SingleFileFormat_ProducesSingleApiFile() Assert.Single(factory.Writers); } - /// Validates that leaves regular names unchanged. + /// Validates that CppNames leaves regular names unchanged. [Fact] - public void CppEmitter_SanitizeFileName_RegularName_IsUnchanged() + public void CppNames_RegularClassName_IsUnchanged() { // Arrange / Act - var result = CppEmitter.SanitizeFileName("MyClass"); + var (names, classKey) = BuildNames("ns", new CppClass("MyClass", [], [], [], [], [], [], false, false, null, null)); // Assert - Assert.Equal("MyClass", result); + Assert.Equal("MyClass", names.Name(classKey)); } @@ -156,9 +156,9 @@ public void CppEmitter_IsOperatorName_DistinguishesOperatorsFromIdentifiers(stri Assert.Equal(expected, CppEmitter.IsOperatorName(name)); } - /// Validates that colliding member names are merged onto a single combined page keyed by the lowercase name. + /// Validates that overloads sharing a name are merged onto one combined page named after the member. [Fact] - public void CppEmitter_WriteCombinedMemberPage_CaseInsensitiveCollision_ProducesSingleCombinedPage() + public void CppEmitter_WriteCombinedMemberPage_Overloads_ProducesSingleCombinedPage() { // Arrange var factory = new InMemoryMarkdownWriterFactory(); @@ -169,90 +169,38 @@ public void CppEmitter_WriteCombinedMemberPage_CaseInsensitiveCollision_Produces [], [ new CppFunction("Name", "int", [], CppAccessibility.Public, false, false, false, false, false, false, null, null), - ], - [ - new CppField("name", "int", CppAccessibility.Public, false, false, null, null), + new CppFunction("Name", "int", [new CppParameter("value", "int")], CppAccessibility.Public, false, false, false, false, false, false, null, null), ], [], [], + [], false, false, null, null); + var (names, classKey) = BuildNames("fixtures", cls); // Act - CppEmitter.WriteCombinedMemberPage( - factory, - "fixtures", - "fixtures", - cls, - "name", - [cls.Members[0], cls.Fields[0]], - resolver); + CppEmitter.WriteCombinedMemberPage(factory, names, classKey, "fixtures", cls, [cls.Members[0], cls.Members[1]], resolver); // Assert - Assert.True(factory.HasWriter("fixtures/CaseCollisionClass", "name")); - Assert.False(factory.HasWriter("fixtures/CaseCollisionClass", "Name")); - var writer = factory.GetWriter("fixtures/CaseCollisionClass", "name"); + Assert.True(factory.HasWriter("fixtures/CaseCollisionClass", "Name")); + var writer = factory.GetWriter("fixtures/CaseCollisionClass", "Name"); var headings = writer.Operations.OfType().ToList(); - Assert.Contains(headings, h => h.Level == 2 && h.Text.StartsWith("Name", StringComparison.Ordinal)); - Assert.Contains(headings, h => h.Level == 2 && h.Text.StartsWith("name", StringComparison.Ordinal)); - } - - /// Validates that illegal file-name characters are replaced deterministically on every platform. - [Fact] - public void CppEmitter_SanitizeFileName_InvalidCharacters_AreReplacedWithLegalCharacters() - { - // Arrange / Act - var result = CppEmitter.SanitizeFileName("operator/"); - - // Assert - Assert.StartsWith("operator-", result, StringComparison.Ordinal); - Assert.True(FileNameSanitizer.IsLegalSegment(result)); - Assert.Equal(result, CppEmitter.SanitizeFileName("operator/")); - } - - /// Validates that template, qualified, and reserved C++ names become legal segments. - /// The raw declaration name. - [Theory] - [InlineData("Foo")] - [InlineData("a::b")] - [InlineData("CON")] - public void CppEmitter_SanitizeFileName_TemplateQualifiedAndReservedNames_AreLegal(string name) - { - // Arrange / Act - var result = CppEmitter.SanitizeFileName(name); - - // Assert - Assert.True(FileNameSanitizer.IsLegalSegment(result)); + Assert.Contains(headings, h => h.Level == 2 && h.Text.StartsWith("Name()", StringComparison.Ordinal)); + Assert.Contains(headings, h => h.Level == 2 && h.Text.StartsWith("Name(int)", StringComparison.Ordinal)); } - /// Validates that SanitizePath sanitizes each segment, drops empty segments, and handles empty input. - [Fact] - public void CppEmitter_SanitizePath_NestedAndReservedSegments_AreSanitizedPerSegment() + /// Builds resolved names for a single class in the namespace . + /// The namespace key. + /// The class. + /// The names and the registry key of the class. + private static (CppNames Names, string ClassKey) BuildNames(string nsKey, CppClass cls) { - // Arrange / Act / Assert - Assert.Equal(string.Empty, CppEmitter.SanitizePath(string.Empty)); - Assert.Equal("ns/Foo{int}", CppEmitter.SanitizePath("ns/Foo")); - Assert.Equal("ns", CppEmitter.SanitizePath("/ns/")); - Assert.Equal("aux-/Aux-", CppEmitter.SanitizePath("aux/Aux")); - Assert.All(CppEmitter.SanitizePath("a:b/CON").Split('/'), s => Assert.True(FileNameSanitizer.IsLegalSegment(s))); - } - - /// Validates that sanitizing the same name twice yields the same, legal result. - /// The raw declaration name. - [Theory] - [InlineData("Foo")] - [InlineData("a:b")] - [InlineData("CON")] - public void CppEmitter_SanitizeFileName_IsDeterministic(string name) - { - // Arrange / Act - var once = CppEmitter.SanitizeFileName(name); - - // Assert - Assert.Equal(once, CppEmitter.SanitizeFileName(name)); - Assert.True(FileNameSanitizer.IsLegalSegment(once)); + var decls = new CppEmitter.NamespaceDeclarations(nsKey); + decls.Classes.Add(cls); + var names = CppNames.Build(new Dictionary { [nsKey] = decls }); + return (names, CppNames.ClassKey(CppNames.NamespaceKey(nsKey), cls.Name)); } /// Validates that base-class names are appended to the class declaration line. @@ -347,10 +295,12 @@ public void CppEmitter_WriteCombinedMemberPage_TooFewMembers_ThrowsArgumentExcep "GetCount", "int", [], CppAccessibility.Public, false, false, false, false, false, false, null, null); + var (names, classKey) = BuildNames("ns", cls); + // Act / Assert: one-element list must be rejected — combined page requires ≥ 2 Assert.Throws(() => CppEmitter.WriteCombinedMemberPage( - factory, "ns", "ns", cls, "getcount", + factory, names, classKey, "ns", cls, [singleMember], resolver)); } diff --git a/test/ApiMark.Cpp.Tests/CppFileNamingTests.cs b/test/ApiMark.Cpp.Tests/CppFileNamingTests.cs new file mode 100644 index 0000000..f2f16fc --- /dev/null +++ b/test/ApiMark.Cpp.Tests/CppFileNamingTests.cs @@ -0,0 +1,327 @@ +// Copyright (c) DemaConsulting LLC. All rights reserved. +// Licensed under the MIT License. + +using System.Text.RegularExpressions; +using ApiMark.Core; +using ApiMark.Core.TestHelpers; +using ApiMark.Cpp.CppAst; +using Xunit; + +namespace ApiMark.Cpp.Tests; + +/// +/// Tests for the symbol-name registry applied to C++ output: exact-name rules, every collision scenario, +/// and whole-output properties (unique, legal names whose links all resolve). +/// +public class CppFileNamingTests +{ + /// Matches a Markdown link destination ending in .md, with an optional anchor. + private static readonly Regex LinkPattern = new(@"\]\(([^)\s#]+\.md)(?:#[^)\s]*)?\)", RegexOptions.Compiled); + + /// A factory that records every page request so that overwritten pages are detected. + private sealed class RecordingFactory : IMarkdownWriterFactory + { + /// The wrapped factory. + private readonly InMemoryMarkdownWriterFactory _inner = new(); + + /// Gets every requested page key, in request order. + public List Requests { get; } = []; + + /// Gets the pages written so far. + public IReadOnlyDictionary Writers => _inner.Writers; + + /// + public IMarkdownWriter CreateMarkdown(string subFolder, string name) + { + Requests.Add(subFolder.Length == 0 ? name : $"{subFolder}/{name}"); + return _inner.CreateMarkdown(subFolder, name); + } + } + + /// Builds a function without documentation. + private static CppFunction Fn(string name, string returnType, params CppParameter[] parameters) => + new(name, returnType, parameters, CppAccessibility.Public, false, false, false, false, false, false, null, null); + + /// Builds a class without documentation. + private static CppClass Cls(string name, CppFunction[]? methods = null, CppField[]? fields = null, CppClass[]? nested = null) => + new(name, [], [], methods ?? [], fields ?? [], nested ?? [], [], false, false, null, null); + + /// Builds an enum without documentation. + private static CppEnum Enm(string name) => new(name, [], false, null, null); + + /// Builds a type alias without documentation. + private static CppTypeAlias Alias(string name) => new(name, "int", false, null, null); + + /// Builds a field without documentation. + private static CppField Field(string name) => new(name, "int", CppAccessibility.Public, false, false, null, null); + + /// Creates declarations for one namespace. + private static CppEmitter.NamespaceDeclarations Ns(string display) => new(display, null); + + /// Emits the declarations into a recording factory. + private static RecordingFactory Emit(SortedDictionary nsDecls) + { + var options = new CppGeneratorOptions + { + LibraryName = "TestLib", + PublicIncludeRoots = [FixturePaths.GetFixtureIncludeDir()], + }; + var names = CppNames.Build(nsDecls); + var resolver = new CppTypeLinkResolver(names.BuildKnownTypes(nsDecls)); + var emitter = new CppEmitter(options, nsDecls, resolver, names); + var factory = new RecordingFactory(); + emitter.Emit(factory, new EmitConfig(), new InMemoryContext()); + return factory; + } + + /// Creates a sorted namespace map from the supplied pairs. + private static SortedDictionary Decls(params (string Key, CppEmitter.NamespaceDeclarations Decl)[] items) + { + var map = new SortedDictionary(StringComparer.Ordinal); + foreach (var (key, decl) in items) + { + map[key] = decl; + } + + return map; + } + + /// Builds a model that exercises every collision scenario at once. + private static SortedDictionary BuildCollisionModel() + { + var lib = Ns("lib"); + lib.Classes.Add(Cls("Foo", [Fn("Get", "int"), Fn("get", "int"), Fn("Get", "int", new CppParameter("a", "int")), Fn("operators", "int"), Fn("operator+", "int", new CppParameter("o", "int"))], [Field("Value"), Field("value")], [Cls("Inner"), Cls("inner")])); + lib.Classes.Add(Cls("Bar")); + lib.Classes.Add(Cls("bar")); + lib.Classes.Add(Cls("operators")); + lib.Enums.Add(Enm("Foo")); + lib.Enums.Add(Enm("BAR")); + lib.TypeAliases.Add(Alias("Foo")); + lib.TypeAliases.Add(Alias("Baz")); + lib.FreeFunctions.Add(Fn("Foo", "int")); + lib.FreeFunctions.Add(Fn("baz", "int")); + lib.FreeFunctions.Add(Fn("Run", "int")); + lib.FreeFunctions.Add(Fn("Run", "int", new CppParameter("a", "int"))); + lib.FreeFunctions.Add(Fn("operator<<", "int", new CppParameter("w", "int"))); + lib.FreeFunctions.Add(Fn("api", "int")); + + var api = Ns("api"); + api.Classes.Add(Cls("Foo", [Fn("Get", "Foo")])); + var upper = Ns("API"); + upper.Classes.Add(Cls("Aux")); + upper.Enums.Add(Enm("CON")); + var odd = Ns("a::b"); + odd.Classes.Add(Cls("X")); + + return Decls(("lib", lib), ("api", api), ("API", upper), ("a::b", odd), ("operators", Ns("operators"))); + } + + /// Asserts that every page is unique (case-insensitively, as requested) and legal. + internal static void AssertUniqueAndLegal(IEnumerable requests) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var request in requests) + { + Assert.True(seen.Add(request), $"Page '{request}' was requested more than once (case-insensitively)"); + foreach (var segment in request.Split('/')) + { + Assert.True(FileNameRules.IsLegalSegment(segment), $"Illegal segment '{segment}' in '{request}'"); + } + } + } + + /// Asserts that every relative link resolves to an emitted page and returns the number of links checked. + internal static int AssertAllLinksResolve(IReadOnlyDictionary writers) + { + var checkedLinks = 0; + foreach (var (key, writer) in writers) + { + var slash = key.LastIndexOf('/'); + var folder = slash < 0 ? string.Empty : key[..slash]; + var texts = writer.Operations.OfType().SelectMany(t => t.Rows).SelectMany(r => r) + .Concat(writer.Operations.OfType().Select(p => p.Text)); + foreach (var text in texts) + { + foreach (Match match in LinkPattern.Matches(text)) + { + var target = Path.GetRelativePath(".", Path.Combine(folder, match.Groups[1].Value)).Replace('\\', '/'); + target = target[..^".md".Length]; + Assert.True(writers.ContainsKey(target), $"Link '{match.Groups[1].Value}' on '{key}' resolves to missing page '{target}'"); + checkedLinks++; + } + } + } + + return checkedLinks; + } + + /// A class, enum, alias and function of the same name each get their own page; the class keeps the plain name. + [Fact] + public void CppNames_ClassEnumAliasFunctionSameName_AreSeparatedInKindOrder() + { + // Arrange + var lib = Ns("lib"); + lib.Classes.Add(Cls("Foo")); + lib.Enums.Add(Enm("Foo")); + lib.TypeAliases.Add(Alias("Foo")); + lib.FreeFunctions.Add(Fn("Foo", "int")); + + // Act + var names = CppNames.Build(Decls(("lib", lib))); + var ns = CppNames.NamespaceKey("lib"); + + // Assert + Assert.Equal("lib/Foo", names.Path(CppNames.ClassKey(ns, "Foo"))); + Assert.Equal("lib/Foo-2", names.Path(CppNames.EnumKey(ns, "Foo"))); + Assert.Equal("lib/Foo-3", names.Path(CppNames.AliasKey(ns, "Foo"))); + Assert.Equal("lib/Foo-4", names.Path(CppNames.FunctionKey(ns, "Foo"))); + } + + /// Declarations that differ only by case are separate pages; the ordinally first keeps the plain name. + [Fact] + public void CppNames_CaseTwinClasses_SecondGetsSuffix() + { + // Arrange + var lib = Ns("lib"); + lib.Classes.Add(Cls("Bar", [Fn("Get", "int"), Fn("get", "int")])); + lib.Classes.Add(Cls("bar")); + + // Act + var names = CppNames.Build(Decls(("lib", lib))); + var ns = CppNames.NamespaceKey("lib"); + var bar = CppNames.ClassKey(ns, "Bar"); + + // Assert + Assert.Equal("lib/Bar", names.Path(bar)); + Assert.Equal("lib/bar-2", names.Path(CppNames.ClassKey(ns, "bar"))); + Assert.Equal("lib/Bar/Get", names.Path(CppNames.MemberKey(bar, "Get"))); + Assert.Equal("lib/Bar/get-2", names.Path(CppNames.MemberKey(bar, "get"))); + } + + /// The reserved api and operators pages keep their names; same-named symbols are displaced with a numeric suffix. + [Fact] + public void CppNames_ReservedNames_AreNeverTaken() + { + // Arrange + var api = Ns("api"); + api.Classes.Add(Cls("Holder", [Fn("operators", "int")])); + api.Classes.Add(Cls("operators")); + api.FreeFunctions.Add(Fn("Operators", "int")); + + // Act + var names = CppNames.Build(Decls(("api", api))); + var ns = CppNames.NamespaceKey("api"); + + // Assert + Assert.Equal("api", names.Path(CppNames.ApiKey)); + Assert.Equal("api-2", names.Path(ns)); + Assert.Equal("api-2/operators", names.Path(CppNames.OperatorsKey(ns))); + Assert.Equal("api-2/operators-2", names.Path(CppNames.ClassKey(ns, "operators"))); + Assert.Equal("api-2/Operators-3", names.Path(CppNames.FunctionKey(ns, "Operators"))); + Assert.Equal("api-2/Holder/operators", names.Path(CppNames.OperatorsKey(CppNames.ClassKey(ns, "Holder")))); + Assert.Equal("api-2/Holder/operators-2", names.Path(CppNames.MemberKey(CppNames.ClassKey(ns, "Holder"), "operators"))); + } + + /// Template angle brackets become braces and reserved device names are suffixed. + [Fact] + public void CppNames_TemplateAndDeviceNames_AreEscapedOnce() + { + // Arrange + var lib = Ns("lib"); + lib.Classes.Add(Cls("Foo")); + lib.Enums.Add(Enm("CON")); + + // Act + var names = CppNames.Build(Decls(("lib", lib))); + var ns = CppNames.NamespaceKey("lib"); + + // Assert + Assert.Equal("lib/Foo{int}", names.Path(CppNames.ClassKey(ns, "Foo"))); + Assert.Equal("lib/CON_", names.Path(CppNames.EnumKey(ns, "CON"))); + } + + /// A namespace key with qualification separators is one legal segment. + [Fact] + public void CppNames_QualifiedNamespaceKey_IsOneEscapedSegment() + { + // Arrange + var odd = Ns("a::b"); + odd.Classes.Add(Cls("X")); + + // Act + var names = CppNames.Build(Decls(("a::b", odd))); + + // Assert + Assert.Equal("a__b", names.Path(CppNames.NamespaceKey("a::b"))); + Assert.Equal("a__b/X", names.Path(CppNames.ClassKey(CppNames.NamespaceKey("a::b"), "X"))); + } + + /// Overloaded free functions share one page that lists every overload; no overload overwrites another. + [Fact] + public void CppEmitter_OverloadedFreeFunctions_ShareOnePageWithEveryOverload() + { + // Arrange + var lib = Ns("lib"); + lib.FreeFunctions.Add(Fn("Run", "int")); + lib.FreeFunctions.Add(Fn("Run", "int", new CppParameter("a", "int"))); + + // Act + var factory = Emit(Decls(("lib", lib))); + + // Assert + Assert.Single(factory.Requests, r => r == "lib/Run"); + var page = factory.Writers["lib/Run"]; + var headings = page.Operations.OfType().Select(h => h.Text).ToList(); + Assert.Contains("Run()", headings); + Assert.Contains("Run(int)", headings); + var rows = factory.Writers["lib"].Operations.OfType().SelectMany(t => t.Rows).ToList(); + Assert.Contains(rows, r => r[0] == "[Run()](lib/Run.md)"); + Assert.Contains(rows, r => r[0] == "[Run(int)](lib/Run.md)"); + } + + /// A class and a function of the same name are both written, and each namespace row links to its own page. + [Fact] + public void CppEmitter_ClassAndFunctionSameName_BothPagesWrittenAndLinked() + { + // Arrange + var lib = Ns("lib"); + lib.Classes.Add(Cls("Foo")); + lib.FreeFunctions.Add(Fn("Foo", "int")); + + // Act + var factory = Emit(Decls(("lib", lib))); + + // Assert + Assert.True(factory.Writers.ContainsKey("lib/Foo")); + Assert.True(factory.Writers.ContainsKey("lib/Foo-2")); + var rows = factory.Writers["lib"].Operations.OfType().SelectMany(t => t.Rows).ToList(); + Assert.Contains(rows, r => r[0] == "[Foo](lib/Foo.md)"); + Assert.Contains(rows, r => r[0] == "[Foo](lib/Foo-2.md)"); + } + + /// Every page of a model that exercises all collision scenarios is unique, legal, and linked. + [Fact] + public void CppEmitter_CollisionModel_AllPagesUniqueLegalAndLinksResolve() + { + // Arrange / Act + var factory = Emit(BuildCollisionModel()); + + // Assert + AssertUniqueAndLegal(factory.Requests); + Assert.Equal(factory.Requests.Count, factory.Writers.Count); + Assert.True(AssertAllLinksResolve(factory.Writers) > 20); + Assert.Equal(1, factory.Requests.Count(r => r == "api")); + } + + /// Emitting the same model twice yields identical page sets. + [Fact] + public void CppEmitter_CollisionModel_IsDeterministic() + { + // Arrange / Act + var first = Emit(BuildCollisionModel()).Requests.OrderBy(r => r, StringComparer.Ordinal).ToList(); + var second = Emit(BuildCollisionModel()).Requests.OrderBy(r => r, StringComparer.Ordinal).ToList(); + + // Assert + Assert.Equal(first, second); + } +} diff --git a/test/ApiMark.Cpp.Tests/CppFixtureFileNamingTests.cs b/test/ApiMark.Cpp.Tests/CppFixtureFileNamingTests.cs new file mode 100644 index 0000000..20e8763 --- /dev/null +++ b/test/ApiMark.Cpp.Tests/CppFixtureFileNamingTests.cs @@ -0,0 +1,43 @@ +// Copyright (c) DemaConsulting LLC. All rights reserved. +// Licensed under the MIT License. + +using Xunit; + +namespace ApiMark.Cpp.Tests; + +/// +/// Whole-output naming properties of the clang-generated fixture documentation. These tests require clang. +/// +public class CppFixtureFileNamingTests : IClassFixture +{ + /// The shared clang-generated fixture output. + private readonly CppGeneratorFixture _fixture; + + /// Initializes a new instance of the class. + /// The shared fixture. + public CppFixtureFileNamingTests(CppGeneratorFixture fixture) + { + _fixture = fixture; + } + + /// The fixture output is unique, legal, and fully linked for every visibility tier. + [Fact] + public void CppGenerator_FixtureOutput_AllPagesUniqueLegalAndLinksResolve() + { + // Arrange + var factories = new[] + { + _fixture.PublicFactory, + _fixture.WithDeprecatedFactory, + _fixture.PublicAndProtectedFactory, + _fixture.AllFactory, + }; + + // Act / Assert + foreach (var factory in factories) + { + CppFileNamingTests.AssertUniqueAndLegal(factory.Writers.Keys); + Assert.True(CppFileNamingTests.AssertAllLinksResolve(factory.Writers) > 0); + } + } +} diff --git a/test/ApiMark.Cpp.Tests/CppTypeLinkResolverTests.cs b/test/ApiMark.Cpp.Tests/CppTypeLinkResolverTests.cs index 732f93c..ca43eba 100644 --- a/test/ApiMark.Cpp.Tests/CppTypeLinkResolverTests.cs +++ b/test/ApiMark.Cpp.Tests/CppTypeLinkResolverTests.cs @@ -29,56 +29,53 @@ public void CppTypeLinkResolver_Linkify_ExactQualifiedMatch_EmitsLink() } /// - /// Validates that the current folder is treated as an already-sanitized output folder and is not - /// sanitized a second time, so links into and out of a generic type's folder stay direct. + /// Validates that the current folder and page keys are used as approved paths and are not + /// mapped again, so links into and out of a generic type's folder stay direct. /// [Fact] - public void CppTypeLinkResolver_Linkify_AlreadySanitizedFolder_IsNotSanitizedAgain() + public void CppTypeLinkResolver_Linkify_ApprovedFolder_IsUsedAsIs() { // Arrange var knownTypes = new Dictionary(StringComparer.Ordinal) { - { "ns::Foo", "ns/Foo" }, - { "ns::Foo::Inner", "ns/Foo/Inner" }, + { "ns::Foo", "ns/Foo{int}" }, + { "ns::Foo::Inner", "ns/Foo{int}/Inner" }, }; var resolver = new CppTypeLinkResolver(knownTypes); var externalTypes = new SortedSet(); - var folder = CppEmitter.SanitizePath("ns/Foo"); + const string folder = "ns/Foo{int}"; // Act var sibling = resolver.Linkify("ns::Foo::Inner", folder, externalTypes); var owner = resolver.Linkify("ns::Foo", folder, externalTypes); // Assert - Assert.Equal("ns/Foo{int}", folder); Assert.Equal("ns::Foo::[Inner](Inner.md)", sibling); Assert.Equal("ns::[Foo](../Foo{int}.md)", owner); } /// - /// Validates that page keys containing template or reserved names are linked using sanitized paths. + /// Validates that approved page keys with displaced names are linked as given. /// [Fact] - public void CppTypeLinkResolver_Linkify_IllegalPageKey_EmitsSanitizedRelativeLink() + public void CppTypeLinkResolver_Linkify_DisplacedPageKey_EmitsApprovedRelativeLink() { // Arrange var knownTypes = new Dictionary(StringComparer.Ordinal) { - { "ns::Foo", "ns/Foo" }, - { "ns::Aux", "ns/Aux" }, + { "ns::Foo", "ns/Foo{int}" }, + { "ns::Aux", "ns/Aux-2" }, }; var resolver = new CppTypeLinkResolver(knownTypes); var externalTypes = new SortedSet(); // Act - var fromRawFolder = resolver.Linkify("ns::Foo", "ns/Aux", externalTypes); - var fromSanitizedFolder = resolver.Linkify("ns::Foo", "ns/Aux-", externalTypes); - var reserved = resolver.Linkify("ns::Aux", "ns", externalTypes); + var fromSibling = resolver.Linkify("ns::Foo", "ns/Aux-2", externalTypes); + var displaced = resolver.Linkify("ns::Aux", "ns", externalTypes); // Assert - Assert.Equal("ns::[Foo](../Foo{int}.md)", fromRawFolder); - Assert.Equal("ns::[Foo](../Foo{int}.md)", fromSanitizedFolder); - Assert.Equal("ns::[Aux](Aux-.md)", reserved); + Assert.Equal("ns::[Foo](../Foo{int}.md)", fromSibling); + Assert.Equal("ns::[Aux](Aux-2.md)", displaced); } /// From cca89257edc4ef34f7dbaf479d11fed401a4b7b7 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 15 Oct 2026 01:26:57 -0400 Subject: [PATCH 04/15] Migrate VHDL generator file naming to SymbolNameRegistry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ApiMark.Vhdl/VhdlEmitter.cs | 24 +- .../VhdlEmitterGradualDisclosure.cs | 36 ++- src/ApiMark.Vhdl/VhdlNames.cs | 121 +++++++++ .../VhdlEmitterGradualDisclosureTests.cs | 18 +- .../ApiMark.Vhdl.Tests/VhdlFileNamingTests.cs | 255 ++++++++++++++++++ 5 files changed, 405 insertions(+), 49 deletions(-) create mode 100644 src/ApiMark.Vhdl/VhdlNames.cs create mode 100644 test/ApiMark.Vhdl.Tests/VhdlFileNamingTests.cs diff --git a/src/ApiMark.Vhdl/VhdlEmitter.cs b/src/ApiMark.Vhdl/VhdlEmitter.cs index 4a2da52..14f8bf9 100644 --- a/src/ApiMark.Vhdl/VhdlEmitter.cs +++ b/src/ApiMark.Vhdl/VhdlEmitter.cs @@ -32,11 +32,15 @@ internal VhdlEmitter(VhdlGeneratorOptions options, IReadOnlyList { _options = options; _fileModels = fileModels; + Names = VhdlNames.Build(fileModels); } /// Gets the generator options. internal VhdlGeneratorOptions Options => _options; + /// Gets the approved file names of every declaration. + internal VhdlNames Names { get; } + /// public void Emit(IMarkdownWriterFactory factory, EmitConfig config, IContext context) { @@ -84,24 +88,4 @@ internal static string FormatParamType(VhdlParamDecl param) return string.IsNullOrEmpty(direction) ? param.TypeName : $"{direction} {param.TypeName}"; } - /// - /// Converts into a file name segment that is legal on Windows and - /// Linux by delegating to (which also moves - /// reserved device names such as aux or con aside). - /// - /// The raw declaration name to sanitize. - /// A filesystem-safe file name string. - internal static string SanitizeFileName(string name) => FileNameSanitizer.SanitizeSegment(name); - - /// - /// Converts the name of a root-level entity or package into its page file name; a name equal to - /// the reserved api index page is moved aside so it can never overwrite api.md. - /// - /// The raw entity or package name. - /// A legal root-level page file name without extension. - internal static string SanitizePageName(string name) - { - var sanitized = SanitizeFileName(name); - return string.Equals(sanitized, "api", StringComparison.OrdinalIgnoreCase) ? sanitized + "-" : sanitized; - } } diff --git a/src/ApiMark.Vhdl/VhdlEmitterGradualDisclosure.cs b/src/ApiMark.Vhdl/VhdlEmitterGradualDisclosure.cs index c6679e8..17724c5 100644 --- a/src/ApiMark.Vhdl/VhdlEmitterGradualDisclosure.cs +++ b/src/ApiMark.Vhdl/VhdlEmitterGradualDisclosure.cs @@ -80,7 +80,7 @@ private void EmitApiIndexPage( writer.WriteHeading(2, "Entities"); var headers = new[] { "Name", VhdlEmitter.DescriptionColumnHeader }; var rows = allEntities.Select(e => - new[] { $"[{e.Name}]({VhdlEmitter.SanitizePageName(e.Name)}.md)", VhdlEmitter.GetSummary(e.Doc) ?? VhdlEmitter.NoDescriptionPlaceholder }); + new[] { $"[{e.Name}]({_emitter.Names.Link(string.Empty, e)})", VhdlEmitter.GetSummary(e.Doc) ?? VhdlEmitter.NoDescriptionPlaceholder }); writer.WriteTable(headers, rows); } @@ -89,7 +89,7 @@ private void EmitApiIndexPage( writer.WriteHeading(2, "Packages"); var headers = new[] { "Name", VhdlEmitter.DescriptionColumnHeader }; var rows = allPackages.Select(p => - new[] { $"[{p.Name}]({VhdlEmitter.SanitizePageName(p.Name)}.md)", VhdlEmitter.GetSummary(p.Doc) ?? VhdlEmitter.NoDescriptionPlaceholder }); + new[] { $"[{p.Name}]({_emitter.Names.Link(string.Empty, p)})", VhdlEmitter.GetSummary(p.Doc) ?? VhdlEmitter.NoDescriptionPlaceholder }); writer.WriteTable(headers, rows); } } @@ -105,13 +105,13 @@ private void EmitApiIndexPage( /// All architecture declarations across all parsed files; filtered to those /// whose entity name matches . /// - private static void EmitEntityPage( + private void EmitEntityPage( IMarkdownWriterFactory factory, VhdlEntityDecl entity, string fileName, List<(VhdlArchitectureDecl Arch, string FileName)> allArchitectures) { - using var writer = factory.CreateMarkdown("", VhdlEmitter.SanitizePageName(entity.Name)); + using var writer = factory.CreateMarkdown(_emitter.Names.Directory(entity), _emitter.Names.Name(entity)); writer.WriteHeading(1, entity.Name); // Attribution: kind and source file @@ -190,23 +190,22 @@ private static void EmitEntityPage( /// Factory for creating Markdown writers for the package page and subprogram pages. /// The package declaration to emit. /// Base filename of the source file containing this package declaration. - private static void EmitPackagePage(IMarkdownWriterFactory factory, VhdlPackageDecl pkg, string fileName) + private void EmitPackagePage(IMarkdownWriterFactory factory, VhdlPackageDecl pkg, string fileName) { - var pkgPage = VhdlEmitter.SanitizePageName(pkg.Name); - using var writer = factory.CreateMarkdown("", pkgPage); + var names = _emitter.Names; + using var writer = factory.CreateMarkdown(names.Directory(pkg), names.Name(pkg)); writer.WriteHeading(1, pkg.Name); EmitPackageSummary(writer, pkg, fileName); EmitPackageTypes(writer, pkg); EmitPackageConstants(writer, pkg); EmitPackageComponents(writer, pkg); - EmitPackageSubprogramList(writer, pkg, pkgPage); + EmitPackageSubprogramList(writer, pkg); - // Emit one detail file per subprogram with full documentation — placed in a per-package subfolder - // named after the package page so page, folder, and links always agree + // Emit one detail file per subprogram with full documentation, in the folder of the package page foreach (var s in pkg.Subprograms) { - EmitSubprogramDetailPage(factory, pkg, pkgPage, s); + EmitSubprogramDetailPage(factory, pkg, s); } } @@ -321,19 +320,19 @@ private static void EmitPackageComponents(IMarkdownWriter writer, VhdlPackageDec /// Emits the subprograms section as linked paragraphs pointing to per-subprogram detail files. /// Markdown writer to emit into. /// The package declaration whose subprograms are emitted. - /// The sanitized package page name, also used as the subprogram folder. - private static void EmitPackageSubprogramList(IMarkdownWriter writer, VhdlPackageDecl pkg, string pkgFolder) + private void EmitPackageSubprogramList(IMarkdownWriter writer, VhdlPackageDecl pkg) { if (pkg.Subprograms.Count == 0) { return; } + var pageDirectory = _emitter.Names.Directory(pkg); writer.WriteHeading(2, "Subprograms"); foreach (var s in pkg.Subprograms) { // Paragraph: linked name and kind label — subfolder path so the reader can navigate - var detailFile = $"{pkgFolder}/{VhdlEmitter.SanitizeFileName(s.Name)}.md"; + var detailFile = _emitter.Names.Link(pageDirectory, s); var kindText = s.Kind == VhdlSubprogramKind.Function ? "Function" : "Procedure"; writer.WriteParagraph($"**[{s.Name}]({detailFile})** — *{kindText}*"); @@ -348,17 +347,14 @@ private static void EmitPackageSubprogramList(IMarkdownWriter writer, VhdlPackag /// including attribution, summary, parameters, return type, and signature. /// /// Factory for creating the per-subprogram Markdown writer. - /// The owning package declaration, used for attribution and folder naming. - /// The sanitized package page name used as the subfolder. + /// The owning package declaration, used for attribution. /// The subprogram declaration to emit. - private static void EmitSubprogramDetailPage( + private void EmitSubprogramDetailPage( IMarkdownWriterFactory factory, VhdlPackageDecl pkg, - string pkgFolder, VhdlSubprogramDecl s) { - var subFileName = VhdlEmitter.SanitizeFileName(s.Name); - using var subWriter = factory.CreateMarkdown(pkgFolder, subFileName); + using var subWriter = factory.CreateMarkdown(_emitter.Names.Directory(s), _emitter.Names.Name(s)); subWriter.WriteHeading(1, s.Name); // Attribution: kind and owning package name diff --git a/src/ApiMark.Vhdl/VhdlNames.cs b/src/ApiMark.Vhdl/VhdlNames.cs new file mode 100644 index 0000000..cfcdcc1 --- /dev/null +++ b/src/ApiMark.Vhdl/VhdlNames.cs @@ -0,0 +1,121 @@ +using ApiMark.Core; +using ApiMark.Vhdl.VhdlAst; + +namespace ApiMark.Vhdl; + +/// +/// The approved file names of every entity, package, and subprogram of a documented VHDL library. +/// +/// +/// Every declaration is registered with a and resolved up front, so the +/// emitter only reads approved names. Entities and packages share the output root together with the +/// reserved api index page; an entity wins the plain name over a package of the same name (VHDL +/// identifiers are case-insensitive, so Api and API are the same name), and the package +/// receives a numeric suffix (-2). Subprogram pages live in the folder of their package; +/// overloads each get their own page in declaration order (name, name-2, ...). +/// The rules are documented in docs/design/api-mark-core/file-naming-rules.md. +/// +internal sealed class VhdlNames +{ + /// Name of the reserved library index page. + internal const string ApiPageName = "api"; + + /// The resolved registry. + private readonly SymbolNameRegistry _registry = new(); + + /// The registry keys of the declarations, by declaration instance. + private readonly Dictionary _keys = new(ReferenceEqualityComparer.Instance); + + /// Initializes a new instance of the class. + private VhdlNames() + { + } + + /// Gets the registry that holds the approved names. + internal SymbolNameRegistry Registry => _registry; + + /// Builds and resolves the names of every declaration in . + /// The parsed file models of the library. + /// The resolved names. + internal static VhdlNames Build(IReadOnlyList fileModels) + { + ArgumentNullException.ThrowIfNull(fileModels); + + var names = new VhdlNames(); + var registry = names._registry; + registry.Register("R:" + ApiPageName, null, ApiPageName, SymbolKind.Reserved); + + foreach (var entity in fileModels.SelectMany(f => f.Entities)) + { + names.RegisterUnique(registry, entity, "T1:" + entity.Name, null, entity.Name, SymbolKind.Type); + } + + foreach (var pkg in fileModels.SelectMany(f => f.Packages)) + { + var pkgKey = names.RegisterUnique(registry, pkg, "T2:" + pkg.Name, null, pkg.Name, SymbolKind.Type); + for (var i = 0; i < pkg.Subprograms.Count; i++) + { + var s = pkg.Subprograms[i]; + names.RegisterUnique(registry, s, $"{pkgKey}/M:{s.Name}/{i:D6}", pkgKey, s.Name, SymbolKind.Member); + } + } + + registry.Resolve(); + return names; + } + + /// Gets the page path (no extension) of the reserved api index page. + /// The approved path. + internal string ApiPath() => _registry.GetPath("R:" + ApiPageName); + + /// Gets the approved page path (no extension) of an entity, package, or subprogram. + /// The declaration. + /// The path relative to the output root. + internal string Path(object declaration) => _registry.GetPath(_keys[declaration]); + + /// Gets the approved file name (no extension) of an entity, package, or subprogram. + /// The declaration. + /// The file name. + internal string Name(object declaration) => _registry.GetName(_keys[declaration]); + + /// Gets the approved directory that holds the page of a declaration. + /// The declaration. + /// The directory relative to the output root; empty for the root. + internal string Directory(object declaration) => _registry.GetDirectory(_keys[declaration]); + + /// Gets the relative link destination from one page directory to a declaration's page. + /// The directory of the page that holds the link. + /// The target declaration. + /// A forward-slash relative path ending in .md. + internal string Link(string fromDirectory, object declaration) => + LinkPath.Relative(fromDirectory, Path(declaration) + ".md"); + + /// Registers a declaration under a key made unique when the same declaration name occurs twice. + /// The registry. + /// The declaration instance. + /// The preferred key. + /// The parent key, or for the root. + /// The raw name. + /// The symbol kind. + /// The key that was registered. + private string RegisterUnique( + SymbolNameRegistry registry, + object declaration, + string key, + string? parentKey, + string rawName, + SymbolKind kind) + { + var unique = key; + var n = 2; + while (registry.Contains(unique)) + { + unique = $"{key}#{n}"; + n++; + } + + registry.Register(unique, parentKey, rawName, kind); + _keys[declaration] = unique; + return unique; + } +} diff --git a/test/ApiMark.Vhdl.Tests/VhdlEmitterGradualDisclosureTests.cs b/test/ApiMark.Vhdl.Tests/VhdlEmitterGradualDisclosureTests.cs index 9481b50..5f96cf2 100644 --- a/test/ApiMark.Vhdl.Tests/VhdlEmitterGradualDisclosureTests.cs +++ b/test/ApiMark.Vhdl.Tests/VhdlEmitterGradualDisclosureTests.cs @@ -24,10 +24,10 @@ public void VhdlEmitterGradualDisclosure_Emit_ReservedDeviceNames_ProducesLegalL new VhdlEmitterGradualDisclosure(emitter, fileModels).Emit(factory, new EmitConfig(), new InMemoryContext()); // Assert - Assert.All(factory.Writers.Keys, k => Assert.All(k.Split('/'), s => Assert.True(FileNameSanitizer.IsLegalSegment(s), k))); - Assert.True(factory.HasWriter("", "aux-"), "Expected the aux entity page to be named aux-"); + Assert.All(factory.Writers.Keys, k => Assert.All(k.Split('/'), s => Assert.True(FileNameRules.IsLegalSegment(s), k))); + Assert.True(factory.HasWriter("", "aux_"), "Expected the aux entity page to be named aux_"); var rows = factory.GetWriter("", "api").Operations.OfType().SelectMany(t => t.Rows); - Assert.Contains(rows, r => r[0].Contains("(aux-.md)", StringComparison.Ordinal)); + Assert.Contains(rows, r => r[0].Contains("(aux_.md)", StringComparison.Ordinal)); } /// Validates that an entity or package named like the reserved api index page never overwrites it. @@ -48,17 +48,17 @@ public void VhdlEmitterGradualDisclosure_Emit_EntityAndPackageNamedApi_DoNotOver // Assert: the index keeps its page, the declarations are moved aside, and the index links to them var apiHeadings = factory.GetWriter("", "api").Operations.OfType(); Assert.Contains(apiHeadings, h => h.Level == 1 && h.Text.Contains("API Reference", StringComparison.Ordinal)); - Assert.True(factory.HasWriter("", "Api-")); - Assert.True(factory.HasWriter("", "API-")); + Assert.True(factory.HasWriter("", "Api-2")); + Assert.True(factory.HasWriter("", "API-3")); var rows = factory.GetWriter("", "api").Operations.OfType().SelectMany(t => t.Rows).ToList(); - Assert.Contains(rows, r => r[0].Contains("(Api-.md)", StringComparison.Ordinal)); - Assert.Contains(rows, r => r[0].Contains("(API-.md)", StringComparison.Ordinal)); + Assert.Contains(rows, r => r[0].Contains("(Api-2.md)", StringComparison.Ordinal)); + Assert.Contains(rows, r => r[0].Contains("(API-3.md)", StringComparison.Ordinal)); } /// Validates that packages with reserved names keep page, folder, and every link consistent. [Theory] - [InlineData("Api", "Api-")] - [InlineData("Aux", "Aux-")] + [InlineData("Api", "Api-2")] + [InlineData("Aux", "Aux_")] public void VhdlEmitterGradualDisclosure_Emit_PackageWithReservedName_AllLinksResolve(string pkgName, string page) { // Arrange diff --git a/test/ApiMark.Vhdl.Tests/VhdlFileNamingTests.cs b/test/ApiMark.Vhdl.Tests/VhdlFileNamingTests.cs new file mode 100644 index 0000000..f3e95f5 --- /dev/null +++ b/test/ApiMark.Vhdl.Tests/VhdlFileNamingTests.cs @@ -0,0 +1,255 @@ +// Copyright (c) DemaConsulting LLC. All rights reserved. +// Licensed under the MIT License. + +using System.Text.RegularExpressions; +using ApiMark.Core; +using ApiMark.Core.TestHelpers; +using ApiMark.Vhdl.VhdlAst; +using Xunit; + +namespace ApiMark.Vhdl.Tests; + +/// +/// Tests for the symbol-name registry applied to VHDL output: exact-name rules, every collision scenario, +/// and whole-output properties (unique, legal names whose links all resolve). +/// +public class VhdlFileNamingTests +{ + /// Matches a Markdown link destination ending in .md. + private static readonly Regex LinkPattern = new(@"\]\(([^)\s#]+\.md)(?:#[^)\s]*)?\)", RegexOptions.Compiled); + + /// A factory that records every page request so that overwritten pages are detected. + private sealed class RecordingFactory : IMarkdownWriterFactory + { + /// The wrapped factory. + private readonly InMemoryMarkdownWriterFactory _inner = new(); + + /// Gets every requested page key, in request order. + public List Requests { get; } = []; + + /// Gets the pages written so far. + public IReadOnlyDictionary Writers => _inner.Writers; + + /// + public IMarkdownWriter CreateMarkdown(string subFolder, string name) + { + Requests.Add(subFolder.Length == 0 ? name : $"{subFolder}/{name}"); + return _inner.CreateMarkdown(subFolder, name); + } + } + + /// Builds an entity without documentation. + private static VhdlEntityDecl Entity(string name) => new(name, [], [], null); + + /// Builds a function subprogram without documentation. + private static VhdlSubprogramDecl Func(string name) => + new(name, VhdlSubprogramKind.Function, $"function {name} return integer", [], "integer", null); + + /// Builds a package without documentation. + private static VhdlPackageDecl Pkg(string name, params VhdlSubprogramDecl[] subprograms) => + new(name, null, [], [], [], subprograms); + + /// Emits the models into a recording factory. + private static RecordingFactory Emit(params VhdlFileModel[] models) + { + var emitter = new VhdlEmitter(new VhdlGeneratorOptions { LibraryName = "TestLib" }, models); + var factory = new RecordingFactory(); + emitter.Emit(factory, new EmitConfig(), new InMemoryContext()); + return factory; + } + + /// Asserts that every page is unique case-insensitively and legal, and that every link resolves. + private static int AssertUniqueLegalAndLinked(RecordingFactory factory) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var request in factory.Requests) + { + Assert.True(seen.Add(request), $"Page '{request}' was requested more than once (case-insensitively)"); + Assert.All(request.Split('/'), s => Assert.True(FileNameRules.IsLegalSegment(s), $"Illegal segment '{s}' in '{request}'")); + } + + var checkedLinks = 0; + foreach (var (key, writer) in factory.Writers) + { + var slash = key.LastIndexOf('/'); + var folder = slash < 0 ? string.Empty : key[..slash]; + var texts = writer.Operations.OfType().SelectMany(t => t.Rows).SelectMany(r => r) + .Concat(writer.Operations.OfType().Select(p => p.Text)); + foreach (var text in texts) + { + foreach (Match match in LinkPattern.Matches(text)) + { + var target = Path.GetRelativePath(".", Path.Combine(folder, match.Groups[1].Value)).Replace('\\', '/'); + target = target[..^".md".Length]; + Assert.True(factory.Writers.ContainsKey(target), $"Link '{match.Groups[1].Value}' on '{key}' resolves to missing page '{target}'"); + checkedLinks++; + } + } + } + + return checkedLinks; + } + + /// Plain names are used unchanged. + [Fact] + public void VhdlNames_PlainNames_AreUnchanged() + { + // Arrange + var entity = Entity("Counter"); + var package = Pkg("Types", Func("Do_It")); + + // Act + var names = VhdlNames.Build([new VhdlFileModel("a.vhd", [entity], [], [package])]); + + // Assert + Assert.Equal("api", names.ApiPath()); + Assert.Equal("Counter", names.Path(entity)); + Assert.Equal("Types", names.Path(package)); + Assert.Equal("Types/Do_It", names.Path(package.Subprograms[0])); + Assert.Equal("Types", names.Directory(package.Subprograms[0])); + } + + /// An entity keeps the plain name; a package of the same name is displaced with a suffix and carries its subprograms. + [Fact] + public void VhdlNames_EntityAndPackageSameName_EntityWins() + { + // Arrange + var entity = Entity("Shared"); + var package = Pkg("Shared", Func("Run")); + + // Act + var factory = Emit(new VhdlFileModel("a.vhd", [entity], [], [package])); + + // Assert + Assert.Contains("Shared", factory.Requests); + Assert.Contains("Shared-2", factory.Requests); + Assert.Contains("Shared-2/Run", factory.Requests); + Assert.True(AssertUniqueLegalAndLinked(factory) >= 3); + } + + /// An entity and a package that differ only by case are the same VHDL name and must not overwrite each other. + [Fact] + public void VhdlNames_EntityAndPackageCaseTwins_AreSeparated() + { + // Arrange + var entity = Entity("Mix"); + var package = Pkg("MIX"); + + // Act + var factory = Emit(new VhdlFileModel("a.vhd", [entity], [], [package])); + + // Assert + Assert.Contains("Mix", factory.Requests); + Assert.Contains("MIX-2", factory.Requests); + AssertUniqueLegalAndLinked(factory); + } + + /// Entities that differ only by case are separated; the ordinally first keeps the plain name. + [Fact] + public void VhdlNames_CaseTwinEntities_SecondGetsSuffix() + { + // Arrange and Act + var factory = Emit(new VhdlFileModel("a.vhd", [Entity("foo"), Entity("Foo")], [], [])); + + // Assert + Assert.Contains("Foo", factory.Requests); + Assert.Contains("foo-2", factory.Requests); + AssertUniqueLegalAndLinked(factory); + } + + /// The same entity name declared in two files yields two distinct pages. + [Fact] + public void VhdlNames_DuplicateEntityAcrossFiles_GetsDistinctPages() + { + // Arrange and Act + var factory = Emit( + new VhdlFileModel("a.vhd", [Entity("Dup")], [], []), + new VhdlFileModel("b.vhd", [Entity("Dup")], [], [])); + + // Assert + Assert.Contains("Dup", factory.Requests); + Assert.Contains("Dup-2", factory.Requests); + AssertUniqueLegalAndLinked(factory); + } + + /// Entities and packages named like the reserved index page never overwrite it. + [Fact] + public void VhdlNames_ReservedApiName_KeepsIndexPage() + { + // Arrange and Act + var factory = Emit(new VhdlFileModel("a.vhd", [Entity("API")], [], [Pkg("api")])); + + // Assert + Assert.Equal(1, factory.Requests.Count(r => string.Equals(r, "api", StringComparison.OrdinalIgnoreCase))); + Assert.Contains("api", factory.Requests); + Assert.Contains("API-2", factory.Requests); + Assert.Contains("api-3", factory.Requests); + AssertUniqueLegalAndLinked(factory); + } + + /// Overloaded and case-twin subprograms each keep a page in the package folder. + [Fact] + public void VhdlNames_OverloadedAndCaseTwinSubprograms_GetDistinctPages() + { + // Arrange and Act + var factory = Emit(new VhdlFileModel("a.vhd", [], [], [Pkg("P", Func("Add"), Func("Add"), Func("add"))])); + + // Assert + Assert.Contains("P/Add", factory.Requests); + Assert.Contains("P/Add-2", factory.Requests); + Assert.Contains("P/add-3", factory.Requests); + Assert.True(AssertUniqueLegalAndLinked(factory) >= 3); + } + + /// Names with illegal characters are escaped exactly once. + [Fact] + public void VhdlNames_IllegalAndDeviceNames_AreEscaped() + { + // Arrange and Act + var factory = Emit(new VhdlFileModel("a.vhd", [Entity("CON"), Entity("a/b")], [], [Pkg("Pk?", Func("op\"+\""))])); + + // Assert + Assert.Contains("CON_", factory.Requests); + Assert.Contains("a_b", factory.Requests); + Assert.Contains("Pk_", factory.Requests); + Assert.Contains("Pk_/op_+_", factory.Requests); + AssertUniqueLegalAndLinked(factory); + } + + /// Names are the same for every run. + [Fact] + public void VhdlNames_SameModel_IsDeterministic() + { + // Arrange + var model = new VhdlFileModel("a.vhd", [Entity("B"), Entity("b")], [], [Pkg("B", Func("x"), Func("X"))]); + + // Act + var first = Emit(model).Requests; + var second = Emit(model).Requests; + + // Assert + Assert.Equal(first, second); + } + + /// Every page generated from the VHDL fixtures is unique, legal, and every link resolves. + [Fact] + public void VhdlFileNaming_Fixtures_AllPagesUniqueLegalAndLinked() + { + // Arrange + var options = new VhdlGeneratorOptions + { + LibraryName = "Fixtures", + Sources = [FixturePaths.MuxVhd, FixturePaths.CounterVhd, FixturePaths.CommonTypesVhd], + }; + var context = new InMemoryContext(); + var emitter = new VhdlGenerator(options).Parse(context); + var factory = new RecordingFactory(); + + // Act + emitter.Emit(factory, new EmitConfig(), context); + + // Assert + Assert.Empty(context.Errors); + Assert.True(AssertUniqueLegalAndLinked(factory) > 0); + } +} From 20d6fa89a6d2b6e21c7341bb48e65f03512a6045 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 15 Oct 2026 01:42:24 -0400 Subject: [PATCH 05/15] refactor: remove FileNameSanitizer in favor of the symbol-name registry Update the api.md naming appendix and wording in test comments. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../api-mark-core/file-name-sanitizer.md | 90 --------- .../api-mark-core/file-name-sanitizer.yaml | 48 ----- .../api-mark-core/file-name-sanitizer.sysml | 13 -- .../api-mark-core/file-name-sanitizer.md | 64 ------ src/ApiMark.Core/FileMarkdownWriterFactory.cs | 6 +- src/ApiMark.Core/FileNameSanitizer.cs | 183 ----------------- .../DotNetEmitterGradualDisclosure.cs | 6 +- .../FileNameSanitizerTests.cs | 190 ------------------ test/ApiMark.Cpp.Tests/CppFileNamingTests.cs | 2 +- .../DotNetEmitterGradualDisclosureTests.cs | 2 +- .../DotNetFileNamingTests.cs | 2 +- .../DotNetGeneratorTests.cs | 2 +- .../ApiMark.Vhdl.Tests/VhdlFileNamingTests.cs | 2 +- 13 files changed, 13 insertions(+), 597 deletions(-) delete mode 100644 docs/design/api-mark-core/file-name-sanitizer.md delete mode 100644 docs/reqstream/api-mark-core/file-name-sanitizer.yaml delete mode 100644 docs/sysml2/model/api-mark-core/file-name-sanitizer.sysml delete mode 100644 docs/verification/api-mark-core/file-name-sanitizer.md delete mode 100644 src/ApiMark.Core/FileNameSanitizer.cs delete mode 100644 test/ApiMark.Core.Tests/FileNameSanitizerTests.cs diff --git a/docs/design/api-mark-core/file-name-sanitizer.md b/docs/design/api-mark-core/file-name-sanitizer.md deleted file mode 100644 index 520a2eb..0000000 --- a/docs/design/api-mark-core/file-name-sanitizer.md +++ /dev/null @@ -1,90 +0,0 @@ -## FileNameSanitizer - -![FileNameSanitizer Structure](ApiMarkCoreView.svg) - - - -### Purpose - -FileNameSanitizer is the single, auditable point that turns arbitrary declaration names -into file and folder name segments that are legal on both Windows and Linux. Every -language emitter calls it for every generated path segment, so the same name yields the -same segment on every platform and links always agree with the files they point to. - -### Data Model - -FileNameSanitizer is a `public static` class with no instance state. It holds three -immutable lookup sets: the characters replaced for portability, the Windows reserved -device names, and the hash length (eight hexadecimal characters). All members are pure -functions and are safe to call concurrently. - -### Key Methods - -**FileNameSanitizer.SanitizeSegment** (`public`): Converts a raw name to a legal segment. - -- *Parameters*: `string rawName` — the raw name without separators or extension. -- *Returns*: `string` — a non-empty legal segment. -- *Rules*, applied in order: - 1. `<` becomes `{` and `>` becomes `}` (the brace notation of XML documentation - identifiers); neither counts as a lossy substitution. - 2. Each character from `: " | ? * \ /`, control characters, DEL, `[ ] ( ) # % ^`, - backtick, space, and a literal `{` or `}` becomes `-` (lossy). Literal braces are - lossy so they cannot collide with the mapped `<` and `>` (`A` is `A{B}`, while - `A{B}` is `A-B--`). - 3. Trailing dots become `-` (lossy). An empty result becomes `_`. - 4. An input that is otherwise unchanged but already ends in `-` plus eight lowercase - hexadecimal characters, or whose part before the first dot is a reserved device name - followed by `-` (for example `CON-`), also counts as lossy, so it cannot equal the - output produced for another input. - 5. When a lossy substitution occurred, `-` and the first eight lowercase hexadecimal - characters of the SHA-256 of the UTF-8 original are appended so distinct originals - stay distinct. - 6. When the part before the first dot is a reserved device name (compared - case-insensitively, including the superscript digit variants), `-` is appended to - that part: `CON` becomes `CON-` and `Con.Foo` becomes `Con-.Foo`. -- *Throws*: `ArgumentNullException` when `rawName` is null. - -**FileNameSanitizer.IsLegalSegment** (`public`): Returns whether a segment is non-empty, -has none of `< > | ? * : " / \` or control characters, does not end in a dot or space, -and is not a reserved device name. - -**FileNameSanitizer.Hash** (`public`): Returns the eight-character hash suffix used above. - -### Design Decisions - -- **Distinctness guarantee**: the mapping is deterministic, and distinct inputs yield - distinct outputs except for a collision of the truncated 32-bit SHA-256 hash between two - lossy inputs. Outputs without a hash tail are produced only by inputs with no - substitution other than `<`/`>` and the reserved-name suffix, which is a one-to-one - mapping; hashed - outputs end in `-`, a form no output without a hash can have. The mapping is not - idempotent: a hashed output re-sanitized is hashed again. - -- The mapping never calls `Path.GetInvalidFileNameChars`, which differs per operating - system; fixed rules keep output identical everywhere. -- `-` is the replacement and reserved-name suffix because it cannot appear in a C# - identifier; it is only otherwise produced by the generic-arity flattening (`Foo-2`). - Hash suffixes are applied only when information is lost so ordinary names stay readable. -- Case-insensitive collisions between distinct names cannot be solved by a stateless - function; callers that can see sibling names (the .NET emitter) handle them on top of - this unit. - -### Error Handling - -`SanitizeSegment` throws `ArgumentNullException` for a null argument and otherwise never -throws. `IsLegalSegment` accepts null and returns `false`. - -### Dependencies - -N/A - FileNameSanitizer depends only on the .NET base class library -(`System.Security.Cryptography`, `System.Text`). - -### Callers - -- **FileMarkdownWriterFactory** — validates every sub-folder segment and file name with - `IsLegalSegment`. -- **ApiMark.DotNet.DotNetEmitter**, **TypeLinkResolver** — sanitize member, type, and - namespace names. -- **ApiMark.Cpp.CppEmitter** and **ApiMark.Vhdl.VhdlEmitter** — `SanitizeFileName` - delegates to `SanitizeSegment`. diff --git a/docs/reqstream/api-mark-core/file-name-sanitizer.yaml b/docs/reqstream/api-mark-core/file-name-sanitizer.yaml deleted file mode 100644 index 8d483b7..0000000 --- a/docs/reqstream/api-mark-core/file-name-sanitizer.yaml +++ /dev/null @@ -1,48 +0,0 @@ ---- -# FileNameSanitizer unit requirements -sections: - - title: ApiMarkCore Requirements - sections: - - title: FileNameSanitizer Requirements - requirements: - - id: ApiMarkCore-FileNameSanitizer-ProduceLegalSegments - title: FileNameSanitizer shall convert any raw name into a non-empty path - segment that is legal on both Windows and Linux, containing none of the - characters angle brackets, pipe, question mark, asterisk, colon, double quote, slash, backslash, or control characters, with no trailing space - or dot, and not a reserved device name (CON, PRN, AUX, NUL, COM0-9, LPT0-9), - including when the device name is followed by an extension. - justification: | - Generated documentation is checked out and built on both Windows and Linux. - A name such as an explicit interface implementation (IFoo.Method) - or a type named CON is valid source code but cannot be created on Windows. - tests: - - FileNameSanitizer_SanitizeSegment_AngleBrackets_BecomeBraces - - FileNameSanitizer_SanitizeSegment_LegalName_IsUnchanged - - FileNameSanitizer_SanitizeSegment_IllegalCharacter_IsReplacedWithHashSuffix - - FileNameSanitizer_SanitizeSegment_TrailingDot_IsLegal - - FileNameSanitizer_SanitizeSegment_ReservedDeviceName_IsMovedAside - - FileNameSanitizer_SanitizeSegment_EmptyName_ReturnsUnderscore - - FileNameSanitizer_SanitizeSegment_NullName_ThrowsArgumentNullException - - id: ApiMarkCore-FileNameSanitizer-Deterministic - title: FileNameSanitizer shall map a raw name to the same segment on every - platform and keep distinct raw names distinct by appending a hash of the - original name whenever characters were replaced, including literal braces - and inputs that imitate the sanitizer's own output forms, so that distinct - raw names yield distinct segments (up to a collision of the eight-character hash). - justification: | - Output must be reproducible across operating systems and distinct source - names must never collapse onto one file. - tests: - - FileNameSanitizer_SanitizeSegment_DistinctLossyInputs_StayDistinctAndDeterministic - - FileNameSanitizer_SanitizeSegment_AngleBracketsVersusLiteralBraces_AreDistinct - - FileNameSanitizer_SanitizeSegment_InputsImitatingOutputForms_AreDistinct - - FileNameSanitizer_SanitizeSegment_IllegalCharacter_IsReplacedWithHashSuffix - - id: ApiMarkCore-FileNameSanitizer-ValidateSegments - title: FileNameSanitizer shall report whether a segment is legal on Windows - and Linux. - justification: | - Writers and tests need one authoritative legality predicate so that any - emitter naming defect is detected instead of failing only on Windows. - tests: - - FileNameSanitizer_IsLegalSegment_IllegalName_ReturnsFalse - - FileNameSanitizer_IsLegalSegment_LegalName_ReturnsTrue diff --git a/docs/sysml2/model/api-mark-core/file-name-sanitizer.sysml b/docs/sysml2/model/api-mark-core/file-name-sanitizer.sysml deleted file mode 100644 index db9b4ed..0000000 --- a/docs/sysml2/model/api-mark-core/file-name-sanitizer.sysml +++ /dev/null @@ -1,13 +0,0 @@ -package ApiMarkCore { - part def FileNameSanitizer { - doc /* FileNameSanitizer is the single, auditable point that converts declaration names into - file and folder name segments that are legal on both Windows and Linux, deterministic, - and distinct. */ - - comment sourceRef /* Source: src/ApiMark.Core/FileNameSanitizer.cs */ - comment testRef /* Test: test/ApiMark.Core.Tests/FileNameSanitizerTests.cs */ - comment designRef /* Design: docs/design/api-mark-core/file-name-sanitizer.md */ - comment verificationRef /* Verification: docs/verification/api-mark-core/file-name-sanitizer.md */ - comment reqRef /* Requirements: docs/reqstream/api-mark-core/file-name-sanitizer.yaml */ - } -} diff --git a/docs/verification/api-mark-core/file-name-sanitizer.md b/docs/verification/api-mark-core/file-name-sanitizer.md deleted file mode 100644 index 02db571..0000000 --- a/docs/verification/api-mark-core/file-name-sanitizer.md +++ /dev/null @@ -1,64 +0,0 @@ -## FileNameSanitizer - -### Verification Approach - -FileNameSanitizer is verified with unit tests in -`test/ApiMark.Core.Tests/FileNameSanitizerTests.cs` that call the real implementation -directly. End-to-end legality of generated output is verified by -`DotNetEmitterFileNameLegalityTests` in `ApiMark.DotNet.Tests`. - -### Test Environment - -N/A - standard test environment using the .NET test runner is sufficient. - -### Acceptance Criteria - -- All `FileNameSanitizerTests` test cases pass with zero failures. -- Angle brackets become braces; legal names are unchanged. -- Every illegal character, a trailing dot, and an empty name produce a legal segment. -- Reserved device names (with and without an extension part) are moved aside. -- Distinct lossy inputs remain distinct; `A` and `A{B}` do not collide. -- Inputs imitating the sanitizer's own output forms do not collide with it. -- `IsLegalSegment` rejects every illegal shape and accepts legal names. - -### Test Scenarios - -**FileNameSanitizer_SanitizeSegment_AngleBrackets_BecomeBraces**: Verifies -`IFoo.Method` becomes `IFoo{System.Int32}.Method`. - -**FileNameSanitizer_SanitizeSegment_LegalName_IsUnchanged**: Verifies ordinary names, -hyphenated arity names, dotted names, and non-ASCII letters are unchanged. - -**FileNameSanitizer_SanitizeSegment_IllegalCharacter_IsReplacedWithHashSuffix**: Verifies -each illegal character yields a legal segment ending in the hash of the original. - -**FileNameSanitizer_SanitizeSegment_TrailingDot_IsLegal**: Verifies a trailing dot does -not survive. - -**FileNameSanitizer_SanitizeSegment_ReservedDeviceName_IsMovedAside**: Verifies `CON`, -`con`, `Aux`, `NUL`, `PRN`, `COM1`, `lpt9`, and `Con.Foo` gain a `-` suffix while `CON1` -and `Console` are unchanged. - -**FileNameSanitizer_SanitizeSegment_EmptyName_ReturnsUnderscore**: Verifies an empty name -becomes `_`. - -**FileNameSanitizer_SanitizeSegment_NullName_ThrowsArgumentNullException**: Verifies a -null argument is rejected. - -**FileNameSanitizer_SanitizeSegment_DistinctLossyInputs_StayDistinctAndDeterministic**: -Verifies different lossy inputs map to different segments and the mapping is -deterministic. - -**FileNameSanitizer_SanitizeSegment_AngleBracketsVersusLiteralBraces_AreDistinct**: -Verifies `A` becomes `A{B}` while `A{B}` gets a hash suffix and stays legal. - -**FileNameSanitizer_SanitizeSegment_InputsImitatingOutputForms_AreDistinct**: Verifies -inputs that equal a hashed output, `CON-`, or `Con-.Foo` do not collide with the output of -the names they imitate. - -**FileNameSanitizer_IsLegalSegment_IllegalName_ReturnsFalse**: Verifies each illegal shape -(null, empty, illegal characters, control characters, trailing dot or space, reserved -names including superscript variants) is rejected. - -**FileNameSanitizer_IsLegalSegment_LegalName_ReturnsTrue**: Verifies braces and hyphens -are accepted. diff --git a/src/ApiMark.Core/FileMarkdownWriterFactory.cs b/src/ApiMark.Core/FileMarkdownWriterFactory.cs index dbf7712..54976f8 100644 --- a/src/ApiMark.Core/FileMarkdownWriterFactory.cs +++ b/src/ApiMark.Core/FileMarkdownWriterFactory.cs @@ -57,7 +57,7 @@ public FileMarkdownWriterFactory(string outputDirectory) /// Thrown when is null, empty, or whitespace. /// Also thrown when or any /-separated segment of /// is not a legal file or folder name on Windows and Linux - /// (see ). + /// (see ). /// public IMarkdownWriter CreateMarkdown(string subFolder, string name) { @@ -69,7 +69,7 @@ public IMarkdownWriter CreateMarkdown(string subFolder, string name) // Defense in depth: every segment must be legal on Windows and Linux so a naming bug in an // emitter fails loudly and identically on every platform instead of only on Windows. - if (!FileNameSanitizer.IsLegalSegment(name)) + if (!FileNameRules.IsLegalSegment(name)) { throw new ArgumentException($"File name '{name}' is not a legal file name on Windows and Linux.", nameof(name)); } @@ -77,7 +77,7 @@ public IMarkdownWriter CreateMarkdown(string subFolder, string name) if (!string.IsNullOrWhiteSpace(subFolder)) { var illegal = subFolder.Split('/', StringSplitOptions.RemoveEmptyEntries) - .FirstOrDefault(segment => !FileNameSanitizer.IsLegalSegment(segment)); + .FirstOrDefault(segment => !FileNameRules.IsLegalSegment(segment)); if (illegal != null) { throw new ArgumentException($"Folder name '{illegal}' is not a legal folder name on Windows and Linux.", nameof(subFolder)); diff --git a/src/ApiMark.Core/FileNameSanitizer.cs b/src/ApiMark.Core/FileNameSanitizer.cs deleted file mode 100644 index b73086a..0000000 --- a/src/ApiMark.Core/FileNameSanitizer.cs +++ /dev/null @@ -1,183 +0,0 @@ -using System.Security.Cryptography; -using System.Text; - -namespace ApiMark.Core; - -/// -/// Produces and validates file and folder name segments that are legal on both Windows and Linux. -/// -/// -/// -/// All language emitters route every generated path segment through -/// so that output can be written on, and checked out onto, any -/// supported operating system. The mapping is a pure function of its input: the same raw name -/// always produces the same segment on every platform, independent of -/// . -/// -/// -/// The rules are: < becomes { and > becomes } (the -/// brace notation of XML documentation identifiers); every other illegal or link-hostile -/// character (: " | ? * \ /, control characters, [ ] ( ) # % ^ `, space, and a -/// literal { or }, which must not collide with the mapped brackets) becomes -/// -, in which case an eight-character hexadecimal hash of the original name is appended so -/// that distinct originals stay distinct; a trailing dot or space becomes - (with the -/// hash); and a Windows reserved device name (CON, PRN, AUX, NUL, -/// COM0-9, LPT0-9, compared case-insensitively on the part before the first dot) -/// gets a - suffix on that part. -/// -/// -public static class FileNameSanitizer -{ - /// Number of hexadecimal characters of the SHA-256 hash appended to lossy substitutions. - private const int HashLength = 8; - - /// Characters that are illegal in a Windows file name, plus link-hostile characters replaced for portability. - private static readonly HashSet ReplacedCharacters = - [ - ':', '"', '|', '?', '*', '\\', '/', '[', ']', '(', ')', '#', '%', '^', '`', ' ', - ]; - - /// Windows reserved device names that may not be used as a file name or its base name. - private static readonly HashSet ReservedNames = new(StringComparer.OrdinalIgnoreCase) - { - "CON", "PRN", "AUX", "NUL", - "COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", - "LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", - "COM\u00B9", "COM\u00B2", "COM\u00B3", "LPT\u00B9", "LPT\u00B2", "LPT\u00B3", - }; - - /// - /// Converts an arbitrary raw name into a single path segment that is legal on Windows and Linux. - /// - /// The raw name; must not contain a file extension to preserve. - /// - /// A deterministic, legal, non-empty segment. Names that are already legal are returned unchanged - /// (except for the </> brace mapping and the reserved-name suffix). - /// - /// Thrown when is null. - public static string SanitizeSegment(string rawName) - { - ArgumentNullException.ThrowIfNull(rawName); - - var builder = new StringBuilder(rawName.Length); - var lossy = false; - foreach (var c in rawName) - { - if (c == '<') - { - builder.Append('{'); - } - else if (c == '>') - { - builder.Append('}'); - } - else if (ReplacedCharacters.Contains(c) || c is '{' or '}' || c < 0x20 || c == 0x7F) - { - // Literal braces are replaced so they cannot collide with the mapped '<' and '>'. - builder.Append('-'); - lossy = true; - } - else - { - builder.Append(c); - } - } - - // Inputs that already look like one of this method's own outputs (a "-xxxxxxxx" hash tail, or a - // reserved device name followed by the "-" suffix) are hashed so they cannot collide with it. - if (!lossy && (HasHashTail(rawName) || LooksLikeSuffixedReservedName(rawName))) - { - lossy = true; - } - - // Windows silently strips trailing dots and spaces, so they must not appear. - while (builder.Length > 0 && builder[^1] == '.') - { - builder[^1] = '-'; - lossy = true; - } - - if (builder.Length == 0) - { - builder.Append('_'); - lossy = rawName.Length > 0; - } - - // Hash the original so distinct inputs that collapse to the same text stay distinct. - if (lossy) - { - builder.Append('-').Append(Hash(rawName)); - } - - // A reserved device name is checked on the part before the first dot ("Con.Foo" is reserved). - var text = builder.ToString(); - var dot = text.IndexOf('.'); - var baseName = dot < 0 ? text : text[..dot]; - if (ReservedNames.Contains(baseName.TrimEnd(' '))) - { - text = dot < 0 ? text + "-" : text[..dot] + "-" + text[dot..]; - } - - return text; - } - - /// - /// Determines whether a name is a legal single file or folder name segment on Windows and Linux. - /// - /// The segment to test (a file name without extension, or a folder name). - /// - /// when the segment is non-empty, contains none of - /// < > | ? * : " / \ or control characters, does not end in a dot or space, and is not a - /// reserved device name; otherwise . - /// - public static bool IsLegalSegment(string? name) - { - if (string.IsNullOrEmpty(name)) - { - return false; - } - - if (name.Any(c => c is '<' or '>' or '|' or '?' or '*' or ':' or '"' or '/' or '\\' || c < 0x20 || c == 0x7F)) - { - return false; - } - - if (name[^1] is '.' or ' ') - { - return false; - } - - var dot = name.IndexOf('.'); - var baseName = dot < 0 ? name : name[..dot]; - return !ReservedNames.Contains(baseName.TrimEnd(' ')); - } - - /// Determines whether a name ends in - followed by exactly eight lowercase hexadecimal characters. - /// The name to test. - /// when the name ends with a hash tail. - private static bool HasHashTail(string name) - { - if (name.Length < HashLength + 1 || name[^(HashLength + 1)] != '-') - { - return false; - } - - return name[^HashLength..].All(c => c is (>= '0' and <= '9') or (>= 'a' and <= 'f')); - } - - /// Determines whether the part of a name before its first dot is a reserved device name plus a -. - /// The name to test. - /// when the name equals the output form of a reserved-name rewrite. - private static bool LooksLikeSuffixedReservedName(string name) - { - var dot = name.IndexOf('.'); - var baseName = dot < 0 ? name : name[..dot]; - return baseName.EndsWith('-') && ReservedNames.Contains(baseName[..^1]); - } - - /// Computes the lowercase hexadecimal hash suffix for a raw name. - /// The original name. - /// The first hexadecimal characters of the SHA-256 of its UTF-8 bytes. - public static string Hash(string rawName) => - Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(rawName)))[..HashLength].ToLowerInvariant(); -} diff --git a/src/ApiMark.DotNet/DotNetEmitterGradualDisclosure.cs b/src/ApiMark.DotNet/DotNetEmitterGradualDisclosure.cs index 5fe6da0..23c4603 100644 --- a/src/ApiMark.DotNet/DotNetEmitterGradualDisclosure.cs +++ b/src/ApiMark.DotNet/DotNetEmitterGradualDisclosure.cs @@ -163,7 +163,11 @@ private void EmitGradualDisclosure(IMarkdownWriterFactory factory) // the namespace table is immediately accessible without scrolling past prose apiWriter.WriteHeading(2, "File Naming and Path Convention"); apiWriter.WriteParagraph( - "Documentation paths are derived deterministically from fully-qualified symbol names."); + "Documentation paths are derived deterministically from fully-qualified symbol names. " + + "A generic type adds its arity in braces (`Foo{1}`), illegal characters become `_`, and when two " + + "symbols would share a name (compared without regard to case) the plain name goes to the " + + "namespace, then the type, then the member, and the other symbol gets a numeric suffix (`-2`) " + + "only as a last resort."); var conventionHeaders = new[] { "Symbol kind", "Path pattern" }; var conventionRows = new[] { diff --git a/test/ApiMark.Core.Tests/FileNameSanitizerTests.cs b/test/ApiMark.Core.Tests/FileNameSanitizerTests.cs deleted file mode 100644 index 8036f84..0000000 --- a/test/ApiMark.Core.Tests/FileNameSanitizerTests.cs +++ /dev/null @@ -1,190 +0,0 @@ -using ApiMark.Core; -using Xunit; - -namespace ApiMark.Core.Tests; - -/// Verifies produces legal, deterministic, distinct names. -public class FileNameSanitizerTests -{ - /// Validates that angle brackets map to braces without a hash suffix. - [Fact] - public void FileNameSanitizer_SanitizeSegment_AngleBrackets_BecomeBraces() - { - // Arrange / Act - var result = FileNameSanitizer.SanitizeSegment("IFoo.Method"); - - // Assert - Assert.Equal("IFoo{System.Int32}.Method", result); - } - - /// Validates that ordinary names are returned unchanged. - /// The legal name. - [Theory] - [InlineData("MyClass")] - [InlineData("Foo-2")] - [InlineData("Ns.Sub.Type")] - [InlineData("caf\u00e9")] - public void FileNameSanitizer_SanitizeSegment_LegalName_IsUnchanged(string name) - { - // Arrange / Act / Assert - Assert.Equal(name, FileNameSanitizer.SanitizeSegment(name)); - } - - /// Validates that every illegal character is replaced and a hash suffix is appended. - /// The raw name containing an illegal character. - [Theory] - [InlineData("a:b")] - [InlineData("a\"b")] - [InlineData("a|b")] - [InlineData("a?b")] - [InlineData("a*b")] - [InlineData("a/b")] - [InlineData("a\\b")] - [InlineData("a\tb")] - [InlineData("a\u007Fb")] - [InlineData("int[]")] - [InlineData("a b")] - public void FileNameSanitizer_SanitizeSegment_IllegalCharacter_IsReplacedWithHashSuffix(string name) - { - // Arrange / Act - var result = FileNameSanitizer.SanitizeSegment(name); - - // Assert - Assert.True(FileNameSanitizer.IsLegalSegment(result), result); - Assert.EndsWith(FileNameSanitizer.Hash(name), result, StringComparison.Ordinal); - } - - /// Validates that a trailing dot is not preserved. - [Fact] - public void FileNameSanitizer_SanitizeSegment_TrailingDot_IsLegal() - { - // Arrange / Act - var result = FileNameSanitizer.SanitizeSegment("name."); - - // Assert - Assert.True(FileNameSanitizer.IsLegalSegment(result), result); - } - - /// Validates that reserved device names are moved aside, with or without an extension part. - /// The reserved name. - /// The expected sanitized name. - [Theory] - [InlineData("CON", "CON-")] - [InlineData("con", "con-")] - [InlineData("Aux", "Aux-")] - [InlineData("NUL", "NUL-")] - [InlineData("PRN", "PRN-")] - [InlineData("COM1", "COM1-")] - [InlineData("lpt9", "lpt9-")] - [InlineData("Con.Foo", "Con-.Foo")] - [InlineData("CON1", "CON1")] - [InlineData("Console", "Console")] - public void FileNameSanitizer_SanitizeSegment_ReservedDeviceName_IsMovedAside(string name, string expected) - { - // Arrange / Act / Assert - Assert.Equal(expected, FileNameSanitizer.SanitizeSegment(name)); - } - - /// Validates that an empty name still yields a legal segment. - [Fact] - public void FileNameSanitizer_SanitizeSegment_EmptyName_ReturnsUnderscore() - { - // Arrange / Act / Assert - Assert.Equal("_", FileNameSanitizer.SanitizeSegment(string.Empty)); - } - - /// Validates that a null name is rejected. - [Fact] - public void FileNameSanitizer_SanitizeSegment_NullName_ThrowsArgumentNullException() - { - // Arrange / Act / Assert - Assert.Throws(() => FileNameSanitizer.SanitizeSegment(null!)); - } - - /// Validates that distinct lossy inputs stay distinct and the mapping is deterministic. - [Fact] - public void FileNameSanitizer_SanitizeSegment_DistinctLossyInputs_StayDistinctAndDeterministic() - { - // Arrange - string[] inputs = ["a:b", "a|b", "a?b", "a_b", "A", "CON", "x.", "x-"]; - - // Act - var results = inputs.Select(FileNameSanitizer.SanitizeSegment).ToList(); - - // Assert - Assert.Equal(inputs.Length, results.Distinct(StringComparer.Ordinal).Count()); - Assert.Equal(FileNameSanitizer.SanitizeSegment("a:b"), FileNameSanitizer.SanitizeSegment("a:b")); - Assert.NotEqual(FileNameSanitizer.SanitizeSegment("a:b"), FileNameSanitizer.SanitizeSegment("a|b")); - } - - /// Validates that angle brackets and literal braces do not collide (F-001). - [Fact] - public void FileNameSanitizer_SanitizeSegment_AngleBracketsVersusLiteralBraces_AreDistinct() - { - // Arrange / Act - var angle = FileNameSanitizer.SanitizeSegment("A"); - var brace = FileNameSanitizer.SanitizeSegment("A{B}"); - - // Assert - Assert.Equal("A{B}", angle); - Assert.NotEqual(angle, brace); - Assert.True(FileNameSanitizer.IsLegalSegment(brace), brace); - Assert.EndsWith(FileNameSanitizer.Hash("A{B}"), brace, StringComparison.Ordinal); - } - - /// Validates that inputs imitating the sanitizer's own output forms do not collide with it. - [Fact] - public void FileNameSanitizer_SanitizeSegment_InputsImitatingOutputForms_AreDistinct() - { - // Arrange - var hashed = FileNameSanitizer.SanitizeSegment("a:b"); - string[] inputs = ["a:b", hashed, "CON", "CON-", "Con.Foo", "Con-.Foo", "a", "a{b}", "a}b{"]; - - // Act - var results = inputs.Select(FileNameSanitizer.SanitizeSegment).ToList(); - - // Assert - Assert.Equal(inputs.Length, results.Distinct(StringComparer.Ordinal).Count()); - Assert.All(results, r => Assert.True(FileNameSanitizer.IsLegalSegment(r), r)); - Assert.All(inputs, i => Assert.Equal(FileNameSanitizer.SanitizeSegment(i), FileNameSanitizer.SanitizeSegment(i))); - } - - /// Validates the legality predicate rejects each illegal shape. - /// The illegal name. - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData("ab")] - [InlineData("a:b")] - [InlineData("a/b")] - [InlineData("a\\b")] - [InlineData("a\"b")] - [InlineData("a|b")] - [InlineData("a?b")] - [InlineData("a*b")] - [InlineData("a\u0001b")] - [InlineData("a.")] - [InlineData("a ")] - [InlineData("CON")] - [InlineData("nul.txt")] - [InlineData("COM\u00B9")] - public void FileNameSanitizer_IsLegalSegment_IllegalName_ReturnsFalse(string? name) - { - // Arrange / Act / Assert - Assert.False(FileNameSanitizer.IsLegalSegment(name)); - } - - /// Validates the legality predicate accepts legal names, including braces and hyphens. - /// The legal name. - [Theory] - [InlineData("api")] - [InlineData("IFoo{T}.Method")] - [InlineData("CON-")] - [InlineData("Foo-1")] - public void FileNameSanitizer_IsLegalSegment_LegalName_ReturnsTrue(string name) - { - // Arrange / Act / Assert - Assert.True(FileNameSanitizer.IsLegalSegment(name)); - } -} diff --git a/test/ApiMark.Cpp.Tests/CppFileNamingTests.cs b/test/ApiMark.Cpp.Tests/CppFileNamingTests.cs index f2f16fc..f9378e5 100644 --- a/test/ApiMark.Cpp.Tests/CppFileNamingTests.cs +++ b/test/ApiMark.Cpp.Tests/CppFileNamingTests.cs @@ -177,7 +177,7 @@ public void CppNames_ClassEnumAliasFunctionSameName_AreSeparatedInKindOrder() Assert.Equal("lib/Foo-4", names.Path(CppNames.FunctionKey(ns, "Foo"))); } - /// Declarations that differ only by case are separate pages; the ordinally first keeps the plain name. + /// Declarations that differ only by case are separate pages; the first in ordinal order keeps the plain name. [Fact] public void CppNames_CaseTwinClasses_SecondGetsSuffix() { diff --git a/test/ApiMark.DotNet.Tests/DotNetEmitterGradualDisclosureTests.cs b/test/ApiMark.DotNet.Tests/DotNetEmitterGradualDisclosureTests.cs index ac53324..ebaba61 100644 --- a/test/ApiMark.DotNet.Tests/DotNetEmitterGradualDisclosureTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetEmitterGradualDisclosureTests.cs @@ -283,7 +283,7 @@ public void DotNetEmitterGradualDisclosure_Emit_ValidModel_CreatesMemberDetailPa /// Validates that the gradual-disclosure emitter creates a combined page for case-colliding members. [Fact] - public void DotNetEmitterGradualDisclosure_Emit_CaseCollision_CreatesCombinedPage() + public void DotNetEmitterGradualDisclosure_Emit_CaseCollision_CreatesSeparatePages() { // Arrange var factory = new InMemoryMarkdownWriterFactory(); diff --git a/test/ApiMark.DotNet.Tests/DotNetFileNamingTests.cs b/test/ApiMark.DotNet.Tests/DotNetFileNamingTests.cs index db29347..7e0c828 100644 --- a/test/ApiMark.DotNet.Tests/DotNetFileNamingTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetFileNamingTests.cs @@ -70,7 +70,7 @@ public void DotNetNames_NamespaceAndTypeShareAPath_NamespaceWinsAndTypeIsSuffixe Assert.Equal("A/B/C", names.Registry.GetPath("T:A.B.C")); } - /// Case-twin namespaces get separate paths; the ordinally first keeps the plain name. + /// Case-twin namespaces get separate paths; the first in ordinal order keeps the plain name. [Fact] public void DotNetNames_CaseTwinNamespaces_SecondGetsSuffix() { diff --git a/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs b/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs index 1ecef8a..a4ceaf6 100644 --- a/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetGeneratorTests.cs @@ -1284,7 +1284,7 @@ public void DotNetGenerator_Generate_RemarksWithBulletList_RendersListInMarkdown /// /// Validates that two members whose names differ only in case get separate pages: the - /// ordinally first keeps its plain name and the other receives the -2 collision suffix. + /// first in ordinal order keeps its plain name and the other receives the -2 collision suffix. /// [Fact] public void DotNetGenerator_Generate_CaseCollisionClass_CreatesSeparatePages() diff --git a/test/ApiMark.Vhdl.Tests/VhdlFileNamingTests.cs b/test/ApiMark.Vhdl.Tests/VhdlFileNamingTests.cs index f3e95f5..a85acc6 100644 --- a/test/ApiMark.Vhdl.Tests/VhdlFileNamingTests.cs +++ b/test/ApiMark.Vhdl.Tests/VhdlFileNamingTests.cs @@ -144,7 +144,7 @@ public void VhdlNames_EntityAndPackageCaseTwins_AreSeparated() AssertUniqueLegalAndLinked(factory); } - /// Entities that differ only by case are separated; the ordinally first keeps the plain name. + /// Entities that differ only by case are separated; the first in ordinal order keeps the plain name. [Fact] public void VhdlNames_CaseTwinEntities_SecondGetsSuffix() { From f814890cd4c02a04587db8a1067534d598c9d035 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 15 Oct 2026 01:42:25 -0400 Subject: [PATCH 06/15] docs: document symbol-name registry and file naming rules Add numbered naming rules, design and verification docs, requirements, SysML2 model, review set, user guide notes, and breaking change notes (Foo-1 -> Foo{1}). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .reviewmark.yaml | 19 ++- docs/build_notes/introduction.md | 16 ++ docs/design/api-mark-core.md | 12 +- .../file-markdown-writer-factory.md | 4 +- .../design/api-mark-core/file-naming-rules.md | 155 ++++++++++++++++++ .../api-mark-core/symbol-name-registry.md | 65 ++++++++ .../cpp-emitter-gradual-disclosure.md | 15 +- docs/design/api-mark-cpp/cpp-emitter.md | 18 +- .../api-mark-cpp/cpp-type-link-resolver.md | 2 +- .../dot-net-emitter-gradual-disclosure.md | 23 ++- .../api-mark-dot-net/dot-net-emitter.md | 11 +- .../api-mark-dot-net/type-link-resolver.md | 4 +- .../api-mark-dot-net/type-name-simplifier.md | 10 +- docs/design/api-mark-vhdl/vhdl-emitter.md | 19 +-- docs/design/definition.yaml | 3 +- docs/reqstream/api-mark-core.yaml | 22 ++- .../api-mark-core/symbol-name-registry.yaml | 86 ++++++++++ .../cpp-emitter-gradual-disclosure.yaml | 8 +- docs/reqstream/api-mark-cpp/cpp-emitter.yaml | 26 +-- .../api-mark-cpp/cpp-type-link-resolver.yaml | 4 +- docs/reqstream/api-mark-dot-net.yaml | 2 +- .../dot-net-emitter-gradual-disclosure.yaml | 20 +-- .../type-name-simplifier.yaml | 14 +- .../vhdl-emitter-gradual-disclosure.yaml | 10 ++ docs/sysml2/model/api-mark-core.sysml | 2 +- .../api-mark-core/symbol-name-registry.sysml | 14 ++ docs/user_guide/cpp.md | 11 +- docs/user_guide/dotnet.md | 47 ++++-- docs/user_guide/vhdl.md | 15 +- .../api-mark-core/symbol-name-registry.md | 52 ++++++ .../cpp-emitter-gradual-disclosure.md | 13 +- docs/verification/api-mark-cpp/cpp-emitter.md | 21 ++- .../api-mark-cpp/cpp-type-link-resolver.md | 4 +- .../dot-net-emitter-gradual-disclosure.md | 15 +- .../api-mark-dot-net/dot-net-generator.md | 16 +- .../api-mark-dot-net/type-link-resolver.md | 2 +- .../api-mark-dot-net/type-name-simplifier.md | 5 +- docs/verification/definition.yaml | 2 +- docs/verification/introduction.md | 8 +- requirements.yaml | 2 +- 40 files changed, 606 insertions(+), 191 deletions(-) create mode 100644 docs/design/api-mark-core/file-naming-rules.md create mode 100644 docs/design/api-mark-core/symbol-name-registry.md create mode 100644 docs/reqstream/api-mark-core/symbol-name-registry.yaml create mode 100644 docs/sysml2/model/api-mark-core/symbol-name-registry.sysml create mode 100644 docs/verification/api-mark-core/symbol-name-registry.md diff --git a/.reviewmark.yaml b/.reviewmark.yaml index 2b61d67..8d2104b 100644 --- a/.reviewmark.yaml +++ b/.reviewmark.yaml @@ -170,17 +170,22 @@ reviews: - docs/verification/api-mark-core/path-helpers.md - src/ApiMark.Core/PathHelpers.cs - test/ApiMark.Core.Tests/PathHelpersTests.cs - - id: ApiMark-Core-FileNameSanitizer - title: Review that ApiMark Core FileNameSanitizer Implementation is Correct + - id: ApiMark-Core-SymbolNameRegistry + title: Review that ApiMark Core SymbolNameRegistry Implementation is Correct context: - docs/design/api-mark-core.md - docs/reqstream/api-mark-core.yaml paths: - - docs/reqstream/api-mark-core/file-name-sanitizer.yaml - - docs/design/api-mark-core/file-name-sanitizer.md - - docs/verification/api-mark-core/file-name-sanitizer.md - - src/ApiMark.Core/FileNameSanitizer.cs - - test/ApiMark.Core.Tests/FileNameSanitizerTests.cs + - docs/reqstream/api-mark-core/symbol-name-registry.yaml + - docs/design/api-mark-core/symbol-name-registry.md + - docs/design/api-mark-core/file-naming-rules.md + - docs/verification/api-mark-core/symbol-name-registry.md + - src/ApiMark.Core/SymbolNameRegistry.cs + - src/ApiMark.Core/FileNameRules.cs + - src/ApiMark.Core/LinkPath.cs + - test/ApiMark.Core.Tests/SymbolNameRegistryTests.cs + - test/ApiMark.Core.Tests/FileNameRulesTests.cs + - test/ApiMark.Core.Tests/LinkPathTests.cs - id: ApiMark-Core-GlobFileCollector title: Review that ApiMark Core GlobFileCollector Implementation is Correct context: diff --git a/docs/build_notes/introduction.md b/docs/build_notes/introduction.md index 7110543..3c01944 100644 --- a/docs/build_notes/introduction.md +++ b/docs/build_notes/introduction.md @@ -41,6 +41,22 @@ Then build and test the solution: pwsh ./build.ps1 ``` +## Breaking Changes + +Generated file names changed. All file and folder names now come from one symbol-name registry that +resolves every collision before anything is written. Update any links that point at the old names: + +| Old name | New name | +| -------------------------------- | ---------------------------------------- | +| `Foo-1.md` (`Foo`) | `Foo{1}.md` | +| `Name-.md` (illegal char) | `Name_.md` (character replaced by `_`) | +| `CON-.md` (reserved name) | `CON_.md` | +| `operators-.md` (member clash) | `operators-2.md` | +| One combined page for case twins | `Name.md` and `name-2.md` | +| `IFoo{System.Int32}.Get` | `IFoo{int}.Get` | + +The complete rules are in the user guides and `docs/design/api-mark-core/file-naming-rules.md`. + ## References [ApiMark releases](https://github.com/DemaConsulting/ApiMark/releases) diff --git a/docs/design/api-mark-core.md b/docs/design/api-mark-core.md index 6764969..f2eda47 100644 --- a/docs/design/api-mark-core.md +++ b/docs/design/api-mark-core.md @@ -27,7 +27,7 @@ flowchart TD FileMarkdownWriter --> |implements| IMarkdownWriter IMarkdownWriterFactory --> |creates| IMarkdownWriter FileMarkdownWriterFactory --> |uses| PathHelpers - FileMarkdownWriterFactory --> |validates with| FileNameSanitizer + FileMarkdownWriterFactory --> |validates with| FileNameRules IApiEmitter --> |Emit uses| IMarkdownWriterFactory IApiEmitter --> |Emit uses| EmitConfig IApiEmitter --> |Emit uses| IContext @@ -181,10 +181,12 @@ language-agnostic result types for a documentation-coverage scan. cross-platform path-normalization helpers used both internally by ApiMarkCore implementations and directly by other systems. -**FileNameSanitizer (public)**: Public static utility that converts declaration names -into file and folder name segments that are legal on Windows and Linux. Used by -`FileMarkdownWriterFactory` for validation and by the language emitters for naming. See -`docs/design/api-mark-core/file-name-sanitizer.md`. +**SymbolNameRegistry (public)**: Registry that assigns every generated page a unique, legal +file name before any file or link is written; **FileNameRules** is its character-level +escaping and legality check, and **LinkPath** builds relative link paths. Used by the +language emitters for naming and by `FileMarkdownWriterFactory` for validation. See +`docs/design/api-mark-core/symbol-name-registry.md` and +`docs/design/api-mark-core/file-naming-rules.md`. - *Type*: In-process .NET public API. - *Role*: Shared utility — `FileMarkdownWriterFactory` uses `SafePathCombine` diff --git a/docs/design/api-mark-core/file-markdown-writer-factory.md b/docs/design/api-mark-core/file-markdown-writer-factory.md index f85445e..39e5474 100644 --- a/docs/design/api-mark-core/file-markdown-writer-factory.md +++ b/docs/design/api-mark-core/file-markdown-writer-factory.md @@ -45,7 +45,7 @@ Creates a new `FileMarkdownWriter` targeting `{outputDirectory}/{subFolder}/{nam - *Algorithm*: 1. Reject `name` if null or whitespace; throw `ArgumentException`. 2. Reject `name`, and every `/`-separated segment of `subFolder`, that fails - `FileNameSanitizer.IsLegalSegment`; throw `ArgumentException`. + `FileNameRules.IsLegalSegment`; throw `ArgumentException`. 3. Compute the target directory: when `subFolder` is null or whitespace, use `_outputDirectory` directly; otherwise call `PathHelpers.SafePathCombine` to combine `_outputDirectory` with `subFolder`. @@ -78,7 +78,7 @@ Creates a new `FileMarkdownWriter` targeting `{outputDirectory}/{subFolder}/{nam - **PathHelpers** — `SafePathCombine` is used to combine the output root with the caller-supplied subFolder and the file name, preventing directory-traversal attacks. -- **FileNameSanitizer** — `IsLegalSegment` validates each folder segment and the file name. +- **FileNameRules** — `IsLegalSegment` validates each folder segment and the file name. ### Callers diff --git a/docs/design/api-mark-core/file-naming-rules.md b/docs/design/api-mark-core/file-naming-rules.md new file mode 100644 index 0000000..df05243 --- /dev/null +++ b/docs/design/api-mark-core/file-naming-rules.md @@ -0,0 +1,155 @@ +## File Naming Rules + + + +### Purpose + +This document is the complete, numbered specification of the file and folder names that ApiMark +generates for .NET, C++, and VHDL. Anyone can follow the rules by hand to predict the path of any page. +The rules are implemented once, in `FileNameRules` (NR-06 to NR-09) and `SymbolNameRegistry` (NR-14 to +NR-16), and are fed by the language-specific name builders `DotNetNames`, `CppNames`, and `VhdlNames`. + +### Data Model + +N/A - this document defines rules only. The registry data model is in +`docs/design/api-mark-core/symbol-name-registry.md`. + +### Key Methods + +N/A - see the unit design documents for the implementing classes. + +### Rules + +#### NR-01 Output layout + +`api.md` is the index at the output root. Each namespace has a page and, when it has children, a folder of +the same name. Each type has a page `{namespace folder}/{Type}.md` and a folder `{namespace folder}/{Type}/` +that holds its member pages and `operators.md`. The names `api` (root) and `operators` (type folder) are +reserved: they always keep their name. + +#### NR-02 Identity names + +A symbol whose own name is already a legal segment is used exactly as written (`Widget.md`). Unicode letters +are kept as written. + +#### NR-03 Namespaces + +A root namespace is one atomic segment that keeps its dots (`ApiMark.Core` is `ApiMark.Core.md`). Child +namespaces use the remaining name below their root (`Root/Inner.md`). Roots are computed from every +namespace of the assembly, including namespaces that are not visible, so names do not change with the +visibility filter. A namespace beats a type of the same name (NR-14). + +#### NR-04 Generic types + +A generic type adds its own arity in braces: `Foo` is `Foo{1}`, `Foo` is `Foo{2}`. Only the arity +declared on that type counts, not its parents'. A non-generic type is unchanged, so `Foo1` and `Foo{1}` +never collide. Generic methods get no arity. + +#### NR-05 Nested types + +A nested type is placed in its host's folder: `Outer{1}/Inner{1}.md`; each segment applies NR-04 on its own. + +#### NR-06 Illegal and link-hostile characters + +Every one of `< > : " | ? * \ /`, space, `[ ] ( ) # % ^`, the backtick, and control characters becomes `_`. +The braces `{` and `}` are structural (NR-04, NR-13) and are kept. + +#### NR-07 Trailing dot and empty names + +A trailing dot becomes `_`. An empty name becomes `_`. + +#### NR-08 Windows reserved device names + +If the part before the first dot is `CON`, `PRN`, `AUX`, `NUL`, `COM0-9`, or `LPT0-9` (any case, including the +superscript digit variants), `_` is appended to that part: `CON` is `CON_`, `Con.Foo` is `Con_.Foo`. + +#### NR-09 Unicode form and length + +Names are normalized to Unicode form C. A name longer than 200 UTF-16 code units is truncated (without +splitting a surrogate pair); any collision this causes is resolved by NR-15. + +#### NR-10 Members + +Each member (method, property, field, event, constant) has a page in its type's folder named after the member. +All overloads share one page. Members that differ only in case are separate symbols and get separate pages +(NR-14, NR-15). A member named `operators` is displaced from the reserved page. + +#### NR-11 Constructors + +A constructor page is named after the declaring type's simple name without arity. + +#### NR-12 Operators + +All operators and conversion operators of a type share the type's `operators.md` page. + +#### NR-13 Explicit interface members + +An explicit implementation keeps the interface's type arguments in braces, using C# aliases and no +namespace: `IFoo.Get` is `IFoo{int}.Get`; several arguments are joined with `-` +(`IPair{int-string}.Get`); a nested generic argument is nested (`IFoo{List{int}}.Get`); an array argument is +`Array{E}`; an interface that is only generic by arity (all arguments are open type parameters) uses its arity +(`IFoo{1}.Get`). + +#### NR-14 Claim tiers + +Names are compared case-insensitively within one folder. Claims are granted in this order: reserved pages +first; then names that are exactly the symbol's own name (tier 0); then names that needed any +transformation, including braces (tier 1). Inside a tier a namespace beats a type, which beats a member; +remaining ties go to the lower ordinal key. The loser does not take the name. + +Example: `namespace A { class B {} }` and `namespace A.B { class C {} }` both want `A/B`. The namespace +`A.B` keeps `A/B.md` and `A/B/`; the type `A.B` is displaced to `A/B-2.md`. + +#### NR-15 Numeric suffix (last resort) + +A displaced symbol gets `-N` appended to its base name, with N starting at 2 and increasing until the +name is free, in the same deterministic order (`Name.md`, `name-2.md`). This suffix is used only when +every predictable form is taken. + +#### NR-16 Displaced parents carry their children + +A symbol's page and folder share one name, so when a namespace or type is displaced, its children move with +it into the displaced folder. + +#### NR-17 C++ + +Namespaces use the qualified key (`a::b`) as one escaped segment; templates use `Foo{int}`; a class, +enum, alias, and function with one name are separated in that kind order; overloaded free functions share one +page. Reserved pages `api` and `operators` apply. The global namespace is `global`. + +#### NR-18 VHDL + +Entities and packages sit at the root. An entity beats a package with the same name; case twins (VHDL +identifiers are case-insensitive) get NR-15 suffixes; `api` is reserved. Each subprogram overload has its own +page (`Add.md`, `Add-2.md`). + +#### NR-19 Links + +Every link is a relative path with forward slashes built from the registry's approved names. Link-hostile +characters in the destination (`%`, space, parentheses, `#`) are percent-encoded; braces are literal. Names +are escaped once, at registration, and never again. + +#### NR-20 External assemblies + +A link to a type in another (mapped) package uses that type's predictable name (NR-02 to NR-09), because the +other package's collisions cannot be known. + +### Design Decisions + +- Predictability first: a plain name wins over a suffixed one, and `-N` is only the last resort. +- Every symbol, visible or not, is registered so names are stable regardless of visibility. +- Migrating from earlier releases: `Foo-1` is now `Foo{1}`; hash-suffixed names are now `_` and `-N` names; + reserved names use a `_` suffix instead of `-`. + +### Error Handling + +N/A - these rules are total; every name maps to a legal segment. + +### Dependencies + +N/A - specification only. + +### Callers + +N/A - specification only. diff --git a/docs/design/api-mark-core/symbol-name-registry.md b/docs/design/api-mark-core/symbol-name-registry.md new file mode 100644 index 0000000..0e84fa6 --- /dev/null +++ b/docs/design/api-mark-core/symbol-name-registry.md @@ -0,0 +1,65 @@ +## SymbolNameRegistry + +![SymbolNameRegistry Structure](ApiMarkCoreView.svg) + + + +### Purpose + +SymbolNameRegistry is the single source of truth for the file and folder name of every generated page. +Emitters register every symbol that needs a file, the registry resolves all names once so that none +collide, and only then do writers and link resolvers read the approved names. A name is escaped exactly once, +which removes the double-escaping bugs of earlier releases. `FileNameRules` holds the character-level +escaping and legality check, and `LinkPath` builds relative link paths. The numbered rules are in +`docs/design/api-mark-core/file-naming-rules.md`. + +### Data Model + +`SymbolNameRegistry` holds a dictionary of entries keyed by an ordinal key. Each entry records its parent +key, raw name, `SymbolKind` (`Reserved`, `Namespace`, `Type`, `Member`), and, after `Resolve`, its escaped +base name, tier, final name, and path. `FileNameRules` and `LinkPath` are `public static` classes without +state. + +### Key Methods + +**SymbolNameRegistry.Register** (`public`): Adds a symbol (`key`, `parentKey`, `rawName`, `kind`). Throws +`ArgumentException` for an empty or duplicate key or a missing parent, and `InvalidOperationException` after +`Resolve`. + +**SymbolNameRegistry.Resolve** (`public`): Escapes every raw name once, then for each directory grants +names in tier order (rules NR-14 to NR-16) and gives displaced entries a `-N` suffix. Parents are resolved +before children. May be called once. + +**SymbolNameRegistry.GetName / GetPath / GetDirectory / GetAllPaths / Contains** (`public`): Read the +approved names. They throw `InvalidOperationException` before `Resolve` and for an unregistered key. + +**FileNameRules.Escape** (`public static`): Escapes a raw name into a legal segment (NR-06 to NR-09) and +reports whether it was transformed. **FileNameRules.IsLegalSegment** (`public static`): Reports whether a +segment is legal on Windows and Linux. + +**LinkPath.Relative / LinkPath.Escape** (`public static`): Build the forward-slash relative path between two +pages and percent-encode link-hostile characters in a destination (NR-19). + +### Design Decisions + +- Registering everything first and resolving once makes names independent of emit order and visibility. +- Reading before `Resolve` and registering after it are errors, so no page can use an unapproved name. +- Escaping lives only in the registry's `Resolve`; no other production code calls `FileNameRules.Escape`. +- No hash suffixes: names stay readable and the order of resolution (NR-14) is deterministic. + +### Error Handling + +Misuse (null or empty arguments, duplicate keys, wrong call order, unknown keys) throws +`ArgumentException`, `ArgumentNullException`, or `InvalidOperationException`. `Escape` never fails for a +non-null name. + +### Dependencies + +N/A - depends only on the .NET base class library. + +### Callers + +- **ApiMark.DotNet.DotNetNames**, **ApiMark.Cpp.CppNames**, **ApiMark.Vhdl.VhdlNames** - build and resolve the + registry per parsed model; the emitters and link resolvers read it. +- **FileMarkdownWriterFactory** - validates each segment with `FileNameRules.IsLegalSegment`. diff --git a/docs/design/api-mark-cpp/cpp-emitter-gradual-disclosure.md b/docs/design/api-mark-cpp/cpp-emitter-gradual-disclosure.md index 01f0c40..1069849 100644 --- a/docs/design/api-mark-cpp/cpp-emitter-gradual-disclosure.md +++ b/docs/design/api-mark-cpp/cpp-emitter-gradual-disclosure.md @@ -29,15 +29,12 @@ and dedicated pages for enums, type aliases, and grouped operator overloads. | Operators (class) | `{Namespace}/{TypeName}/operators.md` | | Operators (namespace) | `{Namespace}/operators.md` | -Every `{...}` segment is passed through `CppEmitter.SanitizeFileName` (namespace keys, -type, member, enum, alias, and function names; folders via `CppEmitter.SanitizePath`), so -`Foo` is written as `Foo{int}.md` and a type named `Aux` as `Aux-.md`. Relative links -in tables use the same sanitized names while link text keeps the raw name. Each name is sanitized -exactly once: page writers receive the raw name and the emitter passes the already-sanitized -output folder (`CppEmitter.SanitizePath`) to `Linkify`. A declaration or member named `operators` -is written as `operators-` so it cannot overwrite the shared operators page, and a namespace -named `api` gets the root page `api-` (`CppEmitter.GetNamespacePageName`) so it cannot overwrite -the index. Only real operator overload names (`CppEmitter.IsOperatorName`) go to the operators page. +Every `{...}` segment is the name approved by the shared `SymbolNameRegistry` (see `CppNames`; +namespace keys, type, member, enum, alias, and function names), so `Foo` is written as `Foo{int}.md` +and a type named `Aux` as `Aux_.md`. Relative links in tables use the same approved names while link text +keeps the raw name. Names are escaped exactly once, when registered; collisions (case twins, a class and +a function with one name, a declaration named `operators` or a namespace named `api`) are resolved up +front by the registry using `-N` suffixes as a last resort. See `docs/design/api-mark-core/file-naming-rules.md`. The global namespace key (`global`) is already legal and unchanged. **CppEmitterGradualDisclosure instance fields** (private): state supplied at construction. diff --git a/docs/design/api-mark-cpp/cpp-emitter.md b/docs/design/api-mark-cpp/cpp-emitter.md index 55bd998..1bb9281 100644 --- a/docs/design/api-mark-cpp/cpp-emitter.md +++ b/docs/design/api-mark-cpp/cpp-emitter.md @@ -109,20 +109,14 @@ by `WriteFunctionContent`. #### File and page helpers -- **SanitizeFileName** — delegates to `ApiMark.Core.FileNameSanitizer.SanitizeSegment` so - names are legal on Windows and Linux (reserved device names get a `-` suffix; template - brackets become `{`/`}`; other illegal characters become `-` plus a hash). - Tests are clang-free (synthetic models). A result equal to `operators` (case-insensitive) is - moved aside to `operators-`, mirroring the DotNet emitter, so a member or declaration page can - never overwrite the shared operators page of its folder. -- **GetNamespacePageName** — `SanitizeFileName` plus a `-` suffix when the result equals the reserved - root index name `api` (case-insensitive), so a namespace page never overwrites `api.md`. +- **CppNames** — builds the `SymbolNameRegistry` for the parsed model; every page and link name comes from it + and is escaped exactly once (reserved device names get a `_` suffix; template brackets become `{`/`}`; + other illegal characters become `_`). Collisions are resolved up front: `operators` and `api` are reserved, + case twins and same-named declarations receive `-N` as a last resort. Tests are clang-free (synthetic models). - **IsOperatorName** — true only for real operator overload names (`operator` followed by a symbol, space, or end), so identifiers such as `operators` or `operator_count` are ordinary members. -- **SanitizePath** — splits a `/`-separated folder or page-key path, sanitizes each - non-empty segment with `SanitizeFileName`, and rejoins them; returns an empty string - for an empty path. `WriteCombinedMemberPage` uses it for its folder and sanitizes its - file name exactly once (callers pass the raw lowercase key, which also stays the heading). +- **WriteCombinedMemberPage** — writes one shared page for overloaded members; its folder and file name + come from the registry. - **GetIncludePath** — derives the canonical `#include` path relative to the longest matching public include root. - **GetMemberBaseName** — returns the class name for constructors and the member diff --git a/docs/design/api-mark-cpp/cpp-type-link-resolver.md b/docs/design/api-mark-cpp/cpp-type-link-resolver.md index fec8fef..be2e4ad 100644 --- a/docs/design/api-mark-cpp/cpp-type-link-resolver.md +++ b/docs/design/api-mark-cpp/cpp-type-link-resolver.md @@ -49,7 +49,7 @@ private `_knownTypes` field. `IndexOf(shortName, startIdx)` to locate the exact token, then reconstructs the result as `original[..idx] + linked + original[(idx + shortName.Length)..]`. A null `currentFolder` is treated as an empty string (root-level file). - The target page key is sanitized per `/` segment with `CppEmitter.SanitizePath`, exactly once. + The target page key is mapped to the registry-approved names (`CppNames`); nothing is escaped again. `currentFolder` is the output folder exactly as the emitter writes it (already sanitized by the emitter) and is used as is, never sanitized again, so links match the legal file names written by the emitter without detours. diff --git a/docs/design/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md b/docs/design/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md index e19ca6b..bd3feba 100644 --- a/docs/design/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md +++ b/docs/design/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md @@ -391,17 +391,17 @@ member, and operators page patterns. and `shortName` are produced by splitting `namespaceFolderPath` at the last separator (e.g. for `ApiMark.DotNet.Fixtures`, `subFolder=""` and `shortName="ApiMark.DotNet.Fixtures"`) - All names below are produced by `DotNetEmitter.GetTypeFileName` and `GetSanitizedMemberFileName` - (`FileNameSanitizer` plus reserved-name, `operators`, and case-twin disambiguation); `FlattenArity` - is only the first step. Known limits: namespace segments that differ only by case, and - external-assembly type twins, are not disambiguated. + which read the names approved by the symbol-name registry (`DotNetNames`; rules in + `docs/design/api-mark-core/file-naming-rules.md`). Known limit: external-assembly type links use the + predictable name, because the other package's collisions are not known. - Type page: `factory.CreateMarkdown(namespaceFolderPath, GetTypeFileName(type))` where `GetTypeFileName` starts from `FlattenArity`, which replaces backtick-arity notation - (e.g. `` SampleGenericClass`1 ``) with a hyphenated arity form (`SampleGenericClass-1`), and - then applies the sanitizer, reserved-name, and case-twin rules. This is distinct + (e.g. `` SampleGenericClass`1 ``) with brace arity (`SampleGenericClass{1}`); the registry then + applies the escape, reserved-name, and collision rules. This is distinct from `StripArity`, which removes the arity suffix entirely and is used for display names. - Member detail: `factory.CreateMarkdown("{namespaceFolderPath}/{GetTypeFileName(type)}", GetSanitizedMemberFileName(member, type))` - Operators page: `factory.CreateMarkdown("{namespaceFolderPath}/{GetTypeFileName(type)}", "operators")` - (a member literally named `operators` is escaped to `operators-` so the pages cannot collide) + (a member literally named `operators` is displaced to `operators-2` so the pages cannot collide) - Combined page: `factory.CreateMarkdown("{namespaceFolderPath}/{GetTypeFileName(type)}", lowerKey)` where `lowerKey` is `GetSanitizedMemberFileName(member, type)` lower-cased @@ -452,12 +452,11 @@ N/A — this is an internal class with no external interfaces exposed beyond its - Generic type pages carry a `## Type Parameters` table (from ``, placeholder when missing) after the examples; only the type's own parameters (the last *arity* parameters) are listed, so a nested type does not repeat its host's. Signatures append `where` clauses. -- Type and member files use the hyphenated `Name-{arity}` form (for example `PropertyEditorRegistry-1.md`). - A hyphen cannot occur in a C# identifier, so `Foo-1` never collides with a type named `Foo1`, and - `Foo`, `Foo` and `Foo` map to `Foo.md`, `Foo-1.md` and `Foo-2.md`. The scheme lives in the - single helper `TypeNameSimplifier.FlattenArity`, which the emitters and the link resolver share, so - page keys and links cannot diverge. This is a breaking change to earlier `Name1.md` output. - Readable names appear in headings and link text. +- Type and member files use the brace-arity form `Name{arity}` (for example `PropertyEditorRegistry{1}.md`). + `Foo`, `Foo` and `Foo` map to `Foo.md`, `Foo{1}.md` and `Foo{2}.md`, and a type named `Foo1` + keeps `Foo1.md`. All names are approved once by the symbol-name registry (`DotNetNames`); the emitters + and the link resolver only read them, so page keys and links cannot diverge. This is a breaking change + to earlier `Foo-1.md` output. Readable names appear in headings and link text. - Namespace pages begin with `[API Reference](api.md) › ancestors › This`; only ancestors that have pages are linked and the current namespace is plain text. - `api.md` starts with a `**Start here:**` link to the namespace with the most types (first diff --git a/docs/design/api-mark-dot-net/dot-net-emitter.md b/docs/design/api-mark-dot-net/dot-net-emitter.md index 41788ff..39a53a9 100644 --- a/docs/design/api-mark-dot-net/dot-net-emitter.md +++ b/docs/design/api-mark-dot-net/dot-net-emitter.md @@ -256,17 +256,16 @@ These helpers are grouped by concern: substring before it; otherwise returns the input unchanged. Does not delegate to `TypeNameSimplifier`; the implementation is self-contained in `DotNetEmitter`. - *Contrast with `FlattenArity`*: `StripArity` removes the arity digit entirely (for - display names), while `FlattenArity` replaces the backtick with a hyphen and keeps the digit - (for filesystem-safe path segments, e.g. `Foo-1`). + display names), while `FlattenArity` replaces the backtick with brace arity + (for filesystem-safe path segments, e.g. `Foo{1}`). **DotNetEmitter.FlattenArity** (internal static): Converts a generic type's IL -name to a filesystem-safe form by replacing the backtick with a hyphen while retaining -the arity digit. +name to a filesystem-safe form by replacing the backtick arity with brace arity. - *Parameters*: `string name` — the raw IL type name that may contain a backtick arity suffix (e.g. `Dictionary\`2`). -- *Returns*: `string` — the name with the backtick replaced by a hyphen and the arity digit - preserved (e.g. `Dictionary\`2` → `Dictionary-2`). Returns the input unchanged +- *Returns*: `string` — the name with the backtick arity replaced by brace arity + (e.g. `Dictionary\`2` → `Dictionary{2}`). Returns the input unchanged when no backtick is present. - *Note*: This method delegates to `TypeNameSimplifier.FlattenArity`. - *Used by*: Both sub-emitters when constructing folder and file paths for generic diff --git a/docs/design/api-mark-dot-net/type-link-resolver.md b/docs/design/api-mark-dot-net/type-link-resolver.md index 6028a69..b7ff7f2 100644 --- a/docs/design/api-mark-dot-net/type-link-resolver.md +++ b/docs/design/api-mark-dot-net/type-link-resolver.md @@ -141,7 +141,7 @@ and generic arguments. This is the overload the emitters call. scope is an `AssemblyNameReference` present in `_externalLinks`. Returns `null` when links are disabled or the assembly is not mapped. See External Assembly Links. -**TypeLinkResolver.BuildTypePageKey** (private static; type file names come from `DotNetEmitter.GetTypeFileName`, which applies `FileNameSanitizer` and the collision rules): Computes a type's page key +**TypeLinkResolver.BuildTypePageKey** (private static; type file names come from `DotNetEmitter.GetTypeFileName`, which reads the approved names from the symbol-name registry): Computes a type's page key for a given set of root namespaces. `GetTypePageKey` calls it with this assembly's root namespaces; `TryLinkExternalType` calls it with the mapped assembly's. @@ -320,5 +320,5 @@ and escaped with `EscapeMarkdownHeading`. **External nested and generic crefs**: `ExternalLinkTarget` carries the mapped assembly's full namespace list. `TryLinkifyExternalCref` takes the longest real-namespace prefix of the cref's type name and treats the remainder as the chain of enclosing types, flattening each segment with `FlattenArity`, so -`NestedGenericHost`1.Inner`1` links to `NestedGenericHost-1/Inner-1.md`. Without a namespace list it +`NestedGenericHost`1.Inner`1` links to `NestedGenericHost{1}/Inner{1}.md`. Without a namespace list it falls back to the previous first-segment rule. diff --git a/docs/design/api-mark-dot-net/type-name-simplifier.md b/docs/design/api-mark-dot-net/type-name-simplifier.md index ffc74b2..5d496b8 100644 --- a/docs/design/api-mark-dot-net/type-name-simplifier.md +++ b/docs/design/api-mark-dot-net/type-name-simplifier.md @@ -150,14 +150,14 @@ suffix from a type name. > method. **TypeNameSimplifier.FlattenArity** (internal static): Converts the IL backtick -arity suffix to a hyphenated numeric suffix, producing a file-system-safe name that -still distinguishes generic types by parameter count. A hyphen cannot occur in a C# -identifier, so `Foo-1` never collides with a real type named `Foo1`. +arity suffix to brace arity, producing a file-system-safe name that +still distinguishes generic types by parameter count. Braces cannot occur in a C# +identifier, so `Foo{1}` never collides with a real type named `Foo1`. - *Parameters*: `string name` — raw IL type name that may contain a backtick arity suffix (e.g. `Foo\`2`). -- *Returns*: `string` — the name with the backtick replaced by a hyphen and the arity count - preserved (e.g. `Foo-2`). Unchanged when no backtick is present. +- *Returns*: `string` — the name with the backtick arity replaced by brace arity + (e.g. `Foo{2}`). Unchanged when no backtick is present. - *Callers*: TypeLinkResolver (when computing type page keys via `GetTypePageKey`), and DotNetEmitter (via the `DotNetEmitter.FlattenArity` delegate wrapper) when building namespace page links, type page names, member page subfolder paths, and diff --git a/docs/design/api-mark-vhdl/vhdl-emitter.md b/docs/design/api-mark-vhdl/vhdl-emitter.md index 4998214..cebe6e2 100644 --- a/docs/design/api-mark-vhdl/vhdl-emitter.md +++ b/docs/design/api-mark-vhdl/vhdl-emitter.md @@ -69,19 +69,12 @@ type string for display in the parameters table. prepended to the type name (e.g. `OUT STD_LOGIC`); bare type names are returned as-is. -**VhdlEmitter.SanitizeFileName** (internal static): Replaces characters that are -illegal on Windows or Linux by delegating to `ApiMark.Core.FileNameSanitizer.SanitizeSegment`; - reserved device names (for example `aux`) receive a `-` suffix so the page is legal on every platform. - -- *Parameters*: `string name` — the raw declaration name. -- *Returns*: `string` — safe for use as a file-system file name segment. -- *See also*: `SanitizePageName` (internal static) applies the same mapping to root-level entity and - package pages and additionally appends `-` to a name equal to the reserved index page `api` - (case-insensitive), so such a declaration never overwrites `api.md`; the index links use it too. -- *Known limitation*: `VhdlEmitter.cs` does not lowercase or otherwise case-normalize names - (verified by reading the source). VHDL identifiers are case-insensitive, so entities whose - names differ only in case produce page names differing only in case and are not - disambiguated; behavior is unchanged and documented in the user guide. +**VhdlNames** (internal): Builds the `SymbolNameRegistry` for the parsed model. Every entity, package and +subprogram page name comes from it and is escaped exactly once; reserved device names (for example `aux`) get a +`_` suffix. `api` is reserved for the index page. An entity and a package with the same name, and names that differ +only in case (VHDL identifiers are case-insensitive), are resolved up front: the entity keeps the plain name and +the other receives `-N` as a last resort. Each subprogram overload gets its own page (`Add`, `Add-2`). +See `docs/design/api-mark-core/file-naming-rules.md`. ### Error Handling diff --git a/docs/design/definition.yaml b/docs/design/definition.yaml index b48655b..550077e 100644 --- a/docs/design/definition.yaml +++ b/docs/design/definition.yaml @@ -8,7 +8,8 @@ input-files: - docs/design/api-mark-core/i-markdown-writer-factory.md - docs/design/api-mark-core/i-markdown-writer.md - docs/design/api-mark-core/path-helpers.md - - docs/design/api-mark-core/file-name-sanitizer.md + - docs/design/api-mark-core/symbol-name-registry.md + - docs/design/api-mark-core/file-naming-rules.md - docs/design/api-mark-dot-net.md - docs/design/api-mark-dot-net/dot-net-generator.md - docs/design/api-mark-dot-net/type-name-simplifier.md diff --git a/docs/reqstream/api-mark-core.yaml b/docs/reqstream/api-mark-core.yaml index 3eb16df..2114597 100644 --- a/docs/reqstream/api-mark-core.yaml +++ b/docs/reqstream/api-mark-core.yaml @@ -147,22 +147,26 @@ sections: - PathHelpers_NormalizeCase_NonExistentPath_PreservesSuppliedCasing - PathHelpers_NormalizeCase_CaseSensitiveFileSystemWithBothCasings_PrefersExactMatch - PathHelpers_NormalizeCaseDirectory_DriveLetterRoot_DoesNotResolveToCurrentDirectory - - id: ApiMarkCore-FileNameSanitizer-ProvideLegalFileNameHelper - title: ApiMarkCore shall provide a public helper that converts declaration names - into file and folder name segments that are legal on Windows and Linux, - deterministic, and distinct. + - id: ApiMarkCore-SymbolNameRegistry-ProvideUniqueFileNames + title: ApiMarkCore shall provide a symbol-name registry that assigns every generated + page a legal, deterministic, case-insensitively unique file name before any + file or link is written. tags: [system] justification: | Every language emitter writes files named after source declarations. One auditable helper guarantees the output can be created and checked out on any supported operating system and that links agree with the files they target. children: - - ApiMarkCore-FileNameSanitizer-ProduceLegalSegments - - ApiMarkCore-FileNameSanitizer-Deterministic - - ApiMarkCore-FileNameSanitizer-ValidateSegments + - ApiMarkCore-SymbolNameRegistry-EscapeOnce + - ApiMarkCore-SymbolNameRegistry-ResolveBeforeUse + - ApiMarkCore-SymbolNameRegistry-PrimaryNameWins + - ApiMarkCore-SymbolNameRegistry-NumericSuffixLastResort + - ApiMarkCore-SymbolNameRegistry-Deterministic + - ApiMarkCore-SymbolNameRegistry-ValidateSegments + - ApiMarkCore-SymbolNameRegistry-RelativeLinks tests: - - FileNameSanitizer_SanitizeSegment_ReservedDeviceName_IsMovedAside - - FileNameSanitizer_IsLegalSegment_IllegalName_ReturnsFalse + - SymbolNameRegistry_Resolve_CaseTwins_LowestKeyWinsAndOtherGetsSuffix + - FileNameRules_IsLegalSegment_IllegalName_ReturnsFalse - id: ApiMarkCore-ProvideEmitterContract title: ApiMark Core shall provide a well-defined emitter contract. tags: [system] diff --git a/docs/reqstream/api-mark-core/symbol-name-registry.yaml b/docs/reqstream/api-mark-core/symbol-name-registry.yaml new file mode 100644 index 0000000..6e7f703 --- /dev/null +++ b/docs/reqstream/api-mark-core/symbol-name-registry.yaml @@ -0,0 +1,86 @@ +--- +# SymbolNameRegistry, FileNameRules and LinkPath unit requirements +sections: + - title: ApiMarkCore Requirements + sections: + - title: SymbolNameRegistry Requirements + requirements: + - id: ApiMarkCore-SymbolNameRegistry-EscapeOnce + title: FileNameRules shall escape a raw name into a non-empty segment that is + legal on Windows and Linux (no illegal or link-hostile characters, no trailing + dot, no reserved device name, bounded length, Unicode form C), keeping the + structural braces, and the registry shall escape each name exactly once. + justification: | + Generated documentation is checked out on both Windows and Linux. Escaping a + name twice changes it again, which caused links to miss their files. + tests: + - FileNameRules_Escape_LegalName_IsUnchangedAndNotTransformed + - FileNameRules_Escape_StructuralBraces_AreKeptAndTransformed + - FileNameRules_Escape_IllegalCharacter_BecomesUnderscore + - FileNameRules_Escape_TrailingDot_IsReplaced + - FileNameRules_Escape_ReservedDeviceName_GetsUnderscoreSuffix + - FileNameRules_Escape_EmptyName_ReturnsUnderscore + - FileNameRules_Escape_DecomposedName_IsNormalizedToFormC + - FileNameRules_Escape_OverLongName_IsTruncated + - FileNameRules_Escape_NullName_ThrowsArgumentNullException + - id: ApiMarkCore-SymbolNameRegistry-ResolveBeforeUse + title: SymbolNameRegistry shall accept registrations only before Resolve and + provide names only after Resolve. + justification: | + Registering every symbol first, resolving once, and only then writing makes the + registry the single source of truth for both writers and link resolvers. + tests: + - SymbolNameRegistry_Resolve_CalledTwice_Throws + - SymbolNameRegistry_GetName_BeforeResolve_Throws + - SymbolNameRegistry_Register_AfterResolve_Throws + - SymbolNameRegistry_Register_InvalidInput_Throws + - SymbolNameRegistry_GetName_UnknownKey_Throws + - id: ApiMarkCore-SymbolNameRegistry-PrimaryNameWins + title: SymbolNameRegistry shall give the plain name to reserved pages first, + then to symbols whose own name is unchanged, then to symbols whose name needed + transformation, preferring namespaces over types over members, and shall + compare names case-insensitively within a directory. + justification: | + Predictable names matter more than short names; the symbol a reader expects + keeps the plain name and the colliding symbol is displaced. + tests: + - SymbolNameRegistry_Resolve_UniqueNames_AreUnchanged + - SymbolNameRegistry_Resolve_IdentityName_BeatsTransformedName + - SymbolNameRegistry_Resolve_NamespaceAndType_NamespaceWins + - SymbolNameRegistry_Resolve_ReservedName_KeepsNameAndDisplacesSymbol + - SymbolNameRegistry_Resolve_DisplacedParent_ChildrenFollow + - id: ApiMarkCore-SymbolNameRegistry-NumericSuffixLastResort + title: SymbolNameRegistry shall append "-N" (N from 2) only to a symbol whose + every predictable name is taken, and shall skip suffixes already in use. + justification: | + The numeric suffix is the least predictable form and is only a last resort. + tests: + - SymbolNameRegistry_Resolve_CaseTwins_LowestKeyWinsAndOtherGetsSuffix + - SymbolNameRegistry_Resolve_SuffixCandidateTaken_UsesNextNumber + - SymbolNameRegistry_Resolve_CanonicallyEquivalentNames_AreDistinguished + - id: ApiMarkCore-SymbolNameRegistry-Deterministic + title: SymbolNameRegistry shall produce the same names regardless of + registration order, and every generated path shall be legal and unique + case-insensitively. + justification: | + Output must be reproducible across runs and operating systems. + tests: + - SymbolNameRegistry_Resolve_RegistrationOrder_DoesNotMatter + - SymbolNameRegistry_Resolve_GeneratedSymbolSet_PathsAreLegalAndCaseInsensitivelyUnique + - id: ApiMarkCore-SymbolNameRegistry-ValidateSegments + title: FileNameRules shall report whether a segment is legal on Windows and + Linux. + justification: | + Writers and tests need one authoritative legality predicate so that any + emitter naming defect is detected instead of failing only on Windows. + tests: + - FileNameRules_IsLegalSegment_IllegalName_ReturnsFalse + - FileNameRules_IsLegalSegment_LegalName_ReturnsTrue + - id: ApiMarkCore-SymbolNameRegistry-RelativeLinks + title: LinkPath shall build forward-slash relative links between pages and + encode link-hostile characters in the destination. + justification: | + Links must resolve identically on every platform. + tests: + - LinkPath_Relative_Pages_ReturnsForwardSlashRelativePath + - LinkPath_Escape_LinkHostileCharacters_AreEncoded diff --git a/docs/reqstream/api-mark-cpp/cpp-emitter-gradual-disclosure.yaml b/docs/reqstream/api-mark-cpp/cpp-emitter-gradual-disclosure.yaml index 5ee77af..1348330 100644 --- a/docs/reqstream/api-mark-cpp/cpp-emitter-gradual-disclosure.yaml +++ b/docs/reqstream/api-mark-cpp/cpp-emitter-gradual-disclosure.yaml @@ -61,7 +61,7 @@ sections: - id: ApiMarkCpp-CppEmitterGradualDisclosure-EmitMemberDetailPage title: >- CppEmitterGradualDisclosure shall produce a member detail page for each - visible non-operator member, combining only case-insensitive collisions. + visible non-operator member; overloads share a page and case twins get distinct pages. justification: | Dedicated member pages make navigation deterministic while still handling case-collision edge cases safely. @@ -69,7 +69,7 @@ sections: - ApiMarkCpp-CppEmitterGradualDisclosure-RenderFieldConstantValue tests: - CppEmitterGradualDisclosure_Emit_MethodMember_CreatesMemberDetailPage - - CppEmitterGradualDisclosure_Emit_CaseInsensitiveCollision_CreatesCombinedPage + - CppEmitterGradualDisclosure_Emit_CaseTwinMembers_GetDistinctPages - id: ApiMarkCpp-CppEmitterGradualDisclosure-RenderFieldConstantValue title: >- CppEmitterGradualDisclosure shall append "= value" to a field's signature @@ -141,9 +141,9 @@ sections: segment and the matching links keeps output portable and navigable. tests: - CppEmitterGradualDisclosure_Emit_IllegalAndReservedNames_AllWrittenSegmentsAreLegal - - CppEmitterGradualDisclosure_Emit_IllegalAndReservedNames_PagesUseSanitizedPaths + - CppEmitterGradualDisclosure_Emit_IllegalAndReservedNames_PagesUseApprovedPaths - CppEmitterGradualDisclosure_Emit_IllegalAndReservedNames_AllLinksResolveToWrittenPages - - CppEmitterGradualDisclosure_Emit_GenericAndOddNames_MemberPageLinksUseSingleSanitization + - CppEmitterGradualDisclosure_Emit_GenericAndOddNames_MemberPageLinksUseApprovedPaths - CppEmitterGradualDisclosure_Emit_ReservedAndGenericNames_AllLinksResolveToWrittenPages - id: ApiMarkCpp-CppEmitterGradualDisclosure-ReservedPageNames title: >- diff --git a/docs/reqstream/api-mark-cpp/cpp-emitter.yaml b/docs/reqstream/api-mark-cpp/cpp-emitter.yaml index 00468d1..8a59abe 100644 --- a/docs/reqstream/api-mark-cpp/cpp-emitter.yaml +++ b/docs/reqstream/api-mark-cpp/cpp-emitter.yaml @@ -18,7 +18,7 @@ sections: children: - ApiMarkCpp-CppEmitter-WriteCombinedMemberPageForCaseCollisions - ApiMarkCpp-CppEmitter-RejectNullFactory - - ApiMarkCpp-CppEmitter-SanitizeFileName + - ApiMarkCpp-CppEmitter-FileNames - ApiMarkCpp-CppEmitter-BuildClassDeclaration - ApiMarkCpp-CppEmitter-RepresentExternalTypeInfo tests: @@ -33,28 +33,34 @@ sections: collide at the file-system level. CppEmitter must detect these collisions and merge the affected members onto one shared page. tests: - - CppEmitter_WriteCombinedMemberPage_CaseInsensitiveCollision_ProducesSingleCombinedPage + - CppEmitter_WriteCombinedMemberPage_Overloads_ProducesSingleCombinedPage - id: ApiMarkCpp-CppEmitter-RejectNullFactory title: CppEmitter shall reject a null markdown-writer factory. justification: | Emission cannot proceed without a writer factory, so null input must fail immediately with a clear argument error. tests: [CppEmitter_Emit_NullFactory_ThrowsArgumentNullException] - - id: ApiMarkCpp-CppEmitter-SanitizeFileName + - id: ApiMarkCpp-CppEmitter-FileNames title: >- CppEmitter shall convert every output file and folder name, and every page-key path, into segments that are legal on both Windows and Linux - using FileNameSanitizer. + taken from the shared SymbolNameRegistry, which escapes each name once and + resolves every collision before any page or link is written. justification: | Template names such as Foo, reserved device names such as Aux or CON, and operator names are valid C++ but illegal or unsafe as Windows file names. - Delegating to the shared sanitizer keeps names deterministic on every platform. + Using the shared registry keeps names deterministic, unique, and identical + between the pages and the links that target them. tests: - - CppEmitter_SanitizeFileName_RegularName_IsUnchanged - - CppEmitter_SanitizeFileName_InvalidCharacters_AreReplacedWithLegalCharacters - - CppEmitter_SanitizeFileName_TemplateQualifiedAndReservedNames_AreLegal - - CppEmitter_SanitizeFileName_IsDeterministic - - CppEmitter_SanitizePath_NestedAndReservedSegments_AreSanitizedPerSegment + - CppNames_ClassEnumAliasFunctionSameName_AreSeparatedInKindOrder + - CppNames_CaseTwinClasses_SecondGetsSuffix + - CppNames_ReservedNames_AreNeverTaken + - CppNames_TemplateAndDeviceNames_AreEscapedOnce + - CppNames_QualifiedNamespaceKey_IsOneEscapedSegment + - CppEmitter_OverloadedFreeFunctions_ShareOnePageWithEveryOverload + - CppEmitter_ClassAndFunctionSameName_BothPagesWrittenAndLinked + - CppEmitter_CollisionModel_AllPagesUniqueLegalAndLinksResolve + - CppEmitter_CollisionModel_IsDeterministic - id: ApiMarkCpp-CppEmitter-BuildClassDeclaration title: >- CppEmitter shall render class declaration lines with final and direct diff --git a/docs/reqstream/api-mark-cpp/cpp-type-link-resolver.yaml b/docs/reqstream/api-mark-cpp/cpp-type-link-resolver.yaml index 3229fcc..2543bf5 100644 --- a/docs/reqstream/api-mark-cpp/cpp-type-link-resolver.yaml +++ b/docs/reqstream/api-mark-cpp/cpp-type-link-resolver.yaml @@ -81,5 +81,5 @@ sections: already passes sanitized output folders, and sanitizing them again would produce detoured links. tests: - - CppTypeLinkResolver_Linkify_IllegalPageKey_EmitsSanitizedRelativeLink - - CppTypeLinkResolver_Linkify_AlreadySanitizedFolder_IsNotSanitizedAgain + - CppTypeLinkResolver_Linkify_DisplacedPageKey_EmitsApprovedRelativeLink + - CppTypeLinkResolver_Linkify_ApprovedFolder_IsUsedAsIs diff --git a/docs/reqstream/api-mark-dot-net.yaml b/docs/reqstream/api-mark-dot-net.yaml index eaebc21..e9b3279 100644 --- a/docs/reqstream/api-mark-dot-net.yaml +++ b/docs/reqstream/api-mark-dot-net.yaml @@ -78,7 +78,7 @@ sections: - ApiMarkDotNet-DotNetEmitterGradualDisclosure-EmitNamespaceDocRemarksAndExamples - ApiMarkDotNet-DotNetEmitterGradualDisclosure-EmitTypePages - ApiMarkDotNet-DotNetEmitterGradualDisclosure-EmitMemberDetailPages - - ApiMarkDotNet-DotNetEmitterGradualDisclosure-EmitCombinedPageForCaseCollisions + - ApiMarkDotNet-DotNetEmitterGradualDisclosure-EmitSeparatePagesForCaseTwins - ApiMarkDotNet-DotNetEmitterGradualDisclosure-EmitMethodOverloadPage - ApiMarkDotNet-DotNetEmitterGradualDisclosure-EmitOperatorsPage - ApiMarkDotNet-DotNetEmitterGradualDisclosure-EmitNestedTypePages diff --git a/docs/reqstream/api-mark-dot-net/dot-net-emitter-gradual-disclosure.yaml b/docs/reqstream/api-mark-dot-net/dot-net-emitter-gradual-disclosure.yaml index e5064b6..2df2d7a 100644 --- a/docs/reqstream/api-mark-dot-net/dot-net-emitter-gradual-disclosure.yaml +++ b/docs/reqstream/api-mark-dot-net/dot-net-emitter-gradual-disclosure.yaml @@ -96,19 +96,18 @@ sections: tests: - DotNetEmitterGradualDisclosure_Emit_ValidModel_CreatesMemberDetailPage - DotNetGenerator_AllMembers_GetSeparateFiles - - id: ApiMarkDotNet-DotNetEmitterGradualDisclosure-EmitCombinedPageForCaseCollisions - title: DotNetEmitterGradualDisclosure shall emit a single combined page - for members whose sanitized file names collide on a case-insensitive - filesystem. + - id: ApiMarkDotNet-DotNetEmitterGradualDisclosure-EmitSeparatePagesForCaseTwins + title: DotNetEmitterGradualDisclosure shall emit a separate page for each + of two members whose names differ only in case (for example Name and name-2). justification: | On case-insensitive filesystems (Windows, macOS), two members whose sanitized - names differ only in case would produce colliding filenames. Combining them - onto one page prevents data loss and maintains predictable link targets. + names differ only in case would produce colliding filenames. The registry gives each + its own page, chosen by tier and then a numeric suffix, so nothing is lost. tests: - - DotNetEmitterGradualDisclosure_Emit_CaseCollision_CreatesCombinedPage - - DotNetGenerator_Generate_CaseCollisionClass_CreatesCombinedPage - - DotNetGenerator_Generate_CaseCollisionClass_DoesNotCreateSeparateCasedPage - - DotNetGenerator_Generate_CaseCollisionClass_CombinedPageContainsBothMembers + - DotNetEmitterGradualDisclosure_Emit_CaseCollision_CreatesSeparatePages + - DotNetGenerator_Generate_CaseCollisionClass_CreatesSeparatePages + - DotNetGenerator_Generate_CaseCollisionClass_NoPagesDifferOnlyInCase + - DotNetGenerator_Generate_CaseCollisionClass_EachMemberHasOwnPage - id: ApiMarkDotNet-DotNetEmitterGradualDisclosure-EmitMethodOverloadPage title: DotNetEmitterGradualDisclosure shall emit a single consolidated overload page when a type declares multiple overloads of the same method @@ -276,7 +275,6 @@ sections: - DotNetEmitterGradualDisclosure_Emit_GlobalNamespacePages_HaveApiReferenceBreadcrumb - DotNetEmitterGradualDisclosure_Emit_Pages_HaveRelativeBreadcrumbs - DotNetEmitterGradualDisclosure_Emit_OverloadPage_HasBreadcrumb - - DotNetEmitterGradualDisclosure_Emit_CombinedPage_HasBreadcrumb - DotNetEmitterGradualDisclosure_Emit_NestedType_BreadcrumbsIncludeOuterType - DotNetEmitterGradualDisclosure_Emit_MemberPage_FirstParagraphIsBreadcrumb - id: ApiMarkDotNet-DotNetEmitterGradualDisclosure-OverviewRowsShowFullParameters diff --git a/docs/reqstream/api-mark-dot-net/type-name-simplifier.yaml b/docs/reqstream/api-mark-dot-net/type-name-simplifier.yaml index 20d61ec..0c13d34 100644 --- a/docs/reqstream/api-mark-dot-net/type-name-simplifier.yaml +++ b/docs/reqstream/api-mark-dot-net/type-name-simplifier.yaml @@ -141,7 +141,7 @@ sections: - DotNetEmitterGradualDisclosure_Emit_MemberSignatures_UseShortExternalNames - DotNetEmitterGradualDisclosure_Emit_NoSignatureContainsExternalNamespace - id: ApiMarkDotNet-TypeNameSimplifier-GenericArityFileNames - title: TypeNameSimplifier shall flatten a generic arity suffix to "-N" (Foo-1) so that + title: TypeNameSimplifier shall flatten a generic arity suffix to brace notation "{N}" (Foo{1}) so that same-named generic and non-generic-with-digit types never share a file name. justification: | Foo of T and Foo1 both mapped to Foo1.md before, silently overwriting a page. @@ -152,3 +152,15 @@ sections: - DotNetEmitterGradualDisclosure_Emit_AllPages_NoDigitSuffixFileNamesForGenericTypes - DotNetEmitterGradualDisclosure_Emit_AllRelativeLinks_ResolveToEmittedPages - DotNetEmitterGradualDisclosure_Emit_CrefAndBaseTypeLinks_UseHyphenatedArityNames + - DotNetNames_NamespaceAndTypeShareAPath_NamespaceWinsAndTypeIsSuffixed + - DotNetNames_CaseTwinNamespaces_SecondGetsSuffix + - DotNetNames_RootNamespaceNamedApi_IsDisplacedFromReservedPage + - DotNetNames_GenericTypes_UseBraceArity + - DotNetNames_MembersNamedOperators_AreDisplaced + - DotNetNames_CaseTwinMembers_GetSeparatePages + - DotNetNames_NestedTypeAndMemberSameName_TypeWins + - DotNetNames_HostileNames_ProduceUniqueLegalPaths + - DotNetGenerator_Emit_ExplicitGenericInterfaceMembers_KeepTypeArguments + - DotNetGenerator_Emit_GenericType_UsesBraceArity + - DotNetGenerator_Emit_Fixtures_PathsAreUniqueLegalAndLinksResolve + - DotNetGenerator_Emit_Fixtures_NamesAreIndependentOfVisibility diff --git a/docs/reqstream/api-mark-vhdl/vhdl-emitter-gradual-disclosure.yaml b/docs/reqstream/api-mark-vhdl/vhdl-emitter-gradual-disclosure.yaml index a8bbb21..8b062c5 100644 --- a/docs/reqstream/api-mark-vhdl/vhdl-emitter-gradual-disclosure.yaml +++ b/docs/reqstream/api-mark-vhdl/vhdl-emitter-gradual-disclosure.yaml @@ -40,3 +40,13 @@ sections: page would be written to the same file and one would silently replace the other. tests: - VhdlEmitterGradualDisclosure_Emit_EntityAndPackageNamedApi_DoNotOverwriteApiIndexPage + - VhdlNames_PlainNames_AreUnchanged + - VhdlNames_EntityAndPackageSameName_EntityWins + - VhdlNames_EntityAndPackageCaseTwins_AreSeparated + - VhdlNames_CaseTwinEntities_SecondGetsSuffix + - VhdlNames_DuplicateEntityAcrossFiles_GetsDistinctPages + - VhdlNames_ReservedApiName_KeepsIndexPage + - VhdlNames_OverloadedAndCaseTwinSubprograms_GetDistinctPages + - VhdlNames_IllegalAndDeviceNames_AreEscaped + - VhdlNames_SameModel_IsDeterministic + - VhdlFileNaming_Fixtures_AllPagesUniqueLegalAndLinked diff --git a/docs/sysml2/model/api-mark-core.sysml b/docs/sysml2/model/api-mark-core.sysml index 08d1b4a..8c514ce 100644 --- a/docs/sysml2/model/api-mark-core.sysml +++ b/docs/sysml2/model/api-mark-core.sysml @@ -22,7 +22,7 @@ package ApiMarkCore { part iMarkdownWriter : IMarkdownWriter; part fileMarkdownWriter : FileMarkdownWriter; part pathHelpers : PathHelpers; - part fileNameSanitizer : FileNameSanitizer; + part symbolNameRegistry : SymbolNameRegistry; part globFileCollector : GlobFileCollector; part fileSystemGlobbing : FileSystemGlobbing; part iDocumentationCoverageCapable : IDocumentationCoverageCapable; diff --git a/docs/sysml2/model/api-mark-core/symbol-name-registry.sysml b/docs/sysml2/model/api-mark-core/symbol-name-registry.sysml new file mode 100644 index 0000000..c3c1d20 --- /dev/null +++ b/docs/sysml2/model/api-mark-core/symbol-name-registry.sysml @@ -0,0 +1,14 @@ +package ApiMarkCore { + part def SymbolNameRegistry { + doc /* SymbolNameRegistry is the single source of truth for the file and folder name of every + generated page. Symbols are registered first, all names are resolved once without collisions, + and only then do writers and link resolvers read the approved names. FileNameRules escapes + names and LinkPath builds relative links. */ + + comment sourceRef /* Source: src/ApiMark.Core/SymbolNameRegistry.cs, src/ApiMark.Core/FileNameRules.cs, src/ApiMark.Core/LinkPath.cs */ + comment testRef /* Test: test/ApiMark.Core.Tests/SymbolNameRegistryTests.cs */ + comment designRef /* Design: docs/design/api-mark-core/symbol-name-registry.md */ + comment verificationRef /* Verification: docs/verification/api-mark-core/symbol-name-registry.md */ + comment reqRef /* Requirements: docs/reqstream/api-mark-core/symbol-name-registry.yaml */ + } +} diff --git a/docs/user_guide/cpp.md b/docs/user_guide/cpp.md index a1d08f5..cbd34e1 100644 --- a/docs/user_guide/cpp.md +++ b/docs/user_guide/cpp.md @@ -224,11 +224,14 @@ requires. File and folder names are made legal on both Windows and Linux so the output can be checked out anywhere. Template names such as `Foo` are written as `Foo{int}.md`; reserved Windows -device names (`CON`, `PRN`, `AUX`, `NUL`, `COM1`-`COM9`, `LPT1`-`LPT9`) get a trailing `-` -(for example a type `Aux` becomes `Aux-.md`, a namespace `aux` becomes `aux-.md`); any other -illegal character (such as `:`) becomes `-` followed by a short hash of the original name. +device names (`CON`, `PRN`, `AUX`, `NUL`, `COM1`-`COM9`, `LPT1`-`LPT9`) get a trailing `_` +(for example a type `Aux` becomes `Aux_.md`); any other illegal character (such as `:`) becomes `_`. Links inside the pages use the same names, while link text keeps the original C++ name. -Types whose names differ only in case are not disambiguated across the whole tree. +All names are decided up front by one registry. When two declarations in one folder would share a name +(compared without regard to case, for example a class and a function with the same name, or `Foo` and +`foo`), the first in a fixed order keeps the plain name and the others get `-2`, `-3`, and so on. A name +equal to the reserved `operators` or `api` pages is displaced the same way. Overloads of one function +share one page. See `docs/design/api-mark-core/file-naming-rules.md`. ### Single File (`--format single-file`) diff --git a/docs/user_guide/dotnet.md b/docs/user_guide/dotnet.md index 61d5c54..7c626e3 100644 --- a/docs/user_guide/dotnet.md +++ b/docs/user_guide/dotnet.md @@ -133,11 +133,11 @@ Additional details rendered in signatures: ### Generic types and navigation Generic types use escaped headings (`PropertyEditorRegistry\`), a Type Parameters -table taken from ``, and `where` constraints in signatures. Their file names use a hyphenated arity suffix, `Name-{arity}` (for example `PropertyEditorRegistry-1.md` for -`PropertyEditorRegistry` and `KeyedPair-2.md` for `KeyedPair`). A hyphen cannot -appear in a C# identifier, so `Foo-1.md` (for `Foo`) never collides with `Foo1.md` (for a type named -`Foo1`), and `Foo.md`, `Foo-1.md` and `Foo-2.md` stay distinct. Earlier releases wrote `Name1.md`; links -that point at the old names must be updated. Overload rows +table taken from ``, and `where` constraints in signatures. Their file names use brace arity, `Name{arity}` (for example `PropertyEditorRegistry{1}.md` for +`PropertyEditorRegistry` and `KeyedPair{2}.md` for `KeyedPair`). Braces cannot +appear in a C# identifier, so `Foo{1}.md` (for `Foo`) never collides with `Foo1.md` (for a type named +`Foo1`), and `Foo.md`, `Foo{1}.md` and `Foo{2}.md` stay distinct. Earlier releases wrote `Foo-1.md` (and +before that `Name1.md`); links that point at the old names must be updated. Overload rows list every distinct return type (including `void`); the row shows the summary only when every overload has the same summary; when they differ, or any overload has none, it shows `Overloaded; see the member page.` instead of one overload's summary. Cross-reference links to an overload include the signature in the link text (`UndoHistory.Transact(string, Action)`) and end with an anchor to @@ -148,17 +148,30 @@ a breadcrumb back to `api.md`, and `api.md` has a Start here link and a Key Type ### Legal File Names -Every generated file and folder name is legal on Windows and Linux. `<` and `>` (explicit interface -implementations such as `IGenericSource.GetItem`) are written as `{` and `}`. A literal `{` or `}` -in a name is treated like the other illegal characters so it cannot collide with them. Other illegal -characters (`: " | ? * \ /`, control characters, spaces, brackets) become `-` and a short hash of the -original name is appended so distinct names stay distinct. Names that Windows reserves (`CON`, `NUL`, -`PRN`, `AUX`, `COM1`, `LPT1`, and so on) get a `-` suffix (`CON-.md`), as does a member named -`operators` (`operators-.md`), which would otherwise collide with the operators page. Types that differ -only by case, such as `Inner` and `inner`, keep the first name in ordinal order and the others get a -hash suffix, so no two files collide on a case-insensitive file system. Earlier releases wrote the -explicit-implementation names verbatim; links to those old names must be updated. Limitation: namespaces -that differ only by case, and case-twin types from external assemblies, are not disambiguated. +Every generated file and folder name is legal on Windows and Linux, and unique when compared without +regard to case. All names are decided up front by one registry, before any file or link is written; the +complete numbered rules are in `docs/design/api-mark-core/file-naming-rules.md`. In short: + +- A name that is already legal is used as written (`Widget.md`). +- Generic types use brace arity: `Foo` is `Foo{1}.md` and `Foo` is `Foo{2}.md`. Generic methods + get no arity. +- Explicit interface members keep their type arguments: `IFoo.Get` is `IFoo{int}.Get.md`, and several + arguments are joined with `-` (`IPair{int-string}.Get.md`). +- Illegal or link-hostile characters (`: " | ? * \ /`, control characters, spaces, brackets) become `_`. + Reserved Windows names (`CON`, `NUL`, `PRN`, `AUX`, `COM1`, `LPT1`, and so on) get a `_` suffix + (`CON_.md`). +- When two symbols in one folder would get the same name (compared case-insensitively), the symbol whose + own name is unchanged keeps it, and a namespace beats a type, which beats a member. The other symbol is + displaced to a transformed name and only as a last resort gets a numeric suffix: `Name.md` and + `name-2.md`. This covers namespaces that clash with types, case twins, members that clash with the + reserved `operators` page, and a root namespace named `api`. +- Symbols that are not visible are named as if they were visible, so names do not change when you change + the visibility filter. + +Limitation: a link to a type in an external (mapped) assembly uses that type's predictable name, and is +wrong if the other package had to displace it with a numeric suffix. This is a breaking change from +earlier releases (`Foo-1.md` is now `Foo{1}.md`, hash-suffixed names are now `_` and `-N` names); update +any links that point at the old file names. ### Breadcrumbs @@ -272,7 +285,7 @@ A `` to a type (or a member of a type) in a mapped assembly als type's page in the mapped documentation, when the cref's namespace belongs to the mapped assembly. A cref to an unmapped assembly (including the framework) stays a plain code span. Member crefs link to the type page, and nested types (including generic nested types) link to their nested page, such as -`Outer/Inner.md` or `Outer-1/Inner-1.md`. +`Outer/Inner.md` or `Outer{1}/Inner{1}.md`. ### Cross-Reference Linking diff --git a/docs/user_guide/vhdl.md b/docs/user_guide/vhdl.md index e13e7b3..407b347 100644 --- a/docs/user_guide/vhdl.md +++ b/docs/user_guide/vhdl.md @@ -151,13 +151,14 @@ requires. ### File Naming -File and folder names are legal on both Windows and Linux. Names are not lowercased and -the emitter does not rename by case: VHDL identifiers are case-insensitive, so two entities -or packages whose names differ only in case (for example `Counter` and `counter`) produce -page names that differ only in case and are not disambiguated; this is a known limitation -on case-insensitive file systems. Reserved Windows device names (for example an entity -named `aux`) get a trailing `-` (`aux-.md`) and any other illegal character becomes `-` -followed by a short hash of the original name. +File and folder names are legal on both Windows and Linux and unique without regard to case. All +names are decided up front by one registry. VHDL identifiers are case-insensitive, so an entity +and a package with the same name (or names differing only in case, such as `Api` and `API`) would +share a file name: the entity keeps the plain name and the other gets `-2` (`counter.md`, `counter-2.md`). +Each overload of a subprogram gets its own page (`Add.md`, `Add-2.md`). Reserved Windows device names +(for example an entity named `aux`) get a trailing `_` (`aux_.md`), any other illegal character becomes +`_`, and an entity named `api` is displaced from the reserved index page. See +`docs/design/api-mark-core/file-naming-rules.md`. ### Single File (`--format single-file`) diff --git a/docs/verification/api-mark-core/symbol-name-registry.md b/docs/verification/api-mark-core/symbol-name-registry.md new file mode 100644 index 0000000..447559f --- /dev/null +++ b/docs/verification/api-mark-core/symbol-name-registry.md @@ -0,0 +1,52 @@ +## SymbolNameRegistry + +### Verification Approach + +SymbolNameRegistry, FileNameRules, and LinkPath are verified with unit tests in +`test/ApiMark.Core.Tests` (`SymbolNameRegistryTests`, `FileNameRulesTests`, `LinkPathTests`) that call the +real implementation. End-to-end uniqueness, legality, and link resolution are verified by +`DotNetFileNamingTests`, `CppFileNamingTests`, `CppFixtureFileNamingTests`, and `VhdlFileNamingTests`. + +### Test Environment + +N/A - standard test environment using the .NET test runner is sufficient. The C++ fixture test needs clang. + +### Acceptance Criteria + +- All tests in the three Core test classes pass with zero failures. +- Legal names are unchanged; illegal characters, trailing dots, empty names, reserved device names, + decomposed Unicode, and over-long names produce legal segments. +- Names are granted in tier order; a namespace beats a type; reserved pages keep their name; displaced + parents carry their children; `-N` is used only when needed and skips taken suffixes. +- The result does not depend on registration order, and generated paths are legal and unique without + regard to case. +- Registry misuse throws. + +### Test Scenarios + +**FileNameRules tests** (`FileNameRules_Escape_*`, `FileNameRules_IsLegalSegment_*`): Verify each escape +rule NR-06 to NR-09 with exact outputs and the legality predicate. + +**SymbolNameRegistry_Resolve_UniqueNames_AreUnchanged**, **..._IdentityName_BeatsTransformedName**, +**..._NamespaceAndType_NamespaceWins**, **..._ReservedName_KeepsNameAndDisplacesSymbol**, +**..._DisplacedParent_ChildrenFollow**: Verify rules NR-14 and NR-16. + +**SymbolNameRegistry_Resolve_CaseTwins_LowestKeyWinsAndOtherGetsSuffix**, +**..._SuffixCandidateTaken_UsesNextNumber**, **..._CanonicallyEquivalentNames_AreDistinguished**: Verify +rule NR-15. + +**SymbolNameRegistry_Resolve_RegistrationOrder_DoesNotMatter**, +**..._GeneratedSymbolSet_PathsAreLegalAndCaseInsensitivelyUnique**: Verify determinism and the +uniqueness property over a generated symbol set. + +**SymbolNameRegistry_Resolve_CalledTwice_Throws**, **..._GetName_BeforeResolve_Throws**, +**..._Register_AfterResolve_Throws**, **..._Register_InvalidInput_Throws**, **..._GetName_UnknownKey_Throws**: +Verify call-order and input validation. + +**LinkPath_Relative_Pages_ReturnsForwardSlashRelativePath**, **LinkPath_Escape_LinkHostileCharacters_AreEncoded**: +Verify rule NR-19. + +**Language tests**: `DotNetFileNamingTests` (namespace vs type, case twins, `api`, brace arity, `operators`, +explicit interface members, fixtures for all visibilities, every link resolves), `CppFileNamingTests`, and +`VhdlFileNamingTests` (entity vs package, case twins, reserved names, overloads, fixtures) verify NR-03 +to NR-05, NR-10 to NR-13, NR-17, and NR-18. diff --git a/docs/verification/api-mark-cpp/cpp-emitter-gradual-disclosure.md b/docs/verification/api-mark-cpp/cpp-emitter-gradual-disclosure.md index 5a76a7d..7f1ac14 100644 --- a/docs/verification/api-mark-cpp/cpp-emitter-gradual-disclosure.md +++ b/docs/verification/api-mark-cpp/cpp-emitter-gradual-disclosure.md @@ -17,7 +17,7 @@ Tests run with the standard xUnit.net test runner. - The emitter creates detail pages for visible members and free functions. - The emitter creates enum pages, type-alias pages (namespace-level and class-scoped), nested-type pages, class-level operator pages, and namespace-level operator pages. -- Case-insensitive collisions are combined onto one page. +- Members whose names differ only in case get distinct pages (`-2` suffix). - Empty namespace collections still produce an `api.md` fallback page. - The api index page heading contains the configured library name. - A field signature line appends `= ` when `CppField.Value` is populated, and omits @@ -32,14 +32,14 @@ produce only legal path segments (the in-memory factory does not validate, so ea key is asserted explicitly), that pages exist at the sanitized paths (`Foo{int}`, `Aux-`, `CON-`, `aux-`), and that every emitted relative link resolves to a written page. Tested by `CppEmitterGradualDisclosure_Emit_IllegalAndReservedNames_AllWrittenSegmentsAreLegal`, -`CppEmitterGradualDisclosure_Emit_IllegalAndReservedNames_PagesUseSanitizedPaths`, and +`CppEmitterGradualDisclosure_Emit_IllegalAndReservedNames_PagesUseApprovedPaths`, and `CppEmitterGradualDisclosure_Emit_IllegalAndReservedNames_AllLinksResolveToWrittenPages`. **Links from generic and odd-named types**: Verifies, with hand-built models for `Foo`, namespace `a::b`, `Aux`, and `CON`, that links from member pages to the owning type, nested types, siblings, and reserved-name pages are the direct relative paths to pages that were written (folders are sanitized once), and that every link in every table cell and paragraph resolves to a written -page. Tested by `CppEmitterGradualDisclosure_Emit_GenericAndOddNames_MemberPageLinksUseSingleSanitization` +page. Tested by `CppEmitterGradualDisclosure_Emit_GenericAndOddNames_MemberPageLinksUseApprovedPaths` and `CppEmitterGradualDisclosure_Emit_ReservedAndGenericNames_AllLinksResolveToWrittenPages`. **Names equal to reserved pages**: Verifies that a method or case-colliding member group named @@ -70,10 +70,9 @@ page at `{namespace}/{OuterType}/{NestedType}` so readers can navigate to it dir outer class page. Tested by `CppEmitterGradualDisclosure_Emit_NestedClass_CreatesNestedClassPage`. -**Case-insensitive collision**: Verifies that two members whose names differ only in case are -combined onto a single lowercase-keyed page rather than producing two separately-named pages that -would collide on case-insensitive file systems. Tested by -`CppEmitterGradualDisclosure_Emit_CaseInsensitiveCollision_CreatesCombinedPage`. +**Case-twin members**: Verifies that two members whose names differ only in case get distinct pages +(the second gets a -2 suffix) so they never collide on case-insensitive file systems. Tested by +`CppEmitterGradualDisclosure_Emit_CaseTwinMembers_GetDistinctPages`. **Empty namespace fallback**: Verifies that when the namespace collection is empty the emitter still produces an `api.md` page containing a fallback paragraph so the output is never completely diff --git a/docs/verification/api-mark-cpp/cpp-emitter.md b/docs/verification/api-mark-cpp/cpp-emitter.md index e78bd1e..98a26a7 100644 --- a/docs/verification/api-mark-cpp/cpp-emitter.md +++ b/docs/verification/api-mark-cpp/cpp-emitter.md @@ -16,7 +16,7 @@ Tests run with the standard xUnit.net test runner. - `CppEmitter.Emit` rejects a null factory. - Gradual-disclosure dispatch produces multiple files. - Single-file dispatch produces exactly one `api` file. -- `SanitizeFileName` preserves valid names and replaces illegal characters, template brackets, and reserved device names with legal forms; `SanitizePath` sanitizes each segment. +- File names come from the shared registry: illegal characters, template brackets, and reserved device names become legal forms once, and collisions are resolved up front. - `BuildClassDeclaration` renders non-final, final, and inherited class declarations correctly. - `WriteCombinedMemberPage` emits one shared page for case-insensitive member collisions. - `WriteCombinedMemberPage` rejects a `members` list containing fewer than two elements with `ArgumentException`. @@ -26,12 +26,13 @@ Tests run with the standard xUnit.net test runner. ### Test Scenarios -**Sanitize invalid characters**: Verifies that illegal file-name characters are replaced with -legal characters deterministically instead of causing output-path failures. Tested by -`CppEmitter_SanitizeFileName_InvalidCharacters_AreReplacedWithLegalCharacters`, -`CppEmitter_SanitizeFileName_TemplateQualifiedAndReservedNames_AreLegal`, -`CppEmitter_SanitizeFileName_IsDeterministic`, and -`CppEmitter_SanitizePath_NestedAndReservedSegments_AreSanitizedPerSegment`. +**Registry-based file names**: Verifies that file names come from the shared registry: illegal characters are +replaced once, collisions are separated, and every link resolves. Tested by +`CppNames_ClassEnumAliasFunctionSameName_AreSeparatedInKindOrder`, `CppNames_CaseTwinClasses_SecondGetsSuffix`, +`CppNames_ReservedNames_AreNeverTaken`, `CppNames_TemplateAndDeviceNames_AreEscapedOnce`, +`CppNames_QualifiedNamespaceKey_IsOneEscapedSegment`, `CppEmitter_OverloadedFreeFunctions_ShareOnePageWithEveryOverload`, +`CppEmitter_ClassAndFunctionSameName_BothPagesWrittenAndLinked`, `CppEmitter_CollisionModel_AllPagesUniqueLegalAndLinksResolve`, +and `CppEmitter_CollisionModel_IsDeterministic`. **Null factory rejection**: Verifies that passing null to `CppEmitter.Emit` throws `ArgumentNullException` before any I/O occurs. Tested by @@ -45,9 +46,7 @@ legal characters deterministically instead of causing output-path failures. Test `OutputFormat.SingleFile` creates exactly one writer keyed as `api`. Tested by `CppEmitter_Emit_SingleFileFormat_ProducesSingleApiFile`. -**SanitizeFileName preserves valid names**: Verifies that names containing only valid -file-name characters are returned unchanged. Tested by -`CppEmitter_SanitizeFileName_RegularName_IsUnchanged`. +**Registry names are legal and unique**: Verified by `CppNames_*` and `CppEmitter_CollisionModel_*` tests. **BuildClassDeclaration with final class**: Verifies that the `final` keyword is appended to the class declaration when `CppClass.IsFinal` is true. Tested by @@ -63,7 +62,7 @@ produces a declaration string containing only the class keyword and name. Tested **WriteCombinedMemberPage for case-insensitive collisions**: Verifies that members whose names differ only in case are merged onto a single lowercase-keyed page. Tested by -`CppEmitter_WriteCombinedMemberPage_CaseInsensitiveCollision_ProducesSingleCombinedPage`. +`CppEmitter_WriteCombinedMemberPage_Overloads_ProducesSingleCombinedPage`. **GetIncludePath returns relative path for matching root**: Verifies that a source file residing under a configured public include root produces a root-relative, forward-slash diff --git a/docs/verification/api-mark-cpp/cpp-type-link-resolver.md b/docs/verification/api-mark-cpp/cpp-type-link-resolver.md index c2fee37..24a1e37 100644 --- a/docs/verification/api-mark-cpp/cpp-type-link-resolver.md +++ b/docs/verification/api-mark-cpp/cpp-type-link-resolver.md @@ -48,8 +48,8 @@ the standard xUnit.net test runner. No clang installation is needed. **Illegal page keys are linked with sanitized paths**: Verifies that `Linkify` sanitizes each segment of the target page key and uses the current folder as already sanitized (never sanitizing it a second time), so links match the legal files written. Tested by -`CppTypeLinkResolver_Linkify_IllegalPageKey_EmitsSanitizedRelativeLink` and -`CppTypeLinkResolver_Linkify_AlreadySanitizedFolder_IsNotSanitizedAgain`. +`CppTypeLinkResolver_Linkify_DisplacedPageKey_EmitsApprovedRelativeLink` and +`CppTypeLinkResolver_Linkify_ApprovedFolder_IsUsedAsIs`. **Exact qualified match emits a link**: Verifies that when a fully-qualified type name (e.g. `"ns::Foo"`) exactly matches a key in `knownTypes`, `Linkify` returns a string diff --git a/docs/verification/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md b/docs/verification/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md index f67d8af..c45e6d6 100644 --- a/docs/verification/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md +++ b/docs/verification/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md @@ -25,8 +25,7 @@ service or network dependency is needed. - A namespace summary page is created for each namespace in the fixture assembly. - A type page is created for each visible type in each namespace. - At least one member detail page is created for a visible member of a visible type. -- A single combined page is created for members whose sanitized file names collide on a - case-insensitive filesystem. +- Members whose names differ only in case receive separate pages (`Name`, `name-2`) chosen by the registry. - A type with pure method overloads produces a consolidated method overload page. - A type with operator overloads produces an `operators.md` page. - A type with a nested type produces a dedicated page under the containing type's folder. @@ -99,11 +98,10 @@ whose key contains both `SampleClass` and `Reset` is created, confirming that per-member detail pages are emitted for all visible members. This scenario is tested by `DotNetEmitterGradualDisclosure_Emit_ValidModel_CreatesMemberDetailPage`. -**Combined collision page is created for CaseCollisionClass**: Verifies that when -`CaseCollisionClass` has members whose sanitized names differ only in case (`name` -and `Name`), the emitter creates a single combined page keyed by the lower-invariant -name rather than two separate colliding pages. This scenario is tested by -`DotNetEmitterGradualDisclosure_Emit_CaseCollision_CreatesCombinedPage`. +**Case-twin members get separate pages for CaseCollisionClass**: Verifies that when +`CaseCollisionClass` has members whose names differ only in case (`name` +and `Name`), the emitter creates two pages, `Name` and `name-2`, chosen by the registry. This scenario is tested by +`DotNetEmitterGradualDisclosure_Emit_CaseCollision_CreatesSeparatePages`. **Method overload page is created for overloaded methods**: Verifies that a type with multiple overloads of the same method name produces a consolidated overload @@ -237,7 +235,6 @@ nested-type and nested-member page is the relative breadcrumb with the expected `›` separator (type pages omit the type itself; other pages include it). Tested by `DotNetEmitterGradualDisclosure_Emit_Pages_HaveRelativeBreadcrumbs`, `DotNetEmitterGradualDisclosure_Emit_OverloadPage_HasBreadcrumb`, -`DotNetEmitterGradualDisclosure_Emit_CombinedPage_HasBreadcrumb`, `DotNetEmitterGradualDisclosure_Emit_NestedType_BreadcrumbsIncludeOuterType` and `DotNetEmitterGradualDisclosure_Emit_MemberPage_FirstParagraphIsBreadcrumb`. Cross-reference tests exempt only that first breadcrumb paragraph from the no-link check, so links in the @@ -353,7 +350,7 @@ disclosure. Expected: each reference appears as a code span, such as ``Formats ` Tested by `DotNetEmitterGradualDisclosure_Emit_InlineReferences_RenderAsCodeSpansEverywhere`. **Generic type pages**: Arrange the `GenericTypeDocs` fixtures. Act by emitting gradual disclosure. -Expected: file names use the hyphenated `Name-{arity}` form, the H1 is `PropertyEditorRegistry\`, a +Expected: file names use the brace-arity `Name{arity}` form, the H1 is `PropertyEditorRegistry\`, a Type Parameters table lists each `` (placeholder when undocumented), signatures carry `where` constraints, and a non-generic nested type has no Type Parameters section. Tested by `DotNetEmitterGradualDisclosure_Emit_GenericTypePage_ShowsEscapedHeadingAndTypeParameters`, diff --git a/docs/verification/api-mark-dot-net/dot-net-generator.md b/docs/verification/api-mark-dot-net/dot-net-generator.md index a877c9c..493f9c4 100644 --- a/docs/verification/api-mark-dot-net/dot-net-generator.md +++ b/docs/verification/api-mark-dot-net/dot-net-generator.md @@ -201,17 +201,11 @@ member ID uses `.` as the separator (matching the XML doc format) rather than th used by Cecil's `FullName`. This scenario is tested by `DotNetGenerator_Generate_ConversionOperatorReturningNestedType_OperatorsPageContainsSummary`. -**Case-collision class creates a combined page**: Verifies that members whose names differ only by -case are merged into a single combined detail page on case-insensitive targets. This scenario is -tested by `DotNetGenerator_Generate_CaseCollisionClass_CreatesCombinedPage`. - -**Case-collision class does not create a separate cased page**: Verifies that the generator does -not emit a second detail page for the same member name with different casing. This scenario is -tested by `DotNetGenerator_Generate_CaseCollisionClass_DoesNotCreateSeparateCasedPage`. - -**Case-collision class combined page contains both members**: Verifies that the combined detail -page includes documentation for both case-colliding members. This scenario is tested by -`DotNetGenerator_Generate_CaseCollisionClass_CombinedPageContainsBothMembers`. +**Case-collision class creates separate pages**: Verifies that members whose names differ only by +case get separate pages (`Name`, `name-2`) so that no two pages differ only in case. Tested by +`DotNetGenerator_Generate_CaseCollisionClass_CreatesSeparatePages`, +`DotNetGenerator_Generate_CaseCollisionClass_NoPagesDifferOnlyInCase`, and +`DotNetGenerator_Generate_CaseCollisionClass_EachMemberHasOwnPage`. **Missing assembly path throws FileNotFoundException**: Verifies that calling `Parse` when the configured assembly path does not exist throws `FileNotFoundException` with the missing path, diff --git a/docs/verification/api-mark-dot-net/type-link-resolver.md b/docs/verification/api-mark-dot-net/type-link-resolver.md index 90e3b7a..f901cad 100644 --- a/docs/verification/api-mark-dot-net/type-link-resolver.md +++ b/docs/verification/api-mark-dot-net/type-link-resolver.md @@ -308,6 +308,6 @@ and `DotNetEmitterGradualDisclosure_Emit_AllLinkAnchors_MatchRealHeadings`. **Nested and generic-nested external crefs**: Arrange `NestedExternalConsumer` with `ExternalLinks` mapping the external fixture assembly. Expected: crefs and property types naming `NestedHost.Inner`, `NestedHost.Deep.Leaf`, and `NestedGenericHost.Inner` link to `.../NestedHost/Inner.md`, `.../NestedHost/Deep/Leaf.md`, and -`.../NestedGenericHost-1/Inner-1.md`. Tested by +`.../NestedGenericHost{1}/Inner{1}.md`. Tested by `DotNetEmitterGradualDisclosure_Emit_ExternalNestedTypeCrefs_LinkToNestedPages` and `DotNetEmitterGradualDisclosure_Emit_ExternalNestedPropertyTypes_LinkToNestedPages`. diff --git a/docs/verification/api-mark-dot-net/type-name-simplifier.md b/docs/verification/api-mark-dot-net/type-name-simplifier.md index 5ccd154..1eef554 100644 --- a/docs/verification/api-mark-dot-net/type-name-simplifier.md +++ b/docs/verification/api-mark-dot-net/type-name-simplifier.md @@ -158,8 +158,9 @@ signature shows `ExternalBaseClass`, `ExternalGenericFormat`, and so on, `DotNetEmitterGradualDisclosure_Emit_NoSignatureContainsExternalNamespace`. **Generic arity file names**: Arrange `Foo`, `Foo1`, `Foo`, `Foo`, and `Outer.Inner`. -Expected: `Foo-1.md` and `Foo1.md` coexist, namespace indexes link to the hyphenated names, nested generic -types use hyphenated folders and files, and all relative links resolve. Tested by +Expected: `Foo{1}.md` and `Foo1.md` coexist, namespace indexes link to the brace-arity names, nested generic +types use brace-arity folders and files, and all relative links resolve. The registry rules are verified by +`DotNetFileNamingTests` and the `SymbolNameRegistry` tests. Tested by `DotNetEmitterGradualDisclosure_Emit_SameNamedGenericTypes_ProduceDistinctFiles`, `DotNetEmitterGradualDisclosure_Emit_NamespaceIndex_LinksHyphenatedArityFiles`, `DotNetEmitterGradualDisclosure_Emit_NestedGenericType_UsesHyphenatedFolderAndFile`, diff --git a/docs/verification/definition.yaml b/docs/verification/definition.yaml index 6d89ff0..ac41d91 100644 --- a/docs/verification/definition.yaml +++ b/docs/verification/definition.yaml @@ -8,7 +8,7 @@ input-files: - docs/verification/api-mark-core/i-markdown-writer-factory.md - docs/verification/api-mark-core/i-markdown-writer.md - docs/verification/api-mark-core/path-helpers.md - - docs/verification/api-mark-core/file-name-sanitizer.md + - docs/verification/api-mark-core/symbol-name-registry.md - docs/verification/api-mark-dot-net.md - docs/verification/api-mark-dot-net/dot-net-generator.md - docs/verification/api-mark-dot-net/type-name-simplifier.md diff --git a/docs/verification/introduction.md b/docs/verification/introduction.md index bf7ff0f..5969d69 100644 --- a/docs/verification/introduction.md +++ b/docs/verification/introduction.md @@ -25,7 +25,7 @@ Local items: Markdown syntax for all write operations. - **IContext**: unit verification of the output-channel contract interface. - **PathHelpers**: unit verification of the safe path-combination utility. -- **FileNameSanitizer**: unit verification of the legal file-name utility. +- **SymbolNameRegistry**: unit verification of the symbol-name registry, file-name rules and link-path helper. - **IApiEmitter**: unit verification of the language-emitter contract interface. - **EmitConfig**: unit verification of the emit-configuration value object and output-format enum. - **GlobFileCollector**: unit verification of glob-based file discovery — inclusion and exclusion @@ -133,7 +133,7 @@ Local items have parallel artifacts in: `docs/reqstream/api-mark-core/i-markdown-writer-factory.yaml`, `docs/reqstream/api-mark-core/i-markdown-writer.yaml`, `docs/reqstream/api-mark-core/path-helpers.yaml`, - `docs/reqstream/api-mark-core/file-name-sanitizer.yaml`, + `docs/reqstream/api-mark-core/symbol-name-registry.yaml`, `docs/reqstream/api-mark-core/glob-file-collector.yaml`, `docs/reqstream/api-mark-cpp.yaml`, `docs/reqstream/api-mark-cpp/cpp-generator.yaml`, @@ -175,7 +175,7 @@ Local items have parallel artifacts in: `docs/design/api-mark-core/i-markdown-writer-factory.md`, `docs/design/api-mark-core/i-markdown-writer.md`, `docs/design/api-mark-core/path-helpers.md`, - `docs/design/api-mark-core/file-name-sanitizer.md`, + `docs/design/api-mark-core/symbol-name-registry.md`, `docs/design/api-mark-core/glob-file-collector.md`, `docs/design/api-mark-cpp.md`, `docs/design/api-mark-cpp/cpp-generator.md`, `docs/design/api-mark-cpp/cpp-ast-model.md`, @@ -213,7 +213,7 @@ Local items have parallel artifacts in: `docs/verification/api-mark-core/i-markdown-writer-factory.md`, `docs/verification/api-mark-core/i-markdown-writer.md`, `docs/verification/api-mark-core/path-helpers.md`, - `docs/verification/api-mark-core/file-name-sanitizer.md`, + `docs/verification/api-mark-core/symbol-name-registry.md`, `docs/verification/api-mark-core/glob-file-collector.md`, `docs/verification/api-mark-cpp.md`, `docs/verification/api-mark-cpp/cpp-generator.md`, diff --git a/requirements.yaml b/requirements.yaml index e6fddce..a8b4b52 100644 --- a/requirements.yaml +++ b/requirements.yaml @@ -17,7 +17,7 @@ includes: - docs/reqstream/api-mark-core/i-markdown-writer.yaml - docs/reqstream/api-mark-core/file-markdown-writer.yaml - docs/reqstream/api-mark-core/path-helpers.yaml - - docs/reqstream/api-mark-core/file-name-sanitizer.yaml + - docs/reqstream/api-mark-core/symbol-name-registry.yaml - docs/reqstream/api-mark-core/glob-file-collector.yaml - docs/reqstream/api-mark-core/i-documentation-coverage-capable.yaml - docs/reqstream/api-mark-dot-net.yaml From ebced062a124bdb7462724a58736ce4f257ce85b Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 15 Oct 2026 01:44:18 -0400 Subject: [PATCH 07/15] docs: reduce file-name change note to a brief mention Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/build_notes/introduction.md | 17 ++++------------- docs/design/api-mark-core/file-naming-rules.md | 3 +-- .../dot-net-emitter-gradual-disclosure.md | 4 ++-- docs/user_guide/dotnet.md | 7 ++----- 4 files changed, 9 insertions(+), 22 deletions(-) diff --git a/docs/build_notes/introduction.md b/docs/build_notes/introduction.md index 3c01944..b383c44 100644 --- a/docs/build_notes/introduction.md +++ b/docs/build_notes/introduction.md @@ -43,19 +43,10 @@ pwsh ./build.ps1 ## Breaking Changes -Generated file names changed. All file and folder names now come from one symbol-name registry that -resolves every collision before anything is written. Update any links that point at the old names: - -| Old name | New name | -| -------------------------------- | ---------------------------------------- | -| `Foo-1.md` (`Foo`) | `Foo{1}.md` | -| `Name-.md` (illegal char) | `Name_.md` (character replaced by `_`) | -| `CON-.md` (reserved name) | `CON_.md` | -| `operators-.md` (member clash) | `operators-2.md` | -| One combined page for case twins | `Name.md` and `name-2.md` | -| `IFoo{System.Int32}.Get` | `IFoo{int}.Get` | - -The complete rules are in the user guides and `docs/design/api-mark-core/file-naming-rules.md`. +Generated file names changed (for example `Foo-1.md` is now `Foo{1}.md`). All names now come from one +symbol-name registry that resolves every collision before anything is written. Output is regenerated on +each build, so no migration is needed. The rules are in +`docs/design/api-mark-core/file-naming-rules.md`. ## References diff --git a/docs/design/api-mark-core/file-naming-rules.md b/docs/design/api-mark-core/file-naming-rules.md index df05243..0a6c440 100644 --- a/docs/design/api-mark-core/file-naming-rules.md +++ b/docs/design/api-mark-core/file-naming-rules.md @@ -139,8 +139,7 @@ other package's collisions cannot be known. - Predictability first: a plain name wins over a suffixed one, and `-N` is only the last resort. - Every symbol, visible or not, is registered so names are stable regardless of visibility. -- Migrating from earlier releases: `Foo-1` is now `Foo{1}`; hash-suffixed names are now `_` and `-N` names; - reserved names use a `_` suffix instead of `-`. +- No legacy naming or migration support: output is regenerated on each build, so only link integrity matters. ### Error Handling diff --git a/docs/design/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md b/docs/design/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md index bd3feba..5f962b2 100644 --- a/docs/design/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md +++ b/docs/design/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md @@ -455,8 +455,8 @@ N/A — this is an internal class with no external interfaces exposed beyond its - Type and member files use the brace-arity form `Name{arity}` (for example `PropertyEditorRegistry{1}.md`). `Foo`, `Foo` and `Foo` map to `Foo.md`, `Foo{1}.md` and `Foo{2}.md`, and a type named `Foo1` keeps `Foo1.md`. All names are approved once by the symbol-name registry (`DotNetNames`); the emitters - and the link resolver only read them, so page keys and links cannot diverge. This is a breaking change - to earlier `Foo-1.md` output. Readable names appear in headings and link text. + and the link resolver only read them, so page keys and links cannot diverge. + Readable names appear in headings and link text. - Namespace pages begin with `[API Reference](api.md) › ancestors › This`; only ancestors that have pages are linked and the current namespace is plain text. - `api.md` starts with a `**Start here:**` link to the namespace with the most types (first diff --git a/docs/user_guide/dotnet.md b/docs/user_guide/dotnet.md index 7c626e3..c1e8588 100644 --- a/docs/user_guide/dotnet.md +++ b/docs/user_guide/dotnet.md @@ -136,8 +136,7 @@ Generic types use escaped headings (`PropertyEditorRegistry\`), a Type table taken from ``, and `where` constraints in signatures. Their file names use brace arity, `Name{arity}` (for example `PropertyEditorRegistry{1}.md` for `PropertyEditorRegistry` and `KeyedPair{2}.md` for `KeyedPair`). Braces cannot appear in a C# identifier, so `Foo{1}.md` (for `Foo`) never collides with `Foo1.md` (for a type named -`Foo1`), and `Foo.md`, `Foo{1}.md` and `Foo{2}.md` stay distinct. Earlier releases wrote `Foo-1.md` (and -before that `Name1.md`); links that point at the old names must be updated. Overload rows +`Foo1`), and `Foo.md`, `Foo{1}.md` and `Foo{2}.md` stay distinct. Overload rows list every distinct return type (including `void`); the row shows the summary only when every overload has the same summary; when they differ, or any overload has none, it shows `Overloaded; see the member page.` instead of one overload's summary. Cross-reference links to an overload include the signature in the link text (`UndoHistory.Transact(string, Action)`) and end with an anchor to @@ -169,9 +168,7 @@ complete numbered rules are in `docs/design/api-mark-core/file-naming-rules.md`. the visibility filter. Limitation: a link to a type in an external (mapped) assembly uses that type's predictable name, and is -wrong if the other package had to displace it with a numeric suffix. This is a breaking change from -earlier releases (`Foo-1.md` is now `Foo{1}.md`, hash-suffixed names are now `_` and `-N` names); update -any links that point at the old file names. +wrong if the other package had to displace it with a numeric suffix. ### Breadcrumbs From e0958395039e0806483939304c41d9964a9c7556 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 15 Oct 2026 01:59:01 -0400 Subject: [PATCH 08/15] test: update external-link assertions for brace-arity file names Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/ApiMark.Tool.Tests/ProgramTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/ApiMark.Tool.Tests/ProgramTests.cs b/test/ApiMark.Tool.Tests/ProgramTests.cs index 015f1d5..dda319c 100644 --- a/test/ApiMark.Tool.Tests/ProgramTests.cs +++ b/test/ApiMark.Tool.Tests/ProgramTests.cs @@ -277,7 +277,7 @@ public void Program_Main_DotNetWithExternalLink_LinksExternalType() // Assert Assert.Equal(0, exitCode); - Assert.Contains("OtherLib/api/Microsoft.Extensions.Logging/ILogger-1.md", page, StringComparison.Ordinal); + Assert.Contains("OtherLib/api/Microsoft.Extensions.Logging/ILogger{1}.md", page, StringComparison.Ordinal); } /// @@ -292,7 +292,7 @@ public void Program_Main_DotNetWithExternalLink_AssemblyNameIsCaseInsensitive() // Assert Assert.Equal(0, exitCode); - Assert.Contains("OtherLib/api/Microsoft.Extensions.Logging/ILogger-1.md", page, StringComparison.Ordinal); + Assert.Contains("OtherLib/api/Microsoft.Extensions.Logging/ILogger{1}.md", page, StringComparison.Ordinal); } /// @@ -328,7 +328,7 @@ public void Program_Main_DotNetWithBlankExternalLink_IsSkipped() Assert.NotEmpty(page); Assert.Contains("ILogger", page, StringComparison.Ordinal); Assert.DoesNotContain("OtherLib", page, StringComparison.Ordinal); - Assert.DoesNotContain("ILogger-1.md", page, StringComparison.Ordinal); + Assert.DoesNotContain("ILogger{1}.md", page, StringComparison.Ordinal); } /// From 8bfb938e3a36ad636f8edce54e3ca239fc8566ff Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 15 Oct 2026 02:16:42 -0400 Subject: [PATCH 09/15] test: add Core, C++ and VHDL naming regression tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../SymbolNameRegistryTests.cs | 29 +++++++++++ test/ApiMark.Cpp.Tests/CppFileNamingTests.cs | 48 +++++++++++++++++++ .../ApiMark.Vhdl.Tests/VhdlFileNamingTests.cs | 28 +++++++++++ 3 files changed, 105 insertions(+) diff --git a/test/ApiMark.Core.Tests/SymbolNameRegistryTests.cs b/test/ApiMark.Core.Tests/SymbolNameRegistryTests.cs index 438ebab..c27c0ba 100644 --- a/test/ApiMark.Core.Tests/SymbolNameRegistryTests.cs +++ b/test/ApiMark.Core.Tests/SymbolNameRegistryTests.cs @@ -68,6 +68,35 @@ public void SymbolNameRegistry_Resolve_IdentityName_BeatsTransformedName() Assert.Equal("a_b-2", registry.GetName("a")); } + /// + /// Validates the sanitizer collision cases of the earlier review: literal braces never collide with + /// escaped angle brackets, and names that look like escapes or hashes are never merged. + /// + [Fact] + public void SymbolNameRegistry_Resolve_BracesBracketsAndEscapeLookalikes_AreDistinctAndExact() + { + // Arrange / Act + var registry = Resolved( + ("k1", null, "A", SymbolKind.Type), + ("k2", null, "A{B}", SymbolKind.Type), + ("k3", null, "A_B_", SymbolKind.Type), + ("k4", null, "A_B_-2", SymbolKind.Type), + ("k5", null, "Foo_1a2b3c4d", SymbolKind.Type), + ("k6", null, "Foo?", SymbolKind.Type), + ("k7", null, "Foo*", SymbolKind.Type)); + + // Assert: literal braces are kept; identity names win; transformed twins take the next free suffix + Assert.Equal("A{B}", registry.GetName("k2")); + Assert.Equal("A_B_", registry.GetName("k3")); + Assert.Equal("A_B_-2", registry.GetName("k4")); + Assert.Equal("A_B_-3", registry.GetName("k1")); + Assert.Equal("Foo_1a2b3c4d", registry.GetName("k5")); + Assert.Equal("Foo_", registry.GetName("k6")); + Assert.Equal("Foo_-2", registry.GetName("k7")); + var paths = registry.GetAllPaths(); + Assert.Equal(paths.Count, paths.Select(p => p.ToLowerInvariant()).Distinct().Count()); + } + /// Validates that a namespace keeps the plain name when a type claims the same name. [Fact] public void SymbolNameRegistry_Resolve_NamespaceAndType_NamespaceWins() diff --git a/test/ApiMark.Cpp.Tests/CppFileNamingTests.cs b/test/ApiMark.Cpp.Tests/CppFileNamingTests.cs index f9378e5..8a06b22 100644 --- a/test/ApiMark.Cpp.Tests/CppFileNamingTests.cs +++ b/test/ApiMark.Cpp.Tests/CppFileNamingTests.cs @@ -313,6 +313,54 @@ public void CppEmitter_CollisionModel_AllPagesUniqueLegalAndLinksResolve() Assert.Equal(1, factory.Requests.Count(r => r == "api")); } + /// Template names written with angle brackets and with literal braces never share a page. + [Fact] + public void CppNames_AngleTemplateAndLiteralBraceNames_AreDistinctPages() + { + // Arrange + var lib = Ns("lib"); + lib.Classes.Add(Cls("Foo")); + lib.Classes.Add(Cls("Foo{int}")); + lib.Classes.Add(Cls("Foo_int_")); + + // Act + var names = CppNames.Build(Decls(("lib", lib))); + var ns = CppNames.NamespaceKey("lib"); + + // Assert + Assert.Equal("lib/Foo{int}", names.Path(CppNames.ClassKey(ns, "Foo"))); + Assert.Equal("lib/Foo{int}-2", names.Path(CppNames.ClassKey(ns, "Foo{int}"))); + Assert.Equal("lib/Foo_int_", names.Path(CppNames.ClassKey(ns, "Foo_int_"))); + } + + /// A link to a nested, templated class in an escaped namespace points at exactly the page that is written. + [Fact] + public void CppEmitter_NestedTemplateInEscapedNamespace_LinkTargetEqualsWrittenPath() + { + // Arrange + var odd = Ns("a::b"); + odd.Classes.Add(Cls("Outer", null, null, [Cls("Inner")])); + odd.Classes.Add(Cls("Plain", [Fn("Get", "a::b::Outer::Inner", new CppParameter("o", "Outer"))])); + + // Act + var factory = Emit(Decls(("a::b", odd))); + + // Assert: pages are written once under the once-escaped names + Assert.Contains("a__b/Outer{T}/Inner", factory.Requests); + Assert.Contains("a__b/Plain/Get", factory.Requests); + Assert.DoesNotContain(factory.Requests, r => r.Contains("__b__", StringComparison.Ordinal)); + + // The member page links to the nested class and the template with the same paths + var cells = factory.Writers["a__b/Plain/Get"].Operations + .OfType().SelectMany(t => t.Rows.SelectMany(r => r)) + .Concat(factory.Writers["a__b/Plain/Get"].Operations.OfType().Select(p => p.Text)) + .Concat(factory.Writers["a__b/Plain/Get"].Operations.OfType().Select(s => s.Code)) + .ToList(); + Assert.Contains(cells, c => c.Contains("](../Outer{T}/Inner.md)", StringComparison.Ordinal)); + Assert.Contains(cells, c => c.Contains("](../Outer{T}.md)", StringComparison.Ordinal)); + Assert.True(AssertAllLinksResolve(factory.Writers) > 3); + } + /// Emitting the same model twice yields identical page sets. [Fact] public void CppEmitter_CollisionModel_IsDeterministic() diff --git a/test/ApiMark.Vhdl.Tests/VhdlFileNamingTests.cs b/test/ApiMark.Vhdl.Tests/VhdlFileNamingTests.cs index a85acc6..34e9766 100644 --- a/test/ApiMark.Vhdl.Tests/VhdlFileNamingTests.cs +++ b/test/ApiMark.Vhdl.Tests/VhdlFileNamingTests.cs @@ -216,6 +216,34 @@ public void VhdlNames_IllegalAndDeviceNames_AreEscaped() AssertUniqueLegalAndLinked(factory); } + /// + /// An entity Api, a package API and a package Aux never overwrite the index page; the + /// package page, its folder and every subprogram link all use the same resolved name. + /// + [Fact] + public void VhdlNames_EntityApiPackageApiAndAux_ShareOneResolvedNamePerSymbol() + { + // Arrange and Act + var factory = Emit(new VhdlFileModel("a.vhd", [Entity("Api")], [], [Pkg("API", Func("Run"), Func("Aux")), Pkg("Aux", Func("Aux"))])); + + // Assert: the reserved index keeps api; the entity then beats the package of the same name + Assert.Equal( + ["API-3", "API-3/Aux_", "API-3/Run", "Api-2", "Aux_", "Aux_/Aux_", "api"], + factory.Requests.OrderBy(r => r, StringComparer.Ordinal).ToList()); + + // The index links every symbol by its resolved name + var index = factory.Writers["api"].Operations.OfType().SelectMany(t => t.Rows).Select(r => r[0]).ToList(); + Assert.Contains("[Api](Api-2.md)", index); + Assert.Contains("[API](API-3.md)", index); + Assert.Contains("[Aux](Aux_.md)", index); + + // The package page links its subprograms into the folder that carries the same resolved name + var paragraphs = factory.Writers["API-3"].Operations.OfType().Select(p => p.Text).ToList(); + Assert.Contains("**[Run](API-3/Run.md)** — *Function*", paragraphs); + Assert.Contains("**[Aux](API-3/Aux_.md)** — *Function*", paragraphs); + Assert.True(AssertUniqueLegalAndLinked(factory) >= 5); + } + /// Names are the same for every run. [Fact] public void VhdlNames_SameModel_IsDeterministic() From e6f1b0dc1643fa14265f7a0615330055247a4928 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 15 Oct 2026 02:16:43 -0400 Subject: [PATCH 10/15] test: add DotNet synthetic-assembly and external-link naming regression tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DotNetEmitterExternalNestedLinkTests.cs | 35 ++ .../DotNetSyntheticNamingTests.cs | 344 ++++++++++++++++++ 2 files changed, 379 insertions(+) create mode 100644 test/ApiMark.DotNet.Tests/DotNetSyntheticNamingTests.cs diff --git a/test/ApiMark.DotNet.Tests/DotNetEmitterExternalNestedLinkTests.cs b/test/ApiMark.DotNet.Tests/DotNetEmitterExternalNestedLinkTests.cs index 531a3f7..a651e35 100644 --- a/test/ApiMark.DotNet.Tests/DotNetEmitterExternalNestedLinkTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetEmitterExternalNestedLinkTests.cs @@ -65,4 +65,39 @@ public void DotNetEmitterGradualDisclosure_Emit_ExternalNestedPropertyTypes_Link Assert.Contains(cells, c => c.Contains($"]({Root}/NestedHost/Inner.md)", StringComparison.Ordinal)); Assert.Contains(cells, c => c.Contains($"]({Root}/NestedGenericHost{{1}}/Inner{{1}}.md)", StringComparison.Ordinal)); } + + /// + /// Validates every link into the mapped assembly names a page that the mapped assembly's own run + /// writes, so the predictable name equals the name its registry approves. + /// + [Fact] + public void DotNetEmitterGradualDisclosure_Emit_ExternalLinks_MatchTargetAssemblyRegistryNames() + { + // Arrange + var consumer = Emit(); + var target = new InMemoryMarkdownWriterFactory(); + new DotNetGenerator(new DotNetGeneratorOptions + { + AssemblyPath = FixturePaths.GetExternalFixtureDll(), + XmlDocPath = FixturePaths.GetExternalFixtureXmlDoc(), + Visibility = ApiVisibility.Public, + }).Parse(new InMemoryContext()).Emit(target, new EmitConfig(), new InMemoryContext()); + const string marker = "OtherLib/api/"; + var pattern = new System.Text.RegularExpressions.Regex(@"\]\(((?:\.\./)+OtherLib/api/[^)\s#]+)\.md", System.Text.RegularExpressions.RegexOptions.Compiled); + + // Act + var linked = consumer.Writers.Values + .SelectMany(w => w.Operations.OfType().Select(p => p.Text) + .Concat(w.Operations.OfType().SelectMany(t => t.Rows.SelectMany(r => r)))) + .SelectMany(t => pattern.Matches(t).Select(m => m.Groups[1].Value)) + .Select(v => v[(v.IndexOf(marker, StringComparison.Ordinal) + marker.Length)..]) + .Distinct(StringComparer.Ordinal) + .ToList(); + + // Assert + Assert.Contains("ApiMark.DotNet.Fixtures.External/Nested/NestedGenericHost{1}/Inner{1}", linked); + Assert.True(linked.Count >= 4, string.Join(", ", linked)); + var missing = linked.Where(l => !target.Writers.ContainsKey(l)).ToList(); + Assert.True(missing.Count == 0, "Links to pages the target run does not write: " + string.Join(", ", missing)); + } } diff --git a/test/ApiMark.DotNet.Tests/DotNetSyntheticNamingTests.cs b/test/ApiMark.DotNet.Tests/DotNetSyntheticNamingTests.cs new file mode 100644 index 0000000..dd169c8 --- /dev/null +++ b/test/ApiMark.DotNet.Tests/DotNetSyntheticNamingTests.cs @@ -0,0 +1,344 @@ +// Copyright (c) DEMA Consulting +// SPDX-License-Identifier: MIT + +using System.Text.RegularExpressions; +using ApiMark.Core; +using ApiMark.Core.TestHelpers; +using ApiMark.DotNet; +using Mono.Cecil; +using Mono.Cecil.Cil; +using Xunit; + +namespace ApiMark.DotNet.Tests; + +/// +/// End-to-end naming tests over a synthetic assembly whose hostile names cannot be written in C#: +/// literal braces and square brackets, Windows device names, names that look like reserved pages, a +/// namespace that collides with a type, and case twins. Every scenario asserts the exact file names +/// the symbol-name registry produces and that every link and breadcrumb resolves to a written page. +/// +public class DotNetSyntheticNamingTests : IDisposable +{ + /// Matches a Markdown link destination ending in .md, with an optional anchor. + private static readonly Regex LinkPattern = new(@"\]\(([^)\s#]+\.md)(?:#[^)\s]*)?\)", RegexOptions.Compiled); + + /// The directory that holds the synthetic assembly. + private readonly string _directory = Path.Combine(Path.GetTempPath(), "apimark-synthetic-" + Guid.NewGuid().ToString("N")); + + /// Gets the page keys of the shared all-visibility emit. + private HashSet AllKeys => Emitted.Writers.Keys.ToHashSet(StringComparer.Ordinal); + + /// Gets the shared emit of the synthetic assembly. + private InMemoryMarkdownWriterFactory Emitted => _emitted ??= Emit(ApiVisibility.All); + + /// Backing field of . + private InMemoryMarkdownWriterFactory? _emitted; + + /// Initializes a new instance of the class. + public DotNetSyntheticNamingTests() + { + Directory.CreateDirectory(_directory); + } + + /// + public void Dispose() + { + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, recursive: true); + } + + GC.SuppressFinalize(this); + } + + /// Adds a public class to . + private static TypeDefinition AddType(ModuleDefinition module, string ns, string name, params string[] genericParameters) + { + var type = new TypeDefinition(ns, name, TypeAttributes.Public | TypeAttributes.Class, module.ImportReference(typeof(object))); + foreach (var parameter in genericParameters) + { + type.GenericParameters.Add(new GenericParameter(parameter, type)); + } + + module.Types.Add(type); + return type; + } + + /// Adds a public method (with a trivial body) to . + private static MethodDefinition AddMethod(TypeDefinition type, string name, TypeReference returnType, MethodAttributes extra, params TypeReference[] parameters) + { + var method = new MethodDefinition(name, MethodAttributes.Public | MethodAttributes.HideBySig | extra, returnType); + for (var i = 0; i < parameters.Length; i++) + { + method.Parameters.Add(new ParameterDefinition("p" + i, ParameterAttributes.None, parameters[i])); + } + + method.Body.GetILProcessor().Emit(OpCodes.Ret); + type.Methods.Add(method); + return method; + } + + /// Adds a public int field to . + private static void AddField(ModuleDefinition module, TypeDefinition type, string name) => + type.Fields.Add(new FieldDefinition(name, FieldAttributes.Public, module.ImportReference(typeof(int)))); + + /// Builds the synthetic assembly and returns the (dll, xml) paths. + private (string Dll, string Xml) Build() + { + var assembly = AssemblyDefinition.CreateAssembly(new AssemblyNameDefinition("Synth", new Version(1, 0)), "Synth", ModuleKind.Dll); + var m = assembly.MainModule; + var voidType = m.ImportReference(typeof(void)); + var intType = m.ImportReference(typeof(int)); + + // Namespace A.B and type A.B collide on the path A/B + var typeB = AddType(m, "A", "B"); + var typeC = AddType(m, "A.B", "C"); + AddMethod(typeB, "Get", typeC, 0); + AddMethod(typeC, "Make", typeB, 0, typeB); + AddField(m, typeB, "Value"); + + // Root namespace named like the reserved index page, with same-named type and member + var api = AddType(m, "api", "api"); + AddMethod(api, "api", voidType, 0); + AddType(m, "Other", "Api"); + AddType(m, "Other", "api"); + + // Windows device names, with extensions, plus reserved-page names as members + var con = AddType(m, "Dev", "CON"); + var nul = AddType(m, "Dev", "NUL"); + AddType(m, "Dev", "COM1"); + AddType(m, "Dev", "LPT2.log"); + AddMethod(con, "Use", nul, 0, nul); + AddField(m, con, "operators"); + AddField(m, con, "Operators"); + AddMethod(con, "op_Addition", con, MethodAttributes.Static | MethodAttributes.SpecialName, con, con); + AddMethod(con, "op_Custom", intType, 0); + AddMethod(con, "operator+", intType, 0); + AddField(m, con, "Nul"); + + // Literal braces, square brackets, parentheses and underscores + AddType(m, "Hostile", "A[B]"); + AddType(m, "Hostile", "A(B)"); + AddType(m, "Hostile", "A{B}"); + AddType(m, "Hostile", "A_B_"); + AddType(m, "Hostile", "a_b_"); + AddType(m, "Hostile", "x."); + AddType(m, "Hostile", "Foo"); + AddType(m, "Hostile", "Foo1"); + var generic = AddType(m, "Hostile", "Foo`1", "T"); + var inner = new TypeDefinition(string.Empty, "Inner`1", TypeAttributes.NestedPublic | TypeAttributes.Class, m.ImportReference(typeof(object))); + inner.GenericParameters.Add(new GenericParameter("U", inner)); + generic.NestedTypes.Add(inner); + AddType(m, "Hostile", "Foo`2", "T1", "T2"); + + // Case-twin namespaces, types and members + AddType(m, "R", "X"); + AddType(m, "R.Twin", "Y"); + AddType(m, "R.twin", "Z"); + var twins = AddType(m, "R", "Members"); + AddType(m, "R", "members"); + AddField(m, twins, "value"); + AddField(m, twins, "Value"); + + var dll = Path.Combine(_directory, "Synth.dll"); + assembly.Write(dll); + var xml = Path.Combine(_directory, "Synth.xml"); + File.WriteAllText(xml, "Synth"); + return (dll, xml); + } + + /// Emits the synthetic assembly in gradual-disclosure format. + private InMemoryMarkdownWriterFactory Emit(ApiVisibility visibility) + { + var (dll, xml) = Build(); + var factory = new InMemoryMarkdownWriterFactory(); + var context = new InMemoryContext(); + new DotNetGenerator(new DotNetGeneratorOptions { AssemblyPath = dll, XmlDocPath = xml, Visibility = visibility }) + .Parse(context).Emit(factory, new EmitConfig(), context); + Assert.Empty(context.Errors); + return factory; + } + + /// Resolves a relative link against the page that contains it. + private static string Resolve(string key, string link) + { + var folder = key.Contains('/') ? key[..key.LastIndexOf('/')] : string.Empty; + var path = new Uri(new Uri("http://x/" + (folder.Length > 0 ? folder + "/" : string.Empty)), link).AbsolutePath.TrimStart('/'); + return Uri.UnescapeDataString(path)[..^3]; + } + + /// Gets every text of a page that may contain a link. + private static IEnumerable Texts(InMemoryMarkdownWriter writer) => + writer.Operations.OfType().Select(p => p.Text) + .Concat(writer.Operations.OfType().SelectMany(t => t.Rows.SelectMany(r => r))); + + /// A namespace beats a same-named type: the type becomes B-2 and its children move with it. + [Fact] + public void DotNetSyntheticNaming_NamespaceAndTypeSamePath_NamespaceWinsTypeIsSuffixedAndLinksResolve() + { + var keys = AllKeys; + + Assert.Contains("A/B", keys); + Assert.Contains("A/B/C", keys); + Assert.Contains("A/B-2", keys); + Assert.Contains("A/B-2/Get", keys); + Assert.Contains("A/B-2/Value", keys); + Assert.DoesNotContain("A/B/Get", keys); + + // The namespace page lists the type under its displaced name and the type page links the namespace's type + var nsRows = Emitted.Writers["A/B"].Operations.OfType().SelectMany(t => t.Rows).ToList(); + Assert.Contains(nsRows, r => r[0] == "[C](B/C.md)"); + var rootRows = Emitted.Writers["A"].Operations.OfType().SelectMany(t => t.Rows).ToList(); + Assert.Contains(rootRows, r => r[0] == "[B](A/B-2.md)"); + Assert.Contains(rootRows, r => r[0] == "[A.B](A/B.md)"); + } + + /// Hostile type names get exactly the documented legal names, each used once. + [Fact] + public void DotNetSyntheticNaming_HostileTypeNames_GetExactNames() + { + var keys = AllKeys; + + // Device names, with and without extensions + Assert.Contains("Dev/CON_", keys); + Assert.Contains("Dev/NUL_", keys); + Assert.Contains("Dev/COM1_", keys); + Assert.Contains("Dev/LPT2_.log", keys); + Assert.DoesNotContain("Dev/CON", keys); + + // Literal braces never collide with escaped square brackets or parentheses, nor with underscores that look like escapes + Assert.Contains("Hostile/A{B}", keys); + Assert.Contains("Hostile/A_B_", keys); + Assert.Contains("Hostile/a_b_-2", keys); + Assert.Contains("Hostile/A_B_-3", keys); + Assert.Contains("Hostile/A_B_-4", keys); + Assert.DoesNotContain("Hostile/A_B_-5", keys); + Assert.Contains("Hostile/x_", keys); + + // Arity in braces never collides with a digit suffix + Assert.Contains("Hostile/Foo", keys); + Assert.Contains("Hostile/Foo1", keys); + Assert.Contains("Hostile/Foo{1}", keys); + Assert.Contains("Hostile/Foo{2}", keys); + Assert.Contains("Hostile/Foo{1}/Inner{1}", keys); + } + + /// A member named like the operators page is displaced; real operators keep operators.md. + [Fact] + public void DotNetSyntheticNaming_MemberNamedOperators_IsDisplacedAndOperatorCheckIsStrict() + { + var keys = AllKeys; + + Assert.Contains("Dev/CON_/operators", keys); + Assert.Contains("Dev/CON_/Operators-2", keys); + Assert.Contains("Dev/CON_/operators-3", keys); + + // A regular method named like an operator method is a member, not an operator + Assert.Contains("Dev/CON_/op_Custom", keys); + Assert.Contains("Dev/CON_/operator+", keys); + + // operators.md lists the real operator only + var opRows = Emitted.Writers["Dev/CON_/operators"].Operations.OfType().SelectMany(t => t.Rows).ToList(); + Assert.Equal(2, opRows.Count); + Assert.All(opRows, r => Assert.Equal("[CON](../CON_.md)", r[1])); + + // The reserved-page names in the type page link to the displaced pages + var typeCells = Emitted.Writers["Dev/CON_"].Operations.OfType().SelectMany(t => t.Rows.SelectMany(r => r)).ToList(); + Assert.Contains(typeCells, c => c.Contains("(CON_/Operators-2.md)", StringComparison.Ordinal)); + Assert.Contains(typeCells, c => c.Contains("(CON_/operators-3.md)", StringComparison.Ordinal)); + Assert.Contains(typeCells, c => c.Contains("(CON_/operators.md)", StringComparison.Ordinal)); + } + + /// A namespace, type or member named api never overwrites the root api.md. + [Fact] + public void DotNetSyntheticNaming_NamesLikeApi_NeverOverwriteRootIndex() + { + var keys = AllKeys; + + Assert.Contains("api", keys); + Assert.Contains("api-2", keys); + Assert.Contains("api-2/api", keys); + Assert.Contains("api-2/api/api", keys); + Assert.Contains("Other/Api", keys); + Assert.Contains("Other/api-2", keys); + + // The root index lists the displaced namespace under its real path + var rows = Emitted.Writers["api"].Operations.OfType().SelectMany(t => t.Rows).ToList(); + Assert.Contains(rows, r => r[0] == "[api](api-2.md)"); + } + + /// Case twins in namespaces, types and members each get their own legal path. + [Fact] + public void DotNetSyntheticNaming_CaseTwins_GetSeparatePaths() + { + var keys = AllKeys; + + Assert.Contains("R/Twin", keys); + Assert.Contains("R/Twin/Y", keys); + Assert.Contains("R/twin-2/Z", keys); + Assert.Contains("R/Members", keys); + Assert.Contains("R/members-2", keys); + Assert.Contains("R/Members/Value", keys); + Assert.Contains("R/Members/value-2", keys); + Assert.Equal(keys.Count, keys.Select(k => k.ToLowerInvariant()).Distinct().Count()); + } + + /// Every path is legal, every link and breadcrumb resolves to a written page at every visibility. + /// The visibility filter. + [Theory] + [InlineData(ApiVisibility.Public)] + [InlineData(ApiVisibility.PublicAndProtected)] + [InlineData(ApiVisibility.All)] + public void DotNetSyntheticNaming_AllVisibilities_PathsAreLegalAndLinksAndBreadcrumbsResolve(ApiVisibility visibility) + { + var factory = Emit(visibility); + + var keys = factory.Writers.Keys.ToList(); + Assert.Equal(keys.Count, keys.Select(k => k.ToLowerInvariant()).Distinct().Count()); + Assert.All(keys, k => Assert.All(k.Split('/'), s => Assert.True(FileNameRules.IsLegalSegment(s), k))); + + var checkedLinks = 0; + var checkedCrumbs = 0; + foreach (var (key, writer) in factory.Writers) + { + foreach (var text in Texts(writer)) + { + foreach (Match match in LinkPattern.Matches(text)) + { + var target = Resolve(key, match.Groups[1].Value); + Assert.True(factory.Writers.ContainsKey(target), $"{key} -> {match.Groups[1].Value} resolves to missing {target}"); + checkedLinks++; + } + } + + if (key == "api") + { + continue; + } + + var ops = writer.Operations.ToList(); + var crumb = Assert.IsType(ops[ops.FindIndex(o => o is HeadingOperation { Level: 1 }) + 1]).Text; + var first = LinkPattern.Matches(crumb).First().Groups[1].Value; + Assert.Equal("api", Resolve(key, first)); + checkedCrumbs++; + } + + Assert.True(checkedLinks > 20); + Assert.Equal(factory.Writers.Count - 1, checkedCrumbs); + } + + /// Links between types use the approved names: displaced type, escaped device name, once-escaped parameters. + [Fact] + public void DotNetSyntheticNaming_TypeLinks_UseApprovedNamesWithoutDoubleEscaping() + { + var made = Emitted.Writers["A/B/C/Make"].Operations.OfType().Select(p => p.Text) + .Concat(Emitted.Writers["A/B/C/Make"].Operations.OfType().SelectMany(t => t.Rows.SelectMany(r => r))) + .Concat(Emitted.Writers["A/B/C/Make"].Operations.OfType().Select(s => s.Code)) + .ToList(); + Assert.Contains(made, t => t.Contains("](../../B-2.md)", StringComparison.Ordinal)); + + var use = Emitted.Writers["Dev/CON_/Use"].Operations.OfType().SelectMany(t => t.Rows.SelectMany(r => r)).ToList(); + Assert.Contains(use, t => t.Contains("](../NUL_.md)", StringComparison.Ordinal)); + Assert.DoesNotContain(AllKeys, k => k.Contains("__", StringComparison.Ordinal)); + } +} From 22dcb13b4a2a453b4935eb1eb7307b4fe43eb2fd Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 15 Oct 2026 02:16:45 -0400 Subject: [PATCH 11/15] docs: trace new naming regression tests in requirements and verification Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/reqstream/api-mark-core/symbol-name-registry.yaml | 1 + docs/reqstream/api-mark-cpp/cpp-emitter.yaml | 2 ++ docs/reqstream/api-mark-dot-net/type-link-resolver.yaml | 1 + docs/reqstream/api-mark-dot-net/type-name-simplifier.yaml | 7 +++++++ .../api-mark-vhdl/vhdl-emitter-gradual-disclosure.yaml | 1 + docs/verification/api-mark-core/symbol-name-registry.md | 7 +++++-- docs/verification/api-mark-cpp/cpp-emitter.md | 4 +++- docs/verification/api-mark-dot-net/type-link-resolver.md | 4 +++- docs/verification/api-mark-dot-net/type-name-simplifier.md | 7 ++++++- 9 files changed, 29 insertions(+), 5 deletions(-) diff --git a/docs/reqstream/api-mark-core/symbol-name-registry.yaml b/docs/reqstream/api-mark-core/symbol-name-registry.yaml index 6e7f703..9d2d03d 100644 --- a/docs/reqstream/api-mark-core/symbol-name-registry.yaml +++ b/docs/reqstream/api-mark-core/symbol-name-registry.yaml @@ -49,6 +49,7 @@ sections: - SymbolNameRegistry_Resolve_NamespaceAndType_NamespaceWins - SymbolNameRegistry_Resolve_ReservedName_KeepsNameAndDisplacesSymbol - SymbolNameRegistry_Resolve_DisplacedParent_ChildrenFollow + - SymbolNameRegistry_Resolve_BracesBracketsAndEscapeLookalikes_AreDistinctAndExact - id: ApiMarkCore-SymbolNameRegistry-NumericSuffixLastResort title: SymbolNameRegistry shall append "-N" (N from 2) only to a symbol whose every predictable name is taken, and shall skip suffixes already in use. diff --git a/docs/reqstream/api-mark-cpp/cpp-emitter.yaml b/docs/reqstream/api-mark-cpp/cpp-emitter.yaml index 8a59abe..538e382 100644 --- a/docs/reqstream/api-mark-cpp/cpp-emitter.yaml +++ b/docs/reqstream/api-mark-cpp/cpp-emitter.yaml @@ -57,6 +57,8 @@ sections: - CppNames_ReservedNames_AreNeverTaken - CppNames_TemplateAndDeviceNames_AreEscapedOnce - CppNames_QualifiedNamespaceKey_IsOneEscapedSegment + - CppNames_AngleTemplateAndLiteralBraceNames_AreDistinctPages + - CppEmitter_NestedTemplateInEscapedNamespace_LinkTargetEqualsWrittenPath - CppEmitter_OverloadedFreeFunctions_ShareOnePageWithEveryOverload - CppEmitter_ClassAndFunctionSameName_BothPagesWrittenAndLinked - CppEmitter_CollisionModel_AllPagesUniqueLegalAndLinksResolve diff --git a/docs/reqstream/api-mark-dot-net/type-link-resolver.yaml b/docs/reqstream/api-mark-dot-net/type-link-resolver.yaml index 72bebe6..5a62d8d 100644 --- a/docs/reqstream/api-mark-dot-net/type-link-resolver.yaml +++ b/docs/reqstream/api-mark-dot-net/type-link-resolver.yaml @@ -245,3 +245,4 @@ sections: tests: - DotNetEmitterGradualDisclosure_Emit_ExternalNestedTypeCrefs_LinkToNestedPages - DotNetEmitterGradualDisclosure_Emit_ExternalNestedPropertyTypes_LinkToNestedPages + - DotNetEmitterGradualDisclosure_Emit_ExternalLinks_MatchTargetAssemblyRegistryNames diff --git a/docs/reqstream/api-mark-dot-net/type-name-simplifier.yaml b/docs/reqstream/api-mark-dot-net/type-name-simplifier.yaml index 0c13d34..8715534 100644 --- a/docs/reqstream/api-mark-dot-net/type-name-simplifier.yaml +++ b/docs/reqstream/api-mark-dot-net/type-name-simplifier.yaml @@ -163,4 +163,11 @@ sections: - DotNetGenerator_Emit_ExplicitGenericInterfaceMembers_KeepTypeArguments - DotNetGenerator_Emit_GenericType_UsesBraceArity - DotNetGenerator_Emit_Fixtures_PathsAreUniqueLegalAndLinksResolve + - DotNetSyntheticNaming_HostileTypeNames_GetExactNames + - DotNetSyntheticNaming_NamesLikeApi_NeverOverwriteRootIndex + - DotNetSyntheticNaming_MemberNamedOperators_IsDisplacedAndOperatorCheckIsStrict + - DotNetSyntheticNaming_NamespaceAndTypeSamePath_NamespaceWinsTypeIsSuffixedAndLinksResolve + - DotNetSyntheticNaming_CaseTwins_GetSeparatePaths + - DotNetSyntheticNaming_TypeLinks_UseApprovedNamesWithoutDoubleEscaping + - DotNetSyntheticNaming_AllVisibilities_PathsAreLegalAndLinksAndBreadcrumbsResolve - DotNetGenerator_Emit_Fixtures_NamesAreIndependentOfVisibility diff --git a/docs/reqstream/api-mark-vhdl/vhdl-emitter-gradual-disclosure.yaml b/docs/reqstream/api-mark-vhdl/vhdl-emitter-gradual-disclosure.yaml index 8b062c5..eb44e25 100644 --- a/docs/reqstream/api-mark-vhdl/vhdl-emitter-gradual-disclosure.yaml +++ b/docs/reqstream/api-mark-vhdl/vhdl-emitter-gradual-disclosure.yaml @@ -46,6 +46,7 @@ sections: - VhdlNames_CaseTwinEntities_SecondGetsSuffix - VhdlNames_DuplicateEntityAcrossFiles_GetsDistinctPages - VhdlNames_ReservedApiName_KeepsIndexPage + - VhdlNames_EntityApiPackageApiAndAux_ShareOneResolvedNamePerSymbol - VhdlNames_OverloadedAndCaseTwinSubprograms_GetDistinctPages - VhdlNames_IllegalAndDeviceNames_AreEscaped - VhdlNames_SameModel_IsDeterministic diff --git a/docs/verification/api-mark-core/symbol-name-registry.md b/docs/verification/api-mark-core/symbol-name-registry.md index 447559f..66ef8ca 100644 --- a/docs/verification/api-mark-core/symbol-name-registry.md +++ b/docs/verification/api-mark-core/symbol-name-registry.md @@ -29,7 +29,7 @@ rule NR-06 to NR-09 with exact outputs and the legality predicate. **SymbolNameRegistry_Resolve_UniqueNames_AreUnchanged**, **..._IdentityName_BeatsTransformedName**, **..._NamespaceAndType_NamespaceWins**, **..._ReservedName_KeepsNameAndDisplacesSymbol**, -**..._DisplacedParent_ChildrenFollow**: Verify rules NR-14 and NR-16. +**..._DisplacedParent_ChildrenFollow**, **..._BracesBracketsAndEscapeLookalikes_AreDistinctAndExact**: Verify rules NR-14 and NR-16. **SymbolNameRegistry_Resolve_CaseTwins_LowestKeyWinsAndOtherGetsSuffix**, **..._SuffixCandidateTaken_UsesNextNumber**, **..._CanonicallyEquivalentNames_AreDistinguished**: Verify @@ -47,6 +47,9 @@ Verify call-order and input validation. Verify rule NR-19. **Language tests**: `DotNetFileNamingTests` (namespace vs type, case twins, `api`, brace arity, `operators`, -explicit interface members, fixtures for all visibilities, every link resolves), `CppFileNamingTests`, and +explicit interface members, fixtures for all visibilities, every link resolves), +`DotNetSyntheticNamingTests` (a hand-built assembly with hostile type names, device names, names like `api` +and `operators`, namespace-versus-type collisions and case twins, asserting exact names, links and +breadcrumbs at every visibility), `CppFileNamingTests`, and `VhdlFileNamingTests` (entity vs package, case twins, reserved names, overloads, fixtures) verify NR-03 to NR-05, NR-10 to NR-13, NR-17, and NR-18. diff --git a/docs/verification/api-mark-cpp/cpp-emitter.md b/docs/verification/api-mark-cpp/cpp-emitter.md index 98a26a7..5caebc7 100644 --- a/docs/verification/api-mark-cpp/cpp-emitter.md +++ b/docs/verification/api-mark-cpp/cpp-emitter.md @@ -30,7 +30,9 @@ Tests run with the standard xUnit.net test runner. replaced once, collisions are separated, and every link resolves. Tested by `CppNames_ClassEnumAliasFunctionSameName_AreSeparatedInKindOrder`, `CppNames_CaseTwinClasses_SecondGetsSuffix`, `CppNames_ReservedNames_AreNeverTaken`, `CppNames_TemplateAndDeviceNames_AreEscapedOnce`, -`CppNames_QualifiedNamespaceKey_IsOneEscapedSegment`, `CppEmitter_OverloadedFreeFunctions_ShareOnePageWithEveryOverload`, +`CppNames_QualifiedNamespaceKey_IsOneEscapedSegment`, `CppNames_AngleTemplateAndLiteralBraceNames_AreDistinctPages`, +`CppEmitter_NestedTemplateInEscapedNamespace_LinkTargetEqualsWrittenPath`, +`CppEmitter_OverloadedFreeFunctions_ShareOnePageWithEveryOverload`, `CppEmitter_ClassAndFunctionSameName_BothPagesWrittenAndLinked`, `CppEmitter_CollisionModel_AllPagesUniqueLegalAndLinksResolve`, and `CppEmitter_CollisionModel_IsDeterministic`. diff --git a/docs/verification/api-mark-dot-net/type-link-resolver.md b/docs/verification/api-mark-dot-net/type-link-resolver.md index f901cad..c92fbf4 100644 --- a/docs/verification/api-mark-dot-net/type-link-resolver.md +++ b/docs/verification/api-mark-dot-net/type-link-resolver.md @@ -310,4 +310,6 @@ external fixture assembly. Expected: crefs and property types naming `NestedHost and `NestedGenericHost.Inner` link to `.../NestedHost/Inner.md`, `.../NestedHost/Deep/Leaf.md`, and `.../NestedGenericHost{1}/Inner{1}.md`. Tested by `DotNetEmitterGradualDisclosure_Emit_ExternalNestedTypeCrefs_LinkToNestedPages` and -`DotNetEmitterGradualDisclosure_Emit_ExternalNestedPropertyTypes_LinkToNestedPages`. +`DotNetEmitterGradualDisclosure_Emit_ExternalNestedPropertyTypes_LinkToNestedPages`. External link names equal +the names the target assembly's own registry produces; tested by +`DotNetEmitterGradualDisclosure_Emit_ExternalLinks_MatchTargetAssemblyRegistryNames`. diff --git a/docs/verification/api-mark-dot-net/type-name-simplifier.md b/docs/verification/api-mark-dot-net/type-name-simplifier.md index 1eef554..52abdbf 100644 --- a/docs/verification/api-mark-dot-net/type-name-simplifier.md +++ b/docs/verification/api-mark-dot-net/type-name-simplifier.md @@ -166,4 +166,9 @@ types use brace-arity folders and files, and all relative links resolve. The reg `DotNetEmitterGradualDisclosure_Emit_NestedGenericType_UsesHyphenatedFolderAndFile`, `DotNetEmitterGradualDisclosure_Emit_AllPages_NoDigitSuffixFileNamesForGenericTypes`, `DotNetEmitterGradualDisclosure_Emit_AllRelativeLinks_ResolveToEmittedPages`, and -`DotNetEmitterGradualDisclosure_Emit_CrefAndBaseTypeLinks_UseHyphenatedArityNames`. +`DotNetEmitterGradualDisclosure_Emit_CrefAndBaseTypeLinks_UseHyphenatedArityNames`. The hand-built assembly +tests in `DotNetSyntheticNamingTests` (`DotNetSyntheticNaming_HostileTypeNames_GetExactNames`, +`..._NamesLikeApi_NeverOverwriteRootIndex`, `..._MemberNamedOperators_IsDisplacedAndOperatorCheckIsStrict`, +`..._NamespaceAndTypeSamePath_NamespaceWinsTypeIsSuffixedAndLinksResolve`, `..._CaseTwins_GetSeparatePaths`, +`..._TypeLinks_UseApprovedNamesWithoutDoubleEscaping`, and +`..._AllVisibilities_PathsAreLegalAndLinksAndBreadcrumbsResolve`) assert the exact names and links. From bd4ed76f2d2634f9e05c12b538c6be46de437772 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 15 Oct 2026 02:28:35 -0400 Subject: [PATCH 12/15] fix: share link-hostile character set, encode % and # in LinkPath, reject empty registry key Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../design/api-mark-core/file-naming-rules.md | 9 +++-- .../api-mark-core/symbol-name-registry.md | 4 +- .../api-mark-core/symbol-name-registry.md | 13 +++++-- src/ApiMark.Core/FileNameRules.cs | 24 +++++++++--- src/ApiMark.Core/LinkPath.cs | 21 ++++++++-- src/ApiMark.Core/SymbolNameRegistry.cs | 7 ++++ .../FileMarkdownWriterFactoryTests.cs | 10 +++++ test/ApiMark.Core.Tests/FileNameRulesTests.cs | 38 +++++++++++++++++++ test/ApiMark.Core.Tests/LinkPathTests.cs | 37 +++++++++++++++--- .../SymbolNameRegistryTests.cs | 12 ++++++ 10 files changed, 151 insertions(+), 24 deletions(-) diff --git a/docs/design/api-mark-core/file-naming-rules.md b/docs/design/api-mark-core/file-naming-rules.md index 0a6c440..6595231 100644 --- a/docs/design/api-mark-core/file-naming-rules.md +++ b/docs/design/api-mark-core/file-naming-rules.md @@ -126,9 +126,12 @@ page (`Add.md`, `Add-2.md`). #### NR-19 Links -Every link is a relative path with forward slashes built from the registry's approved names. Link-hostile -characters in the destination (`%`, space, parentheses, `#`) are percent-encoded; braces are literal. Names -are escaped once, at registration, and never again. +Every link is a relative path with forward slashes built from the registry's approved names. `LinkPath.Escape` +takes the raw (unencoded) path and percent-encodes `%` (as `%25`, first), `#`, `?`, space, parentheses, and +angle brackets, so a destination always resolves to the file whose name contains them; braces are literal. +Escaping is not idempotent: already encoded text is encoded again, so an externally supplied base URL, which +may already be encoded, is never passed through `Escape`; only the registry-produced path is encoded and then +appended to the base. Names are escaped once, at registration, and never again. #### NR-20 External assemblies diff --git a/docs/design/api-mark-core/symbol-name-registry.md b/docs/design/api-mark-core/symbol-name-registry.md index 0e84fa6..da9c17a 100644 --- a/docs/design/api-mark-core/symbol-name-registry.md +++ b/docs/design/api-mark-core/symbol-name-registry.md @@ -24,8 +24,8 @@ state. ### Key Methods **SymbolNameRegistry.Register** (`public`): Adds a symbol (`key`, `parentKey`, `rawName`, `kind`). Throws -`ArgumentException` for an empty or duplicate key or a missing parent, and `InvalidOperationException` after -`Resolve`. +`ArgumentNullException` for a null key or raw name, `ArgumentException` for an empty key, and +`InvalidOperationException` for a duplicate key, a missing parent, or a call after `Resolve`. **SymbolNameRegistry.Resolve** (`public`): Escapes every raw name once, then for each directory grants names in tier order (rules NR-14 to NR-16) and gives displaced entries a `-N` suffix. Parents are resolved diff --git a/docs/verification/api-mark-core/symbol-name-registry.md b/docs/verification/api-mark-core/symbol-name-registry.md index 66ef8ca..5584bda 100644 --- a/docs/verification/api-mark-core/symbol-name-registry.md +++ b/docs/verification/api-mark-core/symbol-name-registry.md @@ -40,11 +40,16 @@ rule NR-15. uniqueness property over a generated symbol set. **SymbolNameRegistry_Resolve_CalledTwice_Throws**, **..._GetName_BeforeResolve_Throws**, -**..._Register_AfterResolve_Throws**, **..._Register_InvalidInput_Throws**, **..._GetName_UnknownKey_Throws**: -Verify call-order and input validation. +**..._Register_AfterResolve_Throws**, **..._Register_InvalidInput_Throws**, **..._Register_EmptyKey_ThrowsArgumentException**, +**..._GetName_UnknownKey_Throws**: Verify call-order and input validation. -**LinkPath_Relative_Pages_ReturnsForwardSlashRelativePath**, **LinkPath_Escape_LinkHostileCharacters_AreEncoded**: -Verify rule NR-19. +**LinkPath_Relative_Pages_ReturnsForwardSlashRelativePath**, **LinkPath_Escape_LinkHostileCharacters_AreEncoded**, +**LinkPath_Escape_RawAndAlreadyEncodedInput_IsEncodedExactlyOncePerCall**: Verify rule NR-19, including +that a raw `%` and `#` are encoded and that already encoded input is encoded again (raw-input contract). + +**FileNameRules_IsLegalSegment_EveryEscapedCharacter_IsIllegalAndEscapeOutputIsLegal** and the +`IsLegalSegment_IllegalName` cases for every NR-06 character verify that `Escape` and `IsLegalSegment` +share one character set. **Language tests**: `DotNetFileNamingTests` (namespace vs type, case twins, `api`, brace arity, `operators`, explicit interface members, fixtures for all visibilities, every link resolves), diff --git a/src/ApiMark.Core/FileNameRules.cs b/src/ApiMark.Core/FileNameRules.cs index ba5388f..7dedd17 100644 --- a/src/ApiMark.Core/FileNameRules.cs +++ b/src/ApiMark.Core/FileNameRules.cs @@ -31,12 +31,23 @@ public static class FileNameRules /// The character that replaces every illegal or link-hostile character. public const char ReplacementCharacter = '_'; - /// Characters that are illegal in a Windows file name, plus link-hostile characters replaced for portability. + /// + /// The characters replaced by and rejected by : + /// those illegal in a Windows file name, plus link-hostile characters replaced for portability (NR-06). + /// private static readonly HashSet ReplacedCharacters = [ '<', '>', ':', '"', '|', '?', '*', '\\', '/', '[', ']', '(', ')', '#', '%', '^', '`', ' ', ]; + /// + /// Determines whether a character is replaced by , that is, whether it may not + /// appear in a segment. Braces are structural and are not included. + /// + /// The character to test. + /// for an illegal, link-hostile, or control character. + private static bool IsReplacedCharacter(char c) => ReplacedCharacters.Contains(c) || c < 0x20 || c == 0x7F; + /// Windows reserved device names that may not be used as a file name or its base name. private static readonly HashSet ReservedNames = new(StringComparer.OrdinalIgnoreCase) { @@ -74,7 +85,7 @@ public static string Escape(string rawName, out bool transformed) builder.Append(c); transformed = true; } - else if (ReplacedCharacters.Contains(c) || c < 0x20 || c == 0x7F) + else if (IsReplacedCharacter(c)) { builder.Append(ReplacementCharacter); transformed = true; @@ -124,9 +135,10 @@ public static string Escape(string rawName, out bool transformed) /// /// The segment to test (a file name without extension, or a folder name). /// - /// when the segment is non-empty, contains none of - /// < > | ? * : " / \ or control characters, does not end in a dot or space, and is not a - /// reserved device name; otherwise . + /// when the segment is non-empty, contains none of the characters + /// replaces (< > : " | ? * \ /, space, [ ] ( ) # % ^, the + /// backtick, and control characters), does not end in a dot or space, and is not a reserved device + /// name; otherwise . /// public static bool IsLegalSegment(string? name) { @@ -135,7 +147,7 @@ public static bool IsLegalSegment(string? name) return false; } - if (name.Any(c => c is '<' or '>' or '|' or '?' or '*' or ':' or '"' or '/' or '\\' || c < 0x20 || c == 0x7F)) + if (name.Any(IsReplacedCharacter)) { return false; } diff --git a/src/ApiMark.Core/LinkPath.cs b/src/ApiMark.Core/LinkPath.cs index c9d98ab..3155bd4 100644 --- a/src/ApiMark.Core/LinkPath.cs +++ b/src/ApiMark.Core/LinkPath.cs @@ -33,16 +33,29 @@ public static string Relative(string fromDirectory, string targetPath) } /// - /// Percent-encodes the characters that would terminate or corrupt a Markdown inline link destination. - /// An existing % is left alone so an already encoded base URL is not encoded twice. + /// Percent-encodes the characters that would terminate, corrupt, or re-interpret a Markdown inline + /// link destination built from page paths. /// - /// The raw destination. - /// The destination with spaces, parentheses, and angle brackets encoded. + /// + /// The input must be a raw (not yet encoded) relative path made of registry-approved names, such as + /// the result of . A raw % is encoded as %25 so the destination + /// resolves to the file whose name contains it; text that is already percent-encoded is therefore + /// encoded again, so an externally supplied base URL (which may already be encoded) must not be + /// passed through this method: encode only the registry-produced path and append it to the base. + /// /, ., and braces are kept. + /// + /// The raw destination path. + /// The destination with %, #, ?, space, parentheses, and angle brackets encoded. public static string Escape(string destination) { ArgumentNullException.ThrowIfNull(destination); + + // The percent sign must be encoded first so the escapes added below are not encoded again. return destination + .Replace("%", "%25", StringComparison.Ordinal) .Replace(" ", "%20", StringComparison.Ordinal) + .Replace("#", "%23", StringComparison.Ordinal) + .Replace("?", "%3F", StringComparison.Ordinal) .Replace("(", "%28", StringComparison.Ordinal) .Replace(")", "%29", StringComparison.Ordinal) .Replace("<", "%3C", StringComparison.Ordinal) diff --git a/src/ApiMark.Core/SymbolNameRegistry.cs b/src/ApiMark.Core/SymbolNameRegistry.cs index 69ed6d3..2eed99c 100644 --- a/src/ApiMark.Core/SymbolNameRegistry.cs +++ b/src/ApiMark.Core/SymbolNameRegistry.cs @@ -71,10 +71,17 @@ public sealed class SymbolNameRegistry /// character is escaped by . /// /// The symbol kind, which decides precedence on a name collision. + /// Thrown when or is null. + /// Thrown when is empty. /// Thrown after , for a duplicate key, or for an unregistered parent. public void Register(string key, string? parentKey, string rawName, SymbolKind kind) { ArgumentNullException.ThrowIfNull(key); + if (key.Length == 0) + { + throw new ArgumentException("The symbol key must not be empty.", nameof(key)); + } + ArgumentNullException.ThrowIfNull(rawName); if (_resolved) diff --git a/test/ApiMark.Core.Tests/FileMarkdownWriterFactoryTests.cs b/test/ApiMark.Core.Tests/FileMarkdownWriterFactoryTests.cs index ec859bb..a2c5e62 100644 --- a/test/ApiMark.Core.Tests/FileMarkdownWriterFactoryTests.cs +++ b/test/ApiMark.Core.Tests/FileMarkdownWriterFactoryTests.cs @@ -64,6 +64,13 @@ public void FileMarkdownWriterFactory_Constructor_NullDirectory_ThrowsArgumentEx [InlineData("a:b")] [InlineData("CON")] [InlineData("trailing.")] + [InlineData("a b")] + [InlineData("a#b")] + [InlineData("a%b")] + [InlineData("a(b)")] + [InlineData("a[b]")] + [InlineData("a^b")] + [InlineData("a`b")] public void FileMarkdownWriterFactory_CreateMarkdown_IllegalFileName_ThrowsArgumentException(string name) { // Arrange @@ -79,6 +86,9 @@ public void FileMarkdownWriterFactory_CreateMarkdown_IllegalFileName_ThrowsArgum [InlineData("ok/ab")] + [InlineData("a|b")] + [InlineData("a?b")] + [InlineData("a*b")] + [InlineData("a\"b")] + [InlineData("a/b")] + [InlineData("a\\b")] + [InlineData("a\u007Fb")] public void FileNameRules_IsLegalSegment_IllegalName_ReturnsFalse(string? name) { // Arrange / Act / Assert Assert.False(FileNameRules.IsLegalSegment(name)); } + /// + /// Validates that and + /// share one character set: every character Escape replaces is illegal, and Escape output is legal. + /// + [Fact] + public void FileNameRules_IsLegalSegment_EveryEscapedCharacter_IsIllegalAndEscapeOutputIsLegal() + { + for (var code = 0; code < 0x80; code++) + { + var c = (char)code; + var name = "a" + c + "b"; + var escaped = FileNameRules.Escape(name, out _); + + Assert.True(FileNameRules.IsLegalSegment(escaped), $"Escape output for U+{code:X4} is not legal"); + if (c is not '{' and not '}' && escaped != name) + { + Assert.False(FileNameRules.IsLegalSegment(name), $"U+{code:X4} is replaced by Escape but legal"); + } + } + } + /// Validates that legal names are reported as legal. /// The legal name. [Theory] diff --git a/test/ApiMark.Core.Tests/LinkPathTests.cs b/test/ApiMark.Core.Tests/LinkPathTests.cs index bcfe57a..ed667d0 100644 --- a/test/ApiMark.Core.Tests/LinkPathTests.cs +++ b/test/ApiMark.Core.Tests/LinkPathTests.cs @@ -25,14 +25,41 @@ public void LinkPath_Relative_Pages_ReturnsForwardSlashRelativePath(string from, Assert.Equal(expected, LinkPath.Relative(from, target)); } - /// Validates that characters that would break a Markdown link are encoded. + /// Validates that every character that would break a Markdown link destination is encoded. + /// The raw destination. + /// The expected encoded destination. + [Theory] + [InlineData("a b", "a%20b")] + [InlineData("a(b)", "a%28b%29")] + [InlineData("a", "a%3Cb%3E")] + [InlineData("a#b", "a%23b")] + [InlineData("a%b", "a%25b")] + [InlineData("a?b", "a%3Fb")] + [InlineData("A/B{2}/C.md", "A/B{2}/C.md")] + [InlineData("../Foo.md", "../Foo.md")] + public void LinkPath_Escape_LinkHostileCharacters_AreEncoded(string raw, string expected) + { + // Arrange / Act / Assert + Assert.Equal(expected, LinkPath.Escape(raw)); + } + + /// + /// Validates the raw-input contract: a raw percent sign is encoded, so already encoded text is + /// encoded again (callers must pass raw registry names only), and the result decodes back to the input. + /// [Fact] - public void LinkPath_Escape_LinkHostileCharacters_AreEncoded() + public void LinkPath_Escape_RawAndAlreadyEncodedInput_IsEncodedExactlyOncePerCall() { - // Arrange / Act - var result = LinkPath.Escape("a b(c)%20"); + // Arrange + const string raw = "dir/C#%20x.md"; + + // Act + var once = LinkPath.Escape(raw); + var twice = LinkPath.Escape(once); // Assert - Assert.Equal("a%20b%28c%29%3Cd%3E%20", result); + Assert.Equal("dir/C%23%2520x.md", once); + Assert.Equal(raw, Uri.UnescapeDataString(once)); + Assert.Equal("dir/C%2523%252520x.md", twice); } } diff --git a/test/ApiMark.Core.Tests/SymbolNameRegistryTests.cs b/test/ApiMark.Core.Tests/SymbolNameRegistryTests.cs index c27c0ba..f1fc3b4 100644 --- a/test/ApiMark.Core.Tests/SymbolNameRegistryTests.cs +++ b/test/ApiMark.Core.Tests/SymbolNameRegistryTests.cs @@ -243,6 +243,18 @@ public void SymbolNameRegistry_Register_InvalidInput_Throws() Assert.Throws(() => registry.Register("c", null, null!, SymbolKind.Type)); } + /// Validates that an empty key is rejected with . + [Fact] + public void SymbolNameRegistry_Register_EmptyKey_ThrowsArgumentException() + { + // Arrange + var registry = new SymbolNameRegistry(); + + // Act / Assert + Assert.Throws(() => registry.Register(string.Empty, null, "A", SymbolKind.Type)); + Assert.Equal(0, registry.Count); + } + /// Validates that an unknown key is rejected after resolution. [Fact] public void SymbolNameRegistry_GetName_UnknownKey_Throws() From d4847add243d339177934e838d7b305acbba19a1 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 15 Oct 2026 02:29:37 -0400 Subject: [PATCH 13/15] chore: cover naming sources and tests in ReviewMark review-sets Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .reviewmark.yaml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.reviewmark.yaml b/.reviewmark.yaml index 8d2104b..24e8f7b 100644 --- a/.reviewmark.yaml +++ b/.reviewmark.yaml @@ -39,6 +39,7 @@ reviews: - requirements.yaml - docs/design/introduction.md - docs/sysml2/**/*.sysml + - docs/reqstream/quality.yaml # ApiMarkCore - Specials - id: ApiMark-Core-Architecture @@ -115,6 +116,7 @@ reviews: - docs/verification/api-mark-core/emit-config.md - src/ApiMark.Core/EmitConfig.cs - src/ApiMark.Core/OutputFormat.cs + - test/ApiMark.Core.Tests/EmitConfigTests.cs - id: ApiMark-Core-IContext title: Review that ApiMark Core IContext Implementation is Correct context: @@ -305,6 +307,9 @@ reviews: - test/ApiMark.DotNet.Tests/DotNetEmitterGradualDisclosureGenericsTests.cs - test/ApiMark.DotNet.Tests/DotNetEmitterBreadcrumbTests.cs - test/ApiMark.DotNet.Tests/DotNetEmitterFileNameLegalityTests.cs + - src/ApiMark.DotNet/DotNetNames.cs + - test/ApiMark.DotNet.Tests/DotNetFileNamingTests.cs + - test/ApiMark.DotNet.Tests/DotNetSyntheticNamingTests.cs - test/ApiMark.DotNet.Fixtures/LegalNames/LegalNameShapes.cs - test/ApiMark.DotNet.Tests/DotNetEmitterOverloadIndexTests.cs - test/ApiMark.DotNet.Tests/DotNetEmitterInheritanceDocsTests.cs @@ -465,6 +470,9 @@ reviews: - src/ApiMark.Cpp/CppEmitterGradualDisclosure.cs - test/ApiMark.Cpp.Fixtures/**/*.h - test/ApiMark.Cpp.Tests/CppEmitterGradualDisclosureTests.cs + - src/ApiMark.Cpp/CppNames.cs + - test/ApiMark.Cpp.Tests/CppFileNamingTests.cs + - test/ApiMark.Cpp.Tests/CppFixtureFileNamingTests.cs - id: ApiMark-Cpp-CppEmitterSingleFile title: Review that ApiMark Cpp CppEmitterSingleFile Implementation is Correct context: [docs/design/api-mark-cpp.md, docs/reqstream/api-mark-cpp.yaml] @@ -506,6 +514,7 @@ reviews: - test/ApiMark.Cpp.Fixtures/**/*.h - test/ApiMark.Cpp.Tests/ClangAstParserTests.cs - test/ApiMark.Cpp.Tests/ClangDiscoveryTests.cs + - test/ApiMark.Cpp.Tests/ClangEnvironmentCollection.cs - id: ApiMark-Cpp-DocumentationCoverageChecker title: Review that ApiMark Cpp DocumentationCoverageChecker Implementation is Correct @@ -587,6 +596,8 @@ reviews: - src/ApiMark.Vhdl/VhdlEmitterGradualDisclosure.cs - test/ApiMark.Vhdl.Tests/Fixtures/counter.vhd - test/ApiMark.Vhdl.Tests/VhdlEmitterGradualDisclosureTests.cs + - src/ApiMark.Vhdl/VhdlNames.cs + - test/ApiMark.Vhdl.Tests/VhdlFileNamingTests.cs - id: ApiMark-Vhdl-VhdlEmitterSingleFile title: Review that ApiMark Vhdl VhdlEmitterSingleFile Implementation is Correct context: @@ -731,6 +742,7 @@ reviews: - docs/verification/api-mark-tool/program.md - src/ApiMark.Tool/Program.cs - test/ApiMark.Tool.Tests/ProgramTests.cs + - test/ApiMark.Tool.Tests/ConsoleCollection.cs # ApiMarkTool.Cli - Units - id: ApiMark-Tool-Cli @@ -778,6 +790,7 @@ reviews: - docs/design/api-mark-tool/self-test/validation.md - docs/verification/api-mark-tool/self-test/validation.md - src/ApiMark.Tool/SelfTest/Validation.cs + - src/ApiMark.Tool/SelfTest/Validation.*.cs - test/ApiMark.Tool.Tests/SelfTest/ValidationTests.cs # OTS Items From 913066616fdb3687910066c558adcc6c571c5a03 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 15 Oct 2026 09:11:39 -0400 Subject: [PATCH 14/15] fix: update C++ case-twin tests and docs for separate pages; document registry and sanitizer removal Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/build_notes/introduction.md | 3 + .../api-mark-core/symbol-name-registry.md | 6 +- docs/reqstream/api-mark-cpp.yaml | 2 +- docs/reqstream/api-mark-cpp/cpp-emitter.yaml | 15 ++--- .../reqstream/api-mark-cpp/cpp-generator.yaml | 28 ++++----- docs/verification/api-mark-cpp.md | 12 ++-- .../cpp-emitter-gradual-disclosure.md | 8 +-- docs/verification/api-mark-cpp/cpp-emitter.md | 6 +- .../api-mark-cpp/cpp-generator.md | 17 +++-- test/ApiMark.Cpp.Tests/CppGeneratorTests.cs | 62 +++++++++++-------- 10 files changed, 87 insertions(+), 72 deletions(-) diff --git a/docs/build_notes/introduction.md b/docs/build_notes/introduction.md index b383c44..d41cc45 100644 --- a/docs/build_notes/introduction.md +++ b/docs/build_notes/introduction.md @@ -48,6 +48,9 @@ symbol-name registry that resolves every collision before anything is written. O each build, so no migration is needed. The rules are in `docs/design/api-mark-core/file-naming-rules.md`. +The public `ApiMark.Core.FileNameSanitizer` type was removed. Use `SymbolNameRegistry`, +`FileNameRules` and `LinkPath` instead. + ## References [ApiMark releases](https://github.com/DemaConsulting/ApiMark/releases) diff --git a/docs/design/api-mark-core/symbol-name-registry.md b/docs/design/api-mark-core/symbol-name-registry.md index da9c17a..0634567 100644 --- a/docs/design/api-mark-core/symbol-name-registry.md +++ b/docs/design/api-mark-core/symbol-name-registry.md @@ -31,9 +31,13 @@ state. names in tier order (rules NR-14 to NR-16) and gives displaced entries a `-N` suffix. Parents are resolved before children. May be called once. -**SymbolNameRegistry.GetName / GetPath / GetDirectory / GetAllPaths / Contains** (`public`): Read the +**SymbolNameRegistry.GetName / GetPath / GetDirectory / GetAllPaths** (`public`): Read the approved names. They throw `InvalidOperationException` before `Resolve` and for an unregistered key. +**SymbolNameRegistry.Contains** (`public`): A registration-presence check that does not require +`Resolve`. It can be called during registration (for example to skip an overload that is already +registered) and after resolution, and returns `false` for an unregistered key instead of throwing. + **FileNameRules.Escape** (`public static`): Escapes a raw name into a legal segment (NR-06 to NR-09) and reports whether it was transformed. **FileNameRules.IsLegalSegment** (`public static`): Reports whether a segment is legal on Windows and Linux. diff --git a/docs/reqstream/api-mark-cpp.yaml b/docs/reqstream/api-mark-cpp.yaml index fff353f..594f81c 100644 --- a/docs/reqstream/api-mark-cpp.yaml +++ b/docs/reqstream/api-mark-cpp.yaml @@ -17,7 +17,7 @@ sections: - ApiMarkCpp-CppGenerator-FilterDeclarationsByOwnership - ApiMarkCpp-CppGenerator-EmitDetailPageForEveryMember - ApiMarkCpp-CppGenerator-ProduceConsistentOutputFileStructure - - ApiMarkCpp-CppGenerator-EmitCombinedMemberPageForCaseInsensitiveCollisions + - ApiMarkCpp-CppGenerator-EmitSeparatePagesForCaseInsensitiveCollisions - ApiMarkCpp-CppGenerator-FilterByVisibility - ApiMarkCpp-CppGenerator-IncludeDeprecatedApisWhenRequested - ApiMarkCpp-CppGenerator-RenderDoxygenDocComments diff --git a/docs/reqstream/api-mark-cpp/cpp-emitter.yaml b/docs/reqstream/api-mark-cpp/cpp-emitter.yaml index 538e382..7ed3bbe 100644 --- a/docs/reqstream/api-mark-cpp/cpp-emitter.yaml +++ b/docs/reqstream/api-mark-cpp/cpp-emitter.yaml @@ -16,7 +16,7 @@ sections: while keeping shared utilities (visibility filters, signature builders, include-path resolution) in one place. children: - - ApiMarkCpp-CppEmitter-WriteCombinedMemberPageForCaseCollisions + - ApiMarkCpp-CppEmitter-WriteSharedPageForOverloads - ApiMarkCpp-CppEmitter-RejectNullFactory - ApiMarkCpp-CppEmitter-FileNames - ApiMarkCpp-CppEmitter-BuildClassDeclaration @@ -24,14 +24,15 @@ sections: tests: - CppEmitter_Emit_GradualDisclosureFormat_ProducesMultipleFiles - CppEmitter_Emit_SingleFileFormat_ProducesSingleApiFile - - id: ApiMarkCpp-CppEmitter-WriteCombinedMemberPageForCaseCollisions + - id: ApiMarkCpp-CppEmitter-WriteSharedPageForOverloads title: >- - CppEmitter shall emit a combined member page for case-insensitive - filename collisions within a single type. + CppEmitter shall emit one shared member page for all overloads of a + member name within a single type. justification: | - On case-insensitive filesystems members whose names differ only in case - collide at the file-system level. CppEmitter must detect these collisions - and merge the affected members onto one shared page. + Overloads of one member name are documented together so a reader finds every + signature on one page. Members whose names differ only in case are not + overloads and keep separate pages (see + ApiMarkCpp-CppGenerator-EmitSeparatePagesForCaseInsensitiveCollisions). tests: - CppEmitter_WriteCombinedMemberPage_Overloads_ProducesSingleCombinedPage - id: ApiMarkCpp-CppEmitter-RejectNullFactory diff --git a/docs/reqstream/api-mark-cpp/cpp-generator.yaml b/docs/reqstream/api-mark-cpp/cpp-generator.yaml index b59423a..fc3476d 100644 --- a/docs/reqstream/api-mark-cpp/cpp-generator.yaml +++ b/docs/reqstream/api-mark-cpp/cpp-generator.yaml @@ -56,31 +56,31 @@ sections: CppGenerator shall emit a dedicated detail page for every visible non-operator class member and every regular free-function category entry, except where - case-insensitive collisions require a shared member page. + case-insensitive collisions require a numbered page name. justification: | Dedicated detail pages keep navigation deterministic for visible non-operator members and for regular free functions. Members whose base names collide on a - case-insensitive filesystem are - combined onto a single shared page (see - ApiMarkCpp-CppGenerator-EmitCombinedMemberPageForCaseInsensitiveCollisions). + case-insensitive filesystem each keep their own page, with the later ones + receiving a numeric suffix (see + ApiMarkCpp-CppGenerator-EmitSeparatePagesForCaseInsensitiveCollisions). Operator overloads are grouped onto a single operators.md page (see ApiMarkCpp-CppGenerator-GroupOperatorOverloadsOnSinglePage). tests: - CppGenerator_AllMembers_GetSeparateFiles - CppGenerator_Generate_MethodWithParameters_CreatesMemberPage - - id: ApiMarkCpp-CppGenerator-EmitCombinedMemberPageForCaseInsensitiveCollisions - title: CppGenerator shall emit a combined member page for case-insensitive - filename collisions. + - id: ApiMarkCpp-CppGenerator-EmitSeparatePagesForCaseInsensitiveCollisions + title: CppGenerator shall emit a separate member page for each member whose + name differs from another member's only in case. justification: | On case-insensitive filesystems (Windows, macOS), two members whose names - differ only in case would produce colliding filenames. Such members are - combined onto a single page named after the lower-invariant of the shared key, - with each member distinguished by an H2 heading showing its exact name and - kind label. This replaces the previous _2/_3 suffix mechanism. + differ only in case would produce colliding filenames. Each such member keeps + its own page: the first in ordinal order keeps the plain name (for example + Name) and the others receive a numeric suffix (for example name-2), as + resolved by the symbol-name registry. tests: - - CppGenerator_Generate_CaseCollisionClass_CreatesCombinedPage - - CppGenerator_Generate_CaseCollisionClass_DoesNotCreateSeparateCasedPage - - CppGenerator_Generate_CaseCollisionClass_CombinedPageContainsBothMembers + - CppGenerator_Generate_CaseCollisionClass_FirstCaseTwinKeepsPlainName + - CppGenerator_Generate_CaseCollisionClass_SecondCaseTwinGetsNumberedPage + - CppGenerator_Generate_CaseCollisionClass_EachPageDocumentsOneMember - id: ApiMarkCpp-CppGenerator-FilterByVisibility title: >- CppGenerator shall filter class members by the configured visibility diff --git a/docs/verification/api-mark-cpp.md b/docs/verification/api-mark-cpp.md index cf5ed8c..d958446 100644 --- a/docs/verification/api-mark-cpp.md +++ b/docs/verification/api-mark-cpp.md @@ -268,12 +268,12 @@ emitter emits type alias sections alongside class, enum, and function sections. declared with the `final` specifier has the `final` keyword included in its generated class signature block. Tested by `CppGenerator_Generate_FinalClass_EmitsFinalKeywordInSignature`. -**Case-insensitive member collision combines onto one page**: Verifies that when two class members -have names that differ only in letter case (a case-insensitive collision), the generator combines -them onto a single shared member page rather than creating conflicting file names. Tested by -`CppGenerator_Generate_CaseCollisionClass_CreatesCombinedPage`, -`CppGenerator_Generate_CaseCollisionClass_CombinedPageContainsBothMembers`, and -`CppGenerator_Generate_CaseCollisionClass_DoesNotCreateSeparateCasedPage`. +**Case-insensitive member collisions get separate numbered pages**: Verifies that when two class +members have names that differ only in letter case, each keeps its own page (`Name` and +`name-2`) rather than sharing a page or overwriting each other. Tested by +`CppGenerator_Generate_CaseCollisionClass_FirstCaseTwinKeepsPlainName`, +`CppGenerator_Generate_CaseCollisionClass_SecondCaseTwinGetsNumberedPage`, and +`CppGenerator_Generate_CaseCollisionClass_EachPageDocumentsOneMember`. **CWD-relative ApiHeaderPatterns restrict documented headers**: Verifies that `ApiHeaderPatterns` entries that are relative paths are resolved against `WorkingDirectory`, diff --git a/docs/verification/api-mark-cpp/cpp-emitter-gradual-disclosure.md b/docs/verification/api-mark-cpp/cpp-emitter-gradual-disclosure.md index 7f1ac14..4eb53af 100644 --- a/docs/verification/api-mark-cpp/cpp-emitter-gradual-disclosure.md +++ b/docs/verification/api-mark-cpp/cpp-emitter-gradual-disclosure.md @@ -29,8 +29,8 @@ Tests run with the standard xUnit.net test runner. **Legal file names**: Verifies, with synthetic models and no clang, that a class named `Foo`, a class `Aux`, an enum `CON`, aliases named `a:b`, and a namespace key `aux` produce only legal path segments (the in-memory factory does not validate, so each recorded -key is asserted explicitly), that pages exist at the sanitized paths (`Foo{int}`, `Aux-`, -`CON-`, `aux-`), and that every emitted relative link resolves to a written page. Tested by +key is asserted explicitly), that pages exist at the sanitized paths (`Foo{int}`, `Aux_`, +`CON_`, and the namespace folder `aux_`), and that every emitted relative link resolves to a written page. Tested by `CppEmitterGradualDisclosure_Emit_IllegalAndReservedNames_AllWrittenSegmentsAreLegal`, `CppEmitterGradualDisclosure_Emit_IllegalAndReservedNames_PagesUseApprovedPaths`, and `CppEmitterGradualDisclosure_Emit_IllegalAndReservedNames_AllLinksResolveToWrittenPages`. @@ -43,8 +43,8 @@ page. Tested by `CppEmitterGradualDisclosure_Emit_GenericAndOddNames_MemberPageL and `CppEmitterGradualDisclosure_Emit_ReservedAndGenericNames_AllLinksResolveToWrittenPages`. **Names equal to reserved pages**: Verifies that a method or case-colliding member group named -`operators` is written to `operators-` beside the shared `operators` page, that a class, nested -class, or free function named `operators` and a namespace named `api` are moved aside the same way +`operators` is written to `operators-2` beside the shared `operators` page, that a class, nested +class, or free function named `operators` and a namespace named `api` (moved to `api-2`) are moved aside the same way with matching links, and that only real operator overloads (not identifiers such as `operators`) are routed to the operators page. Tested by `CppEmitterGradualDisclosure_Emit_MemberNamedOperators_DoesNotOverwriteOperatorsPage`, diff --git a/docs/verification/api-mark-cpp/cpp-emitter.md b/docs/verification/api-mark-cpp/cpp-emitter.md index 5caebc7..68a4095 100644 --- a/docs/verification/api-mark-cpp/cpp-emitter.md +++ b/docs/verification/api-mark-cpp/cpp-emitter.md @@ -18,7 +18,7 @@ Tests run with the standard xUnit.net test runner. - Single-file dispatch produces exactly one `api` file. - File names come from the shared registry: illegal characters, template brackets, and reserved device names become legal forms once, and collisions are resolved up front. - `BuildClassDeclaration` renders non-final, final, and inherited class declarations correctly. -- `WriteCombinedMemberPage` emits one shared page for case-insensitive member collisions. +- `WriteCombinedMemberPage` emits one shared page for all overloads of a member name. - `WriteCombinedMemberPage` rejects a `members` list containing fewer than two elements with `ArgumentException`. - `GetIncludePath` returns a root-relative path when the source file is under a configured public root; returns the full normalized path when no root matches. - `GetIncludePath` rejects a null `sourceFile` with `ArgumentNullException`. @@ -62,8 +62,8 @@ the class declaration line in the form `class X : public Base`. Tested by produces a declaration string containing only the class keyword and name. Tested by `CppEmitter_BuildClassDeclaration_NonFinalNoBase_ReturnsJustClassName`. -**WriteCombinedMemberPage for case-insensitive collisions**: Verifies that members whose -names differ only in case are merged onto a single lowercase-keyed page. Tested by +**WriteCombinedMemberPage for overloads**: Verifies that overloads of one member name are written +onto a single shared page. Tested by `CppEmitter_WriteCombinedMemberPage_Overloads_ProducesSingleCombinedPage`. **GetIncludePath returns relative path for matching root**: Verifies that a source file diff --git a/docs/verification/api-mark-cpp/cpp-generator.md b/docs/verification/api-mark-cpp/cpp-generator.md index e85217a..b5c4444 100644 --- a/docs/verification/api-mark-cpp/cpp-generator.md +++ b/docs/verification/api-mark-cpp/cpp-generator.md @@ -21,7 +21,7 @@ Tests require the fixture headers and a system clang installation accessible on - Deprecated filtering occurs during parse-time namespace collection; visibility filtering is applied during emit-time member selection. - Every visible non-operator member and regular free function receives a deterministic detail - page unless case-insensitive collisions require a combined page. + page unless a case-insensitive collision requires a numbered page name. - Class operators and namespace operators are grouped onto shared operator pages. - Intra-library type references emit Markdown links in table cells; unknown non-std types are tracked for External Types sections. @@ -47,10 +47,10 @@ Tests require the fixture headers and a system clang installation accessible on base class in the rendered class declaration line. Tested by `CppGenerator_Generate_InheritanceClass_EmitsBaseClassInSignature`. -**Case-collision members share one page**: Verifies that members whose names differ only by case -are emitted on a single combined detail page. Tested by -`CppGenerator_Generate_CaseCollisionClass_CreatesCombinedPage` and -`CppGenerator_Generate_CaseCollisionClass_CombinedPageContainsBothMembers`. +**Case-collision members get separate pages**: Verifies that members whose names differ only by +case are emitted on separate detail pages, `Name` and `name-2`. Tested by +`CppGenerator_Generate_CaseCollisionClass_FirstCaseTwinKeepsPlainName` and +`CppGenerator_Generate_CaseCollisionClass_SecondCaseTwinGetsNumberedPage`. **Namespace operators share one page**: Verifies that namespace-level operator overloads are combined onto one operators page. Tested by @@ -175,10 +175,9 @@ return type is a known intra-library type produces a Markdown hyperlink in the R the Methods table. Tested by `CppGenerator_Generate_IntraLibraryReturnType_EmitsMarkdownLinkInReturnsCell`. -**Case-collision members do not create separate cased page**: Verifies that when a -case-insensitive collision exists, no separate page is created for the upper-case member name — -only the combined lowercase page exists. Tested by -`CppGenerator_Generate_CaseCollisionClass_DoesNotCreateSeparateCasedPage`. +**Each case-collision member page documents one member**: Verifies that each case twin page has +its own title and documents only that member. Tested by +`CppGenerator_Generate_CaseCollisionClass_EachPageDocumentsOneMember`. **External type reference emits External Types section**: Verifies that when a documented type references a non-library, non-`std` external type in its member table cells, the generated output diff --git a/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs index a3dde1e..c7c07f1 100644 --- a/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs +++ b/test/ApiMark.Cpp.Tests/CppGeneratorTests.cs @@ -613,59 +613,67 @@ public void CppGenerator_Generate_VariadicFunction_CreatesPage() } /// - /// Validates that two members whose names differ only in case are combined onto a - /// single page named after the lowercase key. + /// Validates that the member whose name is first in ordinal order keeps the plain page + /// name when two members differ only in case. /// [Fact] - public void CppGenerator_Generate_CaseCollisionClass_CreatesCombinedPage() + public void CppGenerator_Generate_CaseCollisionClass_FirstCaseTwinKeepsPlainName() { // Arrange var factory = _fixture.PublicFactory; - // Assert: the combined page is written at the lowercase key path + // Assert Assert.True( - factory.Writers.ContainsKey("fixtures/CaseCollisionClass/name"), - "Expected a combined page at the lowercase key 'name' for the colliding members"); + factory.Writers.ContainsKey("fixtures/CaseCollisionClass/Name"), + "Expected the page 'Name' for the first case twin"); } /// - /// Validates that the generator does not create a separate page for the upper-case - /// member when a case-insensitive collision exists. + /// Validates that the other case twin gets its own numbered page instead of sharing + /// a page or overwriting the first one. /// [Fact] - public void CppGenerator_Generate_CaseCollisionClass_DoesNotCreateSeparateCasedPage() + public void CppGenerator_Generate_CaseCollisionClass_SecondCaseTwinGetsNumberedPage() { // Arrange var factory = _fixture.PublicFactory; - // Assert: no page should be created using the exact-case name "Name" when a collision - // exists — the combined lowercase page replaces all individual pages for the group + // Assert + Assert.True( + factory.Writers.ContainsKey("fixtures/CaseCollisionClass/name-2"), + "Expected the page 'name-2' for the second case twin"); Assert.False( - factory.Writers.ContainsKey("fixtures/CaseCollisionClass/Name"), - "Expected no separate page for 'Name' when a case-insensitive collision exists"); + factory.Writers.ContainsKey("fixtures/CaseCollisionClass/name"), + "Expected no page named 'name' that differs from 'Name' only by case"); } /// - /// Validates that the combined collision page contains H2 headings for both the - /// method Name() and the field name. + /// Validates that each case twin page documents only its own member: the method + /// Name() on Name and the field name on name-2. /// [Fact] - public void CppGenerator_Generate_CaseCollisionClass_CombinedPageContainsBothMembers() + public void CppGenerator_Generate_CaseCollisionClass_EachPageDocumentsOneMember() { // Arrange var factory = _fixture.PublicFactory; + Assert.True(factory.Writers.TryGetValue("fixtures/CaseCollisionClass/Name", out var methodWriter)); + Assert.True(factory.Writers.TryGetValue("fixtures/CaseCollisionClass/name-2", out var fieldWriter)); - // Assert: combined page exists - Assert.True(factory.Writers.TryGetValue("fixtures/CaseCollisionClass/name", out var writer)); - - // Assert: both members appear as distinct H2 headings on the combined page - var level2Headings = writer.Operations + // Act + var methodHeadings = methodWriter.Operations .OfType() - .Where(h => h.Level == 2) + .Where(h => h.Level == 1) .Select(h => h.Text) .ToList(); - Assert.Contains(level2Headings, h => h.StartsWith("Name", StringComparison.Ordinal)); - Assert.Contains(level2Headings, h => h.StartsWith("name", StringComparison.Ordinal)); + var fieldHeadings = fieldWriter.Operations + .OfType() + .Where(h => h.Level == 1) + .Select(h => h.Text) + .ToList(); + + // Assert + Assert.Equal(["CaseCollisionClass.Name"], methodHeadings); + Assert.Equal(["CaseCollisionClass.name"], fieldHeadings); } /// @@ -1294,10 +1302,10 @@ public void CppGenerator_Generate_DeletedCopyConstructor_EmitsDeleteSuffix() factory.Writers.ContainsKey("fixtures/DeletedMembersClass"), "Expected type page for DeletedMembersClass"); - // Assert: both constructors share the same base name, so they are combined onto - // a single page keyed by the lowercase class name + // Assert: both constructors share the same base name, so they share a single page + // named after the class Assert.True( - factory.Writers.TryGetValue("fixtures/DeletedMembersClass/deletedmembersclass", out var writer), + factory.Writers.TryGetValue("fixtures/DeletedMembersClass/DeletedMembersClass", out var writer), "Expected combined constructor page for DeletedMembersClass"); // Assert: the combined constructor page must contain "= delete" for the deleted overload From 17caf311d7f6745c8ce52793daae1c3722dd8f15 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Thu, 15 Oct 2026 10:09:07 -0400 Subject: [PATCH 15/15] Fix stale DotNet case-twin docs and strengthen case-twin test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/api-mark-dot-net.md | 8 ++++---- .../dot-net-emitter-gradual-disclosure.md | 14 ++++++++------ docs/verification/api-mark-dot-net.md | 5 +++-- .../DotNetEmitterGradualDisclosureTests.cs | 14 ++++++-------- 4 files changed, 21 insertions(+), 20 deletions(-) diff --git a/docs/design/api-mark-dot-net.md b/docs/design/api-mark-dot-net.md index 8b4f09c..d37c478 100644 --- a/docs/design/api-mark-dot-net.md +++ b/docs/design/api-mark-dot-net.md @@ -42,7 +42,7 @@ defined by the Core interfaces. The system contains eleven units: covering both plain `const` fields and enum member values. - **DotNetEmitterGradualDisclosure** — writes the multi-file gradual-disclosure tree (one file per namespace and type, and one file per member except where the model - combines case-insensitive filename collisions onto a shared page). Types declaring + gives case-twin members distinct registry-approved pages). Types declaring operator overloads receive a dedicated `operators.md` page grouping all operators with C# method-signature headings and summaries. Nested types appear in a "Nested Types" table on the containing type page and receive dedicated pages under @@ -211,9 +211,9 @@ N/A - not a safety-classified software item. 6. For each visible type, DotNetEmitterGradualDisclosure writes member pages using three distinct page-generation strategies: (1) individual detail pages per member via `factory.CreateMarkdown(namespaceFolderPath, typeName)` — the default for all non-colliding - members; (2) collision-combined pages for case-insensitive filename collisions on a single - type, where two or more members whose sanitized names differ only in case are merged onto one - shared page named after the lower-invariant key; and (3) an operators group page + members, where case-twin members receive distinct registry-approved pages (for example + `Name.md` and `name-2.md`); (2) a residual combined page for any non-overload group that still + shares one approved name (for example same-named non-method members); and (3) an operators group page (`operators.md`) for types that declare operator overloads, grouping all operators with C# method-signature headings and summaries. All members are linked from the type page. Nested types appear in a "Nested Types" table on the containing type page and receive dedicated diff --git a/docs/design/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md b/docs/design/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md index 5f962b2..bd9b95d 100644 --- a/docs/design/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md +++ b/docs/design/api-mark-dot-net/dot-net-emitter-gradual-disclosure.md @@ -123,8 +123,9 @@ pure method overload group (all methods sharing the same exact case-sensitive sa emits them via `WriteExternalTypesSection`. **WriteCombinedMemberPage** (private static): Writes a single combined Markdown -page for a group of members whose sanitized file names collide on -case-insensitive file systems. +page for a residual group of non-overload members that still share one +registry-approved name. Case twins do not reach this path: the registry gives them distinct +names (for example `Name.md` and `name-2.md`) and each gets a dedicated page. - *Algorithm*: Creates `{namespaceFolderPath}/{GetTypeFileName(type)}/{lowerKey}.md` via the factory; writes an H1 heading using `lowerKey`; for each member writes an H2 @@ -135,7 +136,7 @@ case-insensitive file systems. **IsPureMethodOverloadGroup** (private static): Returns true when all members in a collision group are `MethodDefinition` instances sharing the same exact case-sensitive sanitized file name, indicating a classical method overload group -rather than a case-insensitive collision. +rather than a residual shared-name group. **GetMemberKindLabel** (private static): Maps an `IMemberDefinition` to a short human-readable kind string (`"Field"`, `"Property"`, `"Event"`, `"Constructor"`, @@ -208,7 +209,7 @@ to the appropriate per-kind accumulator. using `GetMethodGroupDisplayName` for the display text. **ProcessCollisionMember** (private static): Handles one member from a -case-insensitive filename collision group; writes the combined page on first +residual shared-approved-name group; writes the combined page on first encounter and adds the member's row to the appropriate per-kind accumulator. - *Parameters*: `TypePageWriteContext ctx`, `IMemberDefinition member`, @@ -402,8 +403,9 @@ member, and operators page patterns. - Member detail: `factory.CreateMarkdown("{namespaceFolderPath}/{GetTypeFileName(type)}", GetSanitizedMemberFileName(member, type))` - Operators page: `factory.CreateMarkdown("{namespaceFolderPath}/{GetTypeFileName(type)}", "operators")` (a member literally named `operators` is displaced to `operators-2` so the pages cannot collide) -- Combined page: `factory.CreateMarkdown("{namespaceFolderPath}/{GetTypeFileName(type)}", lowerKey)` - where `lowerKey` is `GetSanitizedMemberFileName(member, type)` lower-cased +- Residual combined page: `factory.CreateMarkdown("{namespaceFolderPath}/{GetTypeFileName(type)}", lowerKey)` + where `lowerKey` is `GetSanitizedMemberFileName(member, type)` lower-cased; used only for a + non-overload group that still shares one approved name ### Error Handling diff --git a/docs/verification/api-mark-dot-net.md b/docs/verification/api-mark-dot-net.md index 2c3a93d..d1898d6 100644 --- a/docs/verification/api-mark-dot-net.md +++ b/docs/verification/api-mark-dot-net.md @@ -21,7 +21,7 @@ service, network dependency, or machine-specific configuration is required. - All ApiMark.DotNet tests pass with zero failures. - The generator discovers namespaces, types, and members from representative fixture assemblies. - Type names and member signatures are simplified into the expected C#-friendly display form. -- Every visible member receives a dedicated detail page or is combined onto a shared collision page, and is linked from the type page. +- Every visible member receives a dedicated detail page (pure method overloads share one overload page), and is linked from the type page. - Visibility filtering excludes members outside the selected audience. - Obsolete member filtering correctly excludes or includes deprecated APIs based on the IncludeObsolete option. - Documentation coverage enforcement correctly identifies undocumented types and members at the @@ -40,7 +40,8 @@ file is wired correctly. This scenario is tested by **All members receive deterministic detail pages**: Verifies that every visible member — regardless of parameters or documentation content — is emitted with a deterministic file path and linked from its parent type page. Simple members get their own dedicated file; pure overload groups -and case-insensitive filename collisions are intentionally combined onto a shared page. +are intentionally combined onto a shared overload page. Case-twin members receive distinct +registry-approved pages (for example `Name.md` and `name-2.md`). **Visibility filters constrain the published API surface**: Verifies that the system honors the selected visibility mode so generated output matches the intended audience and excludes hidden diff --git a/test/ApiMark.DotNet.Tests/DotNetEmitterGradualDisclosureTests.cs b/test/ApiMark.DotNet.Tests/DotNetEmitterGradualDisclosureTests.cs index ebaba61..4794a53 100644 --- a/test/ApiMark.DotNet.Tests/DotNetEmitterGradualDisclosureTests.cs +++ b/test/ApiMark.DotNet.Tests/DotNetEmitterGradualDisclosureTests.cs @@ -281,7 +281,7 @@ public void DotNetEmitterGradualDisclosure_Emit_ValidModel_CreatesMemberDetailPa "Expected a member detail page for SampleClass.Reset"); } - /// Validates that the gradual-disclosure emitter creates a combined page for case-colliding members. + /// Validates that the gradual-disclosure emitter creates separate registry-approved pages for case-twin members. [Fact] public void DotNetEmitterGradualDisclosure_Emit_CaseCollision_CreatesSeparatePages() { @@ -292,13 +292,11 @@ public void DotNetEmitterGradualDisclosure_Emit_CaseCollision_CreatesSeparatePag // Act new DotNetEmitterGradualDisclosure(emitter, emitter.Model).Emit(factory, new EmitConfig(), new InMemoryContext()); - // Assert: CaseCollisionClass has 'name' (field) and 'Name' (property) that collide on - // case-insensitive filesystems; the combined page is keyed using the lower-invariant "name" - Assert.True( - factory.Writers.Keys.Any(k => - k.Contains("CaseCollisionClass", StringComparison.Ordinal) && - k.EndsWith("/name", StringComparison.OrdinalIgnoreCase)), - "Expected a combined collision page for CaseCollisionClass members 'name' and 'Name'"); + // Assert: CaseCollisionClass has 'name' and 'Name' that collide on case-insensitive + // filesystems; the first keeps its plain name and the second gets a numbered page + var keys = factory.Writers.Keys.ToList(); + Assert.Contains(keys, k => k.EndsWith("CaseCollisionClass/Name", StringComparison.Ordinal)); + Assert.Contains(keys, k => k.EndsWith("CaseCollisionClass/name-2", StringComparison.Ordinal)); } /// Validates that the api index page heading contains the assembly name.