Spec-Driven

Spec-Driven C#: Dirnav

traversal, admission, matching, and emission in one generic class

Synopsis:
This page covers CSharp_TextFinder_Dirnav, which walks, selects, reads, matches, and formats. It is the only component that touches file contents, and it writes to no stream.
  • A sealed generic class constrained on the IOutput interface it declares and does not implement.
  • The constructor throws on a pattern it cannot accept, which is the C# expression of a failed construction - so C# sits with C++ here and Rust returns a Result instead.
  • Search takes a string rather than a path type, because .NET has none.
Section 6 names eight framework calls this library does not use, which is more than either sibling needed.
  • The base class library offers a method for nearly every rule, and most of them implement something close to the rule rather than the rule.
  • Encoding.UTF8.GetString is the sharpest - it substitutes U+FFFD and reports nothing, so every file would pass the UTF-8 test. One constructor argument is the whole of the fix.
  • One of the eight would produce correct output today, and is listed because the correctness is a coincidence rather than a promise.
Two rules resolve differently here than in either sibling.
  • One attributes query answers symbolic-link, directory, and file alike, and .NET sets the reparse-point flag on both platforms where C++ and Rust each call a platform-specific query.
  • The unrenderable-name rule needs no name test at all - on Windows every name renders, and on POSIX the runtime has already substituted before this library sees it.
  • The lazy enumerator is stepped by hand inside a try, since a foreach would put a mid-iteration failure beyond any handler this library could place.

1.  Public Interface

CSharp_TextFinder_Dirnav walks a directory tree, reads each selected file, evaluates the expression against each line, and formats every matching file into the block Spec_TextFinder.md §3.4 fixes, emitting each of its lines as it is produced. It is the only component that touches file contents, and it writes to no stream. The namespace exports two things.
public interface IOutput
{
    void Output(string text);
}

public sealed class Dirnav<TOutput> where TOutput : IOutput
{
    public Dirnav(TOutput output, IReadOnlyList<string> skips, ProgramCommands commands);

    public void Search(string root);

    public void EmitRunSummary();
}
The class declares the IOutput interface it does not implement, which is what puts CSharp_TextFinder_Output downstream of it in the reference chain. The constructor builds a System.Text.RegularExpressions.Regex - the engine Spec_TextFinder.md §6.1 assigns to C# - and lets RegexParseException propagate when the pattern will not compile. A constructor that throws on an argument it cannot accept is the C# expression of a failed construction: there is no result type in the base class library to return instead, and a TryCreate factory would put the type's only failure mode behind a second entry point for no gain. The Rust implementation returns a Result here and the C++ one lets std::regex_error propagate, so C# sits with C++ on this one. Two RegexOptions values are rejected by name, and each rejection is a judgment rather than a rule.
  • RegexOptions.Compiled trades startup time for match speed by emitting IL for the pattern. The run is short-lived, and a JIT pass over the pattern costs more than it returns on a tree of this size.
  • RegexOptions.NonBacktracking guarantees linear-time matching and rejects constructs §6.1 admits into its portable subset, so it would make this implementation refuse patterns the other engines accept - which is the larger of the two costs §6.1 names.
skips is an IReadOnlyList<string> rather than a List<string>, so this library cannot modify the list it is given. The specification states plainly that this is weaker than the shared borrow Rust gets from its type system: a caller holding the underlying List<string> may still change it. Nothing here does, and Spec_CSharp_TextFinder_Entry.md §5 fixes that the binary stops extending the list before traversal begins. Search takes a string rather than a path type, because .NET has none: System.IO.Path is a static class of string operations. The root is the text the user typed, and Section 5's rendering works from that text directly. Search returns nothing. Every failure it meets is announced through IOutput, so the caller has nothing to report on its behalf. A single instance is reused across every root path, so the expression is built once per run, and Search carries no state from one call to the next but for the two run counts of Section 5.1, which accumulate across calls by design. EmitRunSummary writes the run summary Spec_TextFinder.md §3.6 requires. It takes no argument and returns nothing, the counts being this instance's own, and CSharp_TextFinder_Entry calls it once after the last Search returns. The split is deliberate: the counts belong here because nothing else sees the entries, and the call belongs to the caller because nothing here knows which root was the last. No property exposes either count - the line is the whole of what they are for, and the unit suite reads them by reading that line through its own IOutput.

