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.
- 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.
- Directory or file, from the same attributes. One query answers both
questions, so an entry costs one filesystem call before selection.
- 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:
- 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.
- 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.
- 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.