2.  Traversal

Search resolves a root before walking it. A symbolic link, a kind that is neither regular file nor directory, and a failed attributes query each draw cannot open; a regular file is examined as a single file; a directory is walked. The skip list is never consulted for the root, which §3.2 exempts because the user named it explicitly. The root's kind comes from File.GetAttributes, which reports the attributes of the entry itself and does not follow a symbolic link. Two obvious alternatives are rejected by name, and both would fail in two ways rather than one: Directory.Exists and File.Exists each follow a link, so they would report the target's kind and hide the case §3.2 requires be announced, and each answers false for an entry that exists and cannot be opened, which would turn an error announcement into silence. The recursion is written explicitly. Walk iterates one directory and calls itself on each subdirectory it decides to enter, so entries are handled as Directory.EnumerateFileSystemEntries yields them, without being collected or reordered. Three framework alternatives are rejected by name, which is more than either sibling needed for this rule, because .NET offers more ways to get it wrong.
  • Directory.GetFileSystemEntries materializes the level into an array. That changes no order, so it would satisfy §3.2, and it reads the whole directory before the first entry is handled - which §3.4's "as they occur" emission makes visible on a large directory.
  • Directory.GetFiles followed by Directory.GetDirectories groups files ahead of directories, which §3.2 forbids in as many words.
  • EnumerationOptions { RecurseSubdirectories = true } owns the descent this library must own, since /s and the skip list both decide whether to enter a directory.
Every fallible call in the walk is guarded, including the enumeration's own advance. EnumerateFileSystemEntries returns lazily, so a directory that becomes unreadable part way through throws from MoveNext rather than from the call that started it. The loop therefore steps the enumerator by hand inside a try, which is what lets §5 rule 7's "announced rather than silently truncated" hold. A foreach would have put that throw outside any handler this library could place. The order of the entry tests matters, and §5 fixes it.
  1. Symbolic link, tested first. An entry whose attributes carry FileAttributes.ReparsePoint is passed over silently, whatever its target, since no attempt is made to open it. .NET sets that attribute for a symbolic link on both Windows and POSIX, so one runtime test serves both platforms where C++ and Rust each call a platform-specific query.
  2. Directory or file, from the same attributes. One query answers both questions, so an entry costs one filesystem call before selection.
  3. Skip list, extension filter, and the open, after both.
A pruned directory draws no announcement. Pruning is work the library chose not to do, and cannot open reports work it could not do. §3.4 settles what happens to an entry whose name the implementation's string type cannot render, and the case resolves differently here than in either sibling - so differently that this library runs no name test at all. A string is a sequence of UTF-16 code units and may hold unpaired surrogates, so on Windows every name the filesystem admits renders and the case does not arise. On POSIX the runtime decodes a name that is not valid UTF-8 before this library sees it, substituting U+FFFD, and the substituted text no longer names the file on disk; the attributes query then fails and the entry draws cannot open carrying the already-substituted name. Two consequences are recorded rather than left to be discovered. §3.4's U+FFFD requirement is met by the runtime rather than by this implementation, and the announcement's path is the substituted text, which §3.4 accepts as one of its two costs. And §3.4's rule that rendering a name may not end the run is satisfied without a catch around a conversion, because this library performs no conversion. The C++ implementation had to write a renderPath helper for the same requirement, and Rust calls OsStr::to_string_lossy; here the requirement costs no code, which is worth naming because a reader looking for the gate will not find one.

3.  File Selection

Selection follows the /p rules of Spec_TextFinder.md §5, and the list arrives from CSharp_TextFinder_Cmdline already normalized to bare extensions. An empty list selects every file; a non-empty list selects a file whose extension matches an entry. The extension is taken as the text after the last . in the file name, with one LastIndexOf('.') and a slice, no special case for a leading dot, and a name holding no . at all has no extension. Path.GetExtension is not used, and the reason is unlike the rejections on this page's siblings. It does not get the rule wrong: it returns .gitignore for .gitignore, which agrees with §5, where the C++ implementation's path::extension() returns nothing for that name and the Rust implementation's Path::extension returns None. The specification declines it anyway, because §5 states the rule over the file name and implementing it directly puts the dot-file case in this library's own code rather than resting it on a framework detail no document in this tree fixes. A framework whose behavior happens to agree is still a dependency on behavior nobody promised. Comparison is case-sensitive on POSIX and case-insensitive on Windows, per §3.2 and §5. The platform test is OperatingSystem.IsWindows(), evaluated at run time and held in one field. That is a constraint of the platform rather than a preference: a .NET assembly is compiled once and runs on both, so C++'s #ifdef _WIN32 and Rust's #[cfg(windows)] have no counterpart here - a conditional-compilation test would fix the behavior of the machine that built the assembly rather than the machine that runs it. The comparison itself is StringComparison.OrdinalIgnoreCase or StringComparison.Ordinal. The culture-sensitive forms are rejected by name: CurrentCultureIgnoreCase folds I and i differently under a Turkish culture, which would make the same tree and the same /p list select different files on two machines of the same platform. One field serves both comparisons, and §5 rule 4 says so: a skip-list entry is compared against a directory's basename by the rule §6 states for an extension, the same rule applied by the same method, since §3.2 and §5 fix one comparison for both. Naming the method rather than restating the rule is what keeps the two from drifting. Selection applies uniformly: a root path that is a regular file is filtered by /p like any other file. A root named on /P escapes the skip list and does not escape the extension filter, and the unit suite asserts both halves.

4.  Admission and the No-Content Case

Examine applies the three admission tests of Spec_TextFinder.md §3.3 in the order the specification fixes. The size test reads the length FileInfo.Length reports, so a file above the limit is never read into memory; the NUL and UTF-8 tests read bytes. The limit is 10,485,760 bytes, written 10_485_760 so that a reader can count the digits. The constructor records once, for the whole run, whether the no-content case applies - RegexText equal to . with LineNumbers and MatchedLine both false. When it does, a selected file that passes the size test and is not empty produces a block of its path line alone, and the file is never opened. No file announcement accompanies that block. searched reports a file that was read and matched nothing and skipped reports one a content test rejected, and in this case neither happened, so the library emits neither whatever /h says. A selected file of zero size produces no block and draws no announcement either. Both error announcements still work, because both rest on metadata. A file that needs reading is read in full with File.ReadAllBytes, so one failing a later test is skipped entirely rather than searched in part. The NUL test is Array.IndexOf(bytes, (byte)0) >= 0. The UTF-8 test is where this library rejects a framework default in the sharpest terms, and the specification says so. The bytes are decoded with a UTF8Encoding constructed throwOnInvalidBytes: true, and a DecoderFallbackException means the file is rejected.
private static readonly UTF8Encoding StrictUtf8 =
    new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
Encoding.UTF8.GetString would have been the obvious call and it implements a different rule: that property returns an encoding whose fallback substitutes U+FFFD for an invalid sequence and reports nothing. Every file would pass §3.3's UTF-8 test, and the case §3.3 names by example - a UTF-16 file of ASCII text - would be searched with its NUL bytes already caught and its mojibake silently accepted. The framework's own default is the wrong tool here, and one constructor argument is the whole of the fix. The C++ implementation writes those four UTF-8 rejections out by hand, one line each with the reason named in a comment, because it has no such call to lean on; Rust leans on std::str::from_utf8. All three end at the same place and only one of the three had a default that would have quietly agreed to everything. A leading UTF-8 BOM survives decoding as U+FEFF and is stripped from the head of the decoded text, after the admission tests rather than before, so its three bytes count toward the size limit and toward the NUL scan like any others. The encoderShouldEmitUTF8Identifier: false argument governs writing rather than reading and does nothing here; it is set because the same encoding type is constructed for the sink, where it does.

5.  Matching and Emission

Each line is evaluated with the instance method Regex.IsMatch, which gives the anywhere-in-the-line match §3.3 requires and asks the engine for nothing more - no match position, no matched substring, no capture group. The static Regex.IsMatch(input, pattern) overload is rejected by name: it consults a pattern cache and would defeat §3.3's requirement that the expression be compiled once per invocation. The line is passed as a string, so ., a character class, and a class escape each match one UTF-16 code unit. §6.1 records std::regex over char as the one engine of the four that matches a byte instead, so on a line holding a non-ASCII character below U+10000 this implementation agrees with Rust and Python and C++ is the outlier. Above U+10000 a . here matches one code unit of a surrogate pair where the Rust engine matches the whole scalar value. §6.1's portable subset is scoped to ASCII lines, so the specification records that difference rather than resolving it. Line splitting is the one rule this library gets from the framework outright. TextReader.ReadLine, over a StringReader on the decoded text, treats CR, LF, and CRLF as terminators and yields a final unterminated run as a line - which is exactly §3.3's rule, all three terminators and the unterminated tail. C++ wrote a splitLines returning views into the buffer, and Rust wrote a 30-line Lines iterator, both because str::lines and its C++ equivalents implement two of the three. Here no splitter is written. string.Split('\n') is rejected by name and gets three things wrong at once: it does not treat a bare CR as a terminator, it leaves a CR at the end of every line of a CRLF file, and it yields a trailing empty element for a file that ends in a terminator. Line numbers count every line, including those that do not match, which follows from counting the reader's yields rather than the matches. Three rules govern the emission, and all three are visible in a dozen lines of Examine:
  1. The path line goes out at the first match, ahead of the detail line for that same match, and a pathWritten flag makes sure it goes out once. A file that never matches produces no line at all; a file that matches many produces its path line once.
  2. With neither /n nor /L, the loop returns as soon as the path line is written. The block has no detail lines, so the first match settles the file and the remaining lines are never read from the reader.
  3. Otherwise the loop runs to the end of the file, writing one detail line - two spaces of indent, then the fields /n and /L select - as each matching line is evaluated.
Nothing is accumulated for the file. IOutput receives each line as it is produced, which is the pipeline behavior §3.4's "as they occur" requires. The /h gate is one method, FileAnnouncement, and it reads as the rule it implements: emit unless SuppressOnNoMatch. Path rendering is where this library does its own string work rather than the framework's. §3.4 requires / on every platform for the whole of <path>, so a path is built by joining the root's own text with the entry names descended through. Path.Combine is rejected by name: it joins with Path.DirectorySeparatorChar, which on Windows is \, and would make the same tree produce different output on two platforms. Path.GetFullPath is rejected too, since §3.4 asks for the path by which the file was reached rather than its absolute form. The root's own text is normalized as well, since §3.4's rule covers all of <path>: every \ becomes / before the first entry name is appended, so -P src\sub on Windows yields src/sub/file.cs and not src\sub/file.cs. A root path of . contributes no leading ./, which Walk arranges by taking an empty prefix for that root. One consequence of that rule is worth naming, because it costs an allocation per entry: the path this library hands to the filesystem is not the path it emits. Filesystem calls take the platform form that EnumerateFileSystemEntries yielded, and the emitted form is built alongside it with /. Two strings therefore travel through the walk for each entry. The alternative is a translation inside every filesystem call, which trades one join per entry for one per call.

5.1  The Run Summary

Spec_TextFinder.md §3.6 puts the two run counts in this library, for every implementation alike, and fixes the line they produce. Two long fields hold them, both zero on construction, and neither is reset by Search, so they accumulate over every root the instance is given.
  • Files are counted at the head of Examine, which both call sites reach only after the /p test of Section 3 admits the file, and which is entered before FileInfo.Length is read. A file admitted and then announced too large or cannot open is therefore counted.
  • Directories are counted at the head of Walk, before Directory.EnumerateFileSystemEntries, so a directory that cannot be enumerated is counted and announced alike.
Everything §3.6 excludes is excluded by where those two lines sit rather than by a test of its own. An entry refused by /p, a reparse point, and anything beneath a pruned directory never reach Examine, and an entry whose attributes cannot be read draws cannot open before selection is even attempted - which is why the file count can be smaller than the number of announcements a run writes. EmitRunSummary formats the line with CultureInfo.InvariantCulture, as Section 5's detail lines do, and sends it through the same IOutput as every other line, in §3.6's fixed form and with neither noun inflected:
accessed <files> files, <directories> directories
The invariant culture is not decoration here. A count formatted under a culture that groups thousands would write 1,024 files on one machine and 1024 files on another, and §3.6 fixes the line byte for byte across four implementations, so the locale is exactly the kind of ambient state a fixed-text rule cannot tolerate. This is the one respect in which the C# count needed a decision the other two did not. The line is not gated on /h, which governs file announcements alone, and it names no path, so this section's rendering rules do not reach it. Twelve unit-suite assertions cover it, and the one worth naming reads accessed 1 files, 0 directories from a root that is a regular file - the uninflected singular, asserted rather than left to be noticed.

6.  Eight Calls Rejected by Name

This specification names more framework calls it does not use than either sibling's does, and the count is not a stylistic difference. The base class library offers a method for nearly every rule §3.2 through §3.4 states, and most of those methods implement something close to the rule rather than the rule. A specification that named only what the code calls would leave the next implementer to rediscover each near-miss.
Rejected What it does instead
Encoding.UTF8.GetString Substitutes U+FFFD and reports nothing, so every file passes the UTF-8 test
string.Split('\n') Misses a bare CR, keeps the CR of a CRLF pair, and adds a trailing empty line
Directory.Exists, File.Exists Follow a link, and answer false for an entry that exists and cannot be opened
Directory.GetFiles then GetDirectories Groups files ahead of directories, which §3.2 forbids
EnumerationOptions.RecurseSubdirectories Owns the descent /s and the skip list must decide
Path.Combine, Path.GetFullPath Joins with the platform separator; returns an absolute path
Static Regex.IsMatch Consults a pattern cache rather than reusing one compiled expression
Path.GetExtension Agrees with §5 today, and rests the dot-file rule on a framework detail
The last row is the only one where the rejected call would produce correct output. It is listed because the correctness is a coincidence rather than a promise, and a reader comparing this library against the C++ one - where the same call is wrong - needs to know which of the two reasons applies here.

7.  Source

IOutput.cs and Dirnav.cs in full, 10 lines and 314. The comments cite the C# specification's sections, which cite Spec_TextFinder.md in turn.
src/IOutput.cs
// IOutput.cs - the seam between traversal and the sink, per CSharp_TextFinder_Structure.md

namespace CSharp_TextFinder_Dirnav;

// Declared here and not in the Output library, which is what puts CSharp_TextFinder_Output
// downstream of this project in the reference chain.
public interface IOutput
{
    void Output(string text);
}
src/Dirnav.cs
// Dirnav.cs - traversal, admission, matching, and emission per Spec_CSharp_TextFinder_Dirnav.md

using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using CSharp_TextFinder_Cmdline;

namespace CSharp_TextFinder_Dirnav;

public sealed class Dirnav<TOutput> where TOutput : IOutput
{
    // §7: the number Spec_TextFinder.md §3.3 fixes.
    private const long SizeLimit = 10_485_760;

    private static readonly char[] Separators = { '/', '\\' };

    // §7: Encoding.UTF8 substitutes U+FFFD and reports nothing, so every file would pass
    // the UTF-8 test. This encoding throws instead.
    private static readonly UTF8Encoding StrictUtf8 =
        new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);

    private readonly TOutput _sink;
    private readonly IReadOnlyList<string> _skips;
    private readonly ProgramCommands _commands;
    private readonly Regex _expression;
    private readonly bool _pathLineOnly;

    // §8.1: never reset between roots.
    private long _files;
    private long _directories;

    // §6: a .NET assembly is built once and runs on both platforms, so the platform test
    // is made at run time where C++ and Rust make it at compile time.
    private readonly StringComparison _nameComparison =
        OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;

    // §4: RegexParseException propagates; CSharp_TextFinder_Entry catches that type and
    // writes the §5.2 diagnostic. RegexOptions.None, per §4.
    public Dirnav(TOutput output, IReadOnlyList<string> skips, ProgramCommands commands)
    {
        _sink = output;
        _skips = skips;
        _commands = commands;
        _expression = new Regex(commands.RegexText, RegexOptions.None);
        _pathLineOnly = commands.RegexText == "."
                        && !commands.LineNumbers
                        && !commands.MatchedLine;
    }

    // §5 rule 1: the root's kind comes from File.GetAttributes, which does not follow a link.
    public void Search(string root)
    {
        string display = Normalize(root);

        if (!TryAttributes(root, out FileAttributes attributes))
        {
            Announce("cannot open", display);
            return;
        }

        if ((attributes & FileAttributes.ReparsePoint) != 0)
        {
            Announce("cannot open", display);
        }
        else if ((attributes & FileAttributes.Directory) != 0)
        {
            Walk(root, display);
        }
        else if (Selected(LastComponent(display)))
        {
            Examine(root, display);
        }
    }

    // §8.1: the run summary of Spec_TextFinder.md §3.6, written once after the last root.
    public void EmitRunSummary()
    {
        _sink.Output(string.Format(
            CultureInfo.InvariantCulture,
            "accessed {0} files, {1} directories",
            _files,
            _directories));
    }

    // §5 rule 2: one level, lazily, in the order the platform yields.
    private void Walk(string directory, string display)
    {
        ++_directories;   // §8.1: counted before enumeration, so one that fails is counted too
        string prefix = display == "." ? string.Empty : display;

        IEnumerator<string> entries;
        try
        {
            entries = Directory.EnumerateFileSystemEntries(directory).GetEnumerator();
        }
        catch (Exception e) when (IsAccessFailure(e))
        {
            Announce("cannot open", display);
            return;
        }

        using (entries)
        {
            while (true)
            {
                // §5 rule 7: a directory that becomes unreadable part way through is
                // announced rather than silently truncated, so MoveNext is guarded too.
                try
                {
                    if (!entries.MoveNext()) break;
                }
                catch (Exception e) when (IsAccessFailure(e))
                {
                    Announce("cannot open", display);
                    return;
                }

                string entry = entries.Current;
                string name = LastComponent(entry);
                string child = Join(prefix, name);

                if (!TryAttributes(entry, out FileAttributes attributes))
                {
                    Announce("cannot open", child);
                    continue;
                }

                // §5 rule 5: tested first, and passed over without a word.
                if ((attributes & FileAttributes.ReparsePoint) != 0) continue;

                if ((attributes & FileAttributes.Directory) != 0)
                {
                    if (_commands.Recurse && !Skipped(name)) Walk(entry, child);
                }
                else if (Selected(name))
                {
                    Examine(entry, child);
                }
            }
        }
    }

    // §7: size from metadata, then the NUL and UTF-8 tests over the bytes.
    private void Examine(string path, string display)
    {
        ++_files;   // §8.1: reached only after the /p test, ahead of every later outcome

        long length;
        try
        {
            length = new FileInfo(path).Length;
        }
        catch (Exception e) when (IsAccessFailure(e))
        {
            Announce("cannot open", display);
            return;
        }

        if (length > SizeLimit)
        {
            Announce("too large", display);
            return;
        }

        // §7: the no-content case of Spec_TextFinder.md §3.3. The file is never opened.
        if (_pathLineOnly)
        {
            if (length > 0) _sink.Output(display);
            return;
        }

        byte[] bytes;
        try
        {
            bytes = File.ReadAllBytes(path);
        }
        catch (Exception e) when (IsAccessFailure(e))
        {
            Announce("cannot open", display);
            return;
        }

        if (Array.IndexOf(bytes, (byte)0) >= 0)
        {
            FileAnnouncement("skipped", display);
            return;
        }

        string text;
        try
        {
            text = StrictUtf8.GetString(bytes);
        }
        catch (DecoderFallbackException)
        {
            FileAnnouncement("skipped", display);
            return;
        }

        // §7: stripped after the admission tests, so its three bytes counted toward both.
        if (text.Length > 0 && text[0] == (char)0xFEFF) text = text[1..];

        bool details = _commands.LineNumbers || _commands.MatchedLine;
        bool pathWritten = false;
        int number = 0;

        // §7: TextReader.ReadLine already implements §3.3's three terminators and its
        // unterminated final run, so this library writes no splitter of its own.
        using var reader = new StringReader(text);
        while (reader.ReadLine() is { } line)
        {
            ++number;
            if (!_expression.IsMatch(line)) continue;

            if (!pathWritten)
            {
                _sink.Output(display);
                pathWritten = true;
            }

            // §8 rule 2: with neither /n nor /L the first match settles the file.
            if (!details) return;

            _sink.Output(Detail(number, line));
        }

        if (!pathWritten) FileAnnouncement("searched", display);
    }

    // §8: two spaces of indent, then the fields /n and /L select.
    private string Detail(int number, string line)
    {
        var text = new StringBuilder("  ");

        if (_commands.LineNumbers)
        {
            text.Append(number.ToString(CultureInfo.InvariantCulture));
            if (_commands.MatchedLine) text.Append(" - ");
        }

        if (_commands.MatchedLine) text.Append(line);

        return text.ToString();
    }

    // §6: the extension is the text after the last dot in the name, dot-files included.
    private bool Selected(string name)
    {
        if (_commands.Extensions.Count == 0) return true;

        int dot = name.LastIndexOf('.');
        if (dot < 0) return false;

        string extension = name[(dot + 1)..];
        foreach (string candidate in _commands.Extensions)
        {
            if (string.Equals(candidate, extension, _nameComparison)) return true;
        }

        return false;
    }

    // §5 rule 4: the same comparison the extension test uses, since §3.2 and §5 fix one.
    private bool Skipped(string name)
    {
        foreach (string skip in _skips)
        {
            if (string.Equals(skip, name, _nameComparison)) return true;
        }

        return false;
    }

    private void Announce(string kind, string path) => _sink.Output(kind + " " + path);

    private void FileAnnouncement(string kind, string path)
    {
        if (!_commands.SuppressOnNoMatch) Announce(kind, path);
    }

    private static bool TryAttributes(string path, out FileAttributes attributes)
    {
        try
        {
            attributes = File.GetAttributes(path);
            return true;
        }
        catch (Exception e) when (IsAccessFailure(e))
        {
            attributes = default;
            return false;
        }
    }

    // §5 rule 7: the three the filesystem calls of this library document.
    private static bool IsAccessFailure(Exception e) =>
        e is IOException or UnauthorizedAccessException or ArgumentException;

    // §8: Spec_TextFinder.md §3.4 renders every path with / on every platform, the
    // root's own text included. Path.Combine would join with the platform separator.
    private static string Normalize(string text) => text.Replace('\\', '/');

    private static string Join(string prefix, string name)
    {
        if (prefix.Length == 0) return name;
        return prefix.EndsWith('/') ? prefix + name : prefix + "/" + name;
    }

    private static string LastComponent(string path)
    {
        int cut = path.LastIndexOfAny(Separators);
        return cut < 0 ? path : path[(cut + 1)..];
    }
}
IsAccessFailure is one line and it is the whole of this library's error handling: IOException, UnauthorizedAccessException, and ArgumentException, caught through an exception filter at each call rather than by a try around the walk. The third joined the list while the code was being written, and the Process page records why: the framework reports a path string the platform will not accept by argument validation rather than by a failed call, and an uncaught one would end the traversal.

8.  Prompt Records

This page carries none. Page_Structure.md §8 assigns it Prompts_Spec_CSharp_TextFinder_Dirnav.md and its Fix companion, and neither was written: this thread produced one record covering every turn, and it sits on the Process page. Two of the three corrections that record holds reach this component, the third exception type and the limit on the root-kind test.