Synopsis:
This page covers CSharp_TextFinder_Cmdline, the single place in the C#
implementation where switch letters, argument syntax, and defaults are known.
-
One data class and one static class of four methods, since C# has no free functions
for the siblings' arrangement.
-
TryParse rather than a returned result - a malformed command line is
something the user typed, and the base class library has neither
std::expected nor Result.
-
Two
NotNullWhen attributes are the whole of what makes that pattern
pleasant to call rather than merely workable.
The property initializers are the sole authority in code for the nine defaults.
- Four properties carry no initializer, and the specification says the omission is deliberate - writing
= false would suggest a value was chosen where the other five show that it was.
- A
record was available and is not used, since value equality would give a reader an Equals they might mistake for a specification claim.
Several framework methods are rejected by name, each because it looks made for the job
and implements a different rule.
bool.TryParse trims and accepts values the specification does not, so -s " true " would be accepted.
string.Trim() trims too much and TrimStart('.') strips every leading dot where the rule strips one.
- Every comparison is ordinal, because under a Turkish culture the fold of
I and i would make /h and /H depend on the machine's locale.
1. Public Interface
CSharp_TextFinder_Cmdline converts an argument array into an object that
controls the other two libraries. It is the single place in the C# implementation where
switch letters, argument syntax, and defaults are known, and it performs no traversal, no
matching, no file I/O, and no stream writing.
Namespace CSharp_TextFinder_Cmdline exports one class of data and one class of
methods.
public sealed class ProgramCommands
{
public List<string> RootPaths { get; set; } = new() { "." }; // /P
public List<string> Extensions { get; set; } = new(); // /p
public string RegexText { get; set; } = "."; // /r
public bool Recurse { get; set; } = true; // /s
public bool SuppressOnNoMatch { get; set; } = true; // /h
public bool Verbose { get; set; } // /v
public bool Help { get; set; } // /H
public bool LineNumbers { get; set; } // /n
public bool MatchedLine { get; set; } // /L
}
public static class CommandLine
{
public static bool TryParse(string[] args,
[NotNullWhen(true)] out ProgramCommands? commands,
[NotNullWhen(false)] out string? diagnostic);
public static string UsageLine();
public static string HelpText();
public static string OptionsText(ProgramCommands commands);
}
CommandLine is a static class because C# has no free functions. The
C++ implementation exports
its four at global scope and the
Rust one exports four
from a module; here they need a containing type, and one type holding all four is the
shortest honest arrangement, since all four render or read the same option set.
TryParse follows the Try pattern rather than returning a result
object or throwing. A malformed command line is something the user typed, so it is an
ordinary outcome of parsing rather than an exceptional one - and the base class library has
no std::expected and no Result to return instead. The
Try pattern is how C# expresses that.
The two out parameters are nullable and carry NotNullWhen
attributes, so a caller compiling with nullable reference types enabled may use
commands after a true return and diagnostic after a
false return without a null check and without a null-forgiving operator.
Exactly one of the two is non-null on return. Those two attributes are the whole of what
makes the pattern pleasant to call rather than merely workable.
TryParse takes string[] rather than reading the arguments itself,
so the unit suite hands it hand-built arrays and 67 checks run without spawning a process.
ProgramCommands has no invariants: every property combination the parser can
produce is valid. It is a reference type, so Dirnav holds a reference to the
object CSharp_TextFinder_Entry owns and the garbage collector keeps it alive.
No lifetime rule appears in this specification, because there is none to state - where C++
states one in prose and Rust has a borrow checker enforce it.
The property names are the switch meanings spelled out rather than the switch letters.
SuppressOnNoMatch for /h is the one worth pausing on: it names
what the flag does rather than what it is called, so the gating check in
CSharp_TextFinder_Dirnav reads as a sentence.
2. Defaults Live in the Property Initializers
The property comments are the switch-to-property mapping, and the initializers are the sole
authority in code for the defaults of Spec_TextFinder.md §5. A newly constructed
ProgramCommands equals the result of parsing an empty array, because
TryParse starts from one and overwrites only what the arguments name. That is
what makes step 4 of the Entry startup sequence a one-liner.
Four of the nine properties carry no initializer, and the omission is deliberate rather than
an oversight: default(bool) is already false, and writing
= false on those four would suggest the value was chosen where the other five
initializers show that it was. The specification says so, because a reader comparing this
block against the §5 table needs to know that a missing initializer is a default and
not a gap.
[Serializable]-style attribute tricks and record types were both
available and neither is used. A record would give value equality that nothing
here needs and an Equals a reader might mistake for a specification claim.
The wording in the specification is load-bearing: this is the sole authority in code.
Spec_TextFinder.md §5 remains the authority overall, and a disagreement between the two
is a defect in this library. The unit suite asserts each of the nine against §5
separately rather than asserting the object equals a fresh ProgramCommands,
which would compare the code against itself.
3. Parsing Rules
TryParse scans args from index 0 left to right, alternating switch
token and argument token. It stops at the first violation and produces that diagnostic; no
partial result is returned. It touches no filesystem: root paths are not tested for
existence, and extensions are not compared against any file.
Index 0, not index 1. Main's array holds the arguments alone,
so there is no program name to skip. Both siblings skip element 0 because their vectors
carry the executable. The unit suite asserts the difference in both directions: a
-r alpha array parses, and an array whose first element is
CSharp_TextFinder draws not a switch: CSharp_TextFinder.
Four rules govern the scan.
- Switch tokens. A token in switch position is valid only when it is
exactly two characters, the first
/ or -, the second one of
the nine letters. A token with no introducer is not a switch; any other
introducer-led token, including a bare / or -, is an
unrecognized switch.
- Arguments. Each switch consumes the following token verbatim,
including when that token begins with
/ or -, since there are
no bare flags. A switch with no following token is missing its argument.
- Conversion. Boolean switches accept only
true or
false, compared with StringComparison.OrdinalIgnoreCase.
/r and /P take the token verbatim and each rejects an empty
argument. /p is normalized.
- Accumulation.
/P clears the default
{ "." } on its first occurrence and appends thereafter, preserving argument
order. Every other switch overwrites any earlier value, silently discarding it.
Rule 1 partitions the first two diagnostics of §5.2 rather than leaving them to
overlap. Before the partition, /ss, -abc, and a bare
/ each satisfied both conditions, and two implementations could have reported
different reason lines for the same token while both followed the specification.
The length test is token.Length != 2, which counts UTF-16 code units. A
two-character token holding an astral character therefore measures 3 or more and is refused
as an unrecognized switch. The specification records that as the same outcome the rule
reaches by its own terms rather than as a divergence: such a token is not one of the nine
switches under any counting, and the reason line is the one §5.2 gives for an
introducer-led token that is not a switch.
bool.TryParse is rejected by name, and the rejection is easy to miss because
the method looks made for this. It trims surrounding whitespace and accepts values §5
does not, so -s " true " would be accepted where this specification refuses it.
The conversion is two string.Equals calls against the two literals §5
names.
Every comparison in this library is ordinal. A culture-sensitive comparison is rejected by
name for a reason specific to one locale: under a Turkish culture a culture-sensitive fold
of I and i differs from the invariant one, and /h and
/H are distinct switches whose distinction must not depend on the machine's
locale. The binary's InvariantGlobalization property makes that impossible to
get wrong later.
Rule 4's first-occurrence-clears behavior needs one bit of state,
rootsSupplied, and the code carries it as a local rather than inferring it from
the list's contents. Inferring would work until a user typed -P ., which is
indistinguishable from the default by value. The unit suite names that case.
Every violation produces the complete usage diagnostic Spec_TextFinder.md §5.2 binds -
a reason line, a newline, then UsageLine() - built by one
Diagnostic helper, so the shape has one definition. Six of the seven rows are
this library's. The seventh, a malformed /r, is detected later: the
Dirnav constructor builds the expression and lets the framework's parse failure
propagate, and the binary composes the diagnostic. Neither library composes it.
The diagnostic's embedded terminators are LF, since they are written into the string rather
than produced by a writer, and the binary writes the string with
Console.Error.Write, which appends nothing. §3.4 leaves the stderr
terminator to the platform and asks for no such thing; this implementation takes it because
it costs one method choice, and the unit suite asserts that no diagnostic carries a CR.
4. Extension-List Normalization
The /p argument arrives as one token, the shell having already removed the
quotes. Normalization implements the /p rules of Spec_TextFinder.md §5:
split on commas, trim each item, strip one leading . if present, discard empty
items, and preserve the order of the survivors. " .cs , , txt " therefore
normalizes to cs, txt.
§5 fixes which characters are trimmed, naming six of them, so this document chooses
none of them. They are held as a char[] and passed to
string.Trim(params char[]), which trims exactly the characters given.
private static readonly char[] Trimmed =
{ (char)0x20, (char)0x09, (char)0x0A, (char)0x0B, (char)0x0C, (char)0x0D };
The six are written as code points rather than as escapes, and the source says why: §5
names them that way, and three of the six - vertical tab, form feed, and horizontal tab in
this position - have no readable literal form. An earlier draft of this file had three of
them written as literal control characters, which makes a source depend on bytes no reader
can see; the
Process page records
the correction.
Two framework methods are rejected by name, each because it looks correct and implements a
different rule.
string.Trim() with no argument trims every character
char.IsWhiteSpace reports, a set that includes no-break space and the en
and em spaces, so this implementation would accept an extension list another rejects.
The unit suite asserts the boundary from the other side: a no-break space is
not trimmed, since §5 does not name it.
string.TrimStart('.') removes every leading dot, where
§5 removes one. ..cs must normalize to .cs and not to
cs. The dot is therefore stripped with an explicit length-one test, and the
suite asserts that case by name.
Duplicates are retained. They are harmless to the membership test
CSharp_TextFinder_Dirnav performs, and the specification says so rather than
leaving a reader to wonder whether the omission was an oversight. Case folding is not
applied here: the platform-dependent comparison §5 fixes is performed by
CSharp_TextFinder_Dirnav when it matches a file name against the list, because
that is where the platform question arises.
5. Help Text and Option Listing
Three methods render text the binary writes, and none of them writes it.
HelpText() returns the text Spec_TextFinder.md §5.1 fixes with
<executable> replaced by CSharp_TextFinder.
UsageLine() returns its first line - the line that terminates every usage
diagnostic - and HelpText() is built from it, so the synopsis has one
definition.
OptionsText(commands) returns the resolved option set in the form
§5.3 fixes.
The help body is a string[], one element per line, joined with
"\n". A raw string literal would read better in the source and is rejected for
a reason the specification states: a raw literal carries the line terminators of the file it
is written in, so an editor or a .gitattributes rule that rewrote this source
to CRLF would change what the program prints. §5.1 fixes the text and §3.4 fixes
the terminator, so neither may depend on how the file is stored.
That was a correction rather than a first draft. The specification called for a raw string
literal until writing the code showed that a raw literal takes no escapes, which left the
terminator guarantee with no mechanism. Joining an array makes the terminator a decision of
this library, and it gives the suite something to assert: the help text is 22 lines, the
count §5.1 fixes.
The listing's construction shows where the form is fixed and where it is not. The six
boolean lines come from one loop over an array of switch-and-value tuples in §5 table
order, so no line can be forgotten and none can be reordered without moving an array
element. The /P, /p, and /r lines are written out,
because each has a rule of its own: one line per root path, the extension list joined by
", " with /p alone when empty, and the expression verbatim.
bool.ToString() is rejected by name for one character: it returns
"True" and "False", which §5.3 forbids, and
ToString(CultureInfo.InvariantCulture) returns the same. The booleans render
through a conditional expression yielding the two literals §5.3 names, and those
literals appear once each in this library. The unit suite asserts that no listing contains
True.
One method serves all three cases §5.3 calls for, since the text is the same in each
and only the caller's next move differs: /v true, after which traversal
follows; the bare command line, after which the process exits 0; and the invalid-regex
diagnostic, where the listing precedes that diagnostic whatever /v says and the
process then exits 1. The listing reflects whatever commands holds, so its
/v line reads false in the latter two unless /v was
itself typed.
The C# specification adds one sentence about OptionsText that is worth more
than it looks: it chooses none of that form and must not be read as the place the form is
decided. A reader who wants to change the listing changes Spec_TextFinder.md §5.3.
All three methods end their returned string with LF, and none writes to a stream. The
library opens no stream at all, which is what lets its unit suite check the rendered text as
a string rather than by capturing output.
6. Source
ProgramCommands.cs and CommandLine.cs in full, 20 lines and 242.
The comments cite the C# specification, which cites Spec_TextFinder.md in turn.
src/ProgramCommands.cs
// ProgramCommands.cs - the resolved option set, per Spec_CSharp_TextFinder_Cmdline.md
namespace CSharp_TextFinder_Cmdline;
// §4: the property initializers are the sole authority in code for the defaults of
// Spec_TextFinder.md §5, so a newly constructed instance equals the result of parsing
// an empty argument array. The four false properties carry no initializer: default(bool)
// is already false, and writing it would suggest a choice where the others show one.
public sealed class ProgramCommands
{
public List<string> RootPaths { get; set; } = new() { "." }; // /P
public List<string> Extensions { get; set; } = new(); // /p
public string RegexText { get; set; } = "."; // /r
public bool Recurse { get; set; } = true; // /s
public bool SuppressOnNoMatch { get; set; } = true; // /h
public bool Verbose { get; set; } // /v
public bool Help { get; set; } // /H
public bool LineNumbers { get; set; } // /n
public bool MatchedLine { get; set; } // /L
}
src/CommandLine.cs
// CommandLine.cs - converts an argument array into ProgramCommands per Spec_CSharp_TextFinder_Cmdline.md
using System.Diagnostics.CodeAnalysis;
using System.Text;
namespace CSharp_TextFinder_Cmdline;
// §4: a static class, because C# has no free functions and all four members render or
// read the one option set.
public static class CommandLine
{
private const string Executable = "CSharp_TextFinder";
// §5 rule 1: the nine letters of Spec_TextFinder.md §5, in that table's order.
private const string SwitchLetters = "PprshvHnL";
// §7: the six characters Spec_TextFinder.md §5 names, and no others:
// space, horizontal tab, line feed, vertical tab, form feed, carriage return.
// Written as code points, since §5 names them that way and three of the six
// have no readable literal form.
private static readonly char[] Trimmed =
{ (char)0x20, (char)0x09, (char)0x0A, (char)0x0B, (char)0x0C, (char)0x0D };
// §8: the text of Spec_TextFinder.md §5.1 below its usage line, one element per line.
// Held as an array rather than a raw string literal so that the terminator is this
// library's decision and not a property of how this file happens to be stored.
private static readonly string[] HelpBody =
{
"",
" /P path (.) root path for traversal; repeat to add more root paths",
" /p \"ext, ext\" () comma-separated bare extensions to search; empty searches every file",
" /r regex (.) regular expression evaluated against each line",
" /s true|false (true) recurse into subdirectories",
" /h true|false (true) hide files that matched nothing; errors always appear",
" /v true|false (false) list the resolved option set before traversal",
" /H true|false (false) print this help and exit",
" /n true|false (false) add a detail line per match, carrying the line number",
" /L true|false (false) add a detail line per match, carrying the line text",
"",
"A matching file prints its path on one line; /n and /L add indented detail",
"lines beneath it. A path is never printed twice. A search ends with a line",
"counting the files and directories it reached.",
"",
"Switch introducers / and - are equivalent. Switch letters are case-sensitive,",
"so /h and /H differ. Every switch takes exactly one argument; there are no bare",
"flags. Arguments containing whitespace or commas must be quoted.",
"",
"Run with no switches at all to list the resolved options and exit without",
"searching.",
};
public static string UsageLine() =>
$"usage: {Executable} [/P path] [/p \"ext, ext\"] [/r regex] [/s bool] [/h bool] " +
"[/v bool] [/H bool] [/n bool] [/L bool]\n";
// §8: built from UsageLine, so the synopsis has one definition.
public static string HelpText() => UsageLine() + string.Join("\n", HelpBody) + "\n";
// §8: the form Spec_TextFinder.md §5.3 fixes. This method chooses none of it.
public static string OptionsText(ProgramCommands commands)
{
var text = new StringBuilder();
foreach (string root in commands.RootPaths)
{
text.Append("/P ").Append(root).Append('\n');
}
if (commands.Extensions.Count == 0)
{
text.Append("/p\n");
}
else
{
text.Append("/p ").Append(string.Join(", ", commands.Extensions)).Append('\n');
}
text.Append("/r ").Append(commands.RegexText).Append('\n');
// §8: bool.ToString returns "True" and "False", which §5.3 forbids.
foreach ((string letter, bool value) in new[]
{
("/s", commands.Recurse),
("/h", commands.SuppressOnNoMatch),
("/v", commands.Verbose),
("/H", commands.Help),
("/n", commands.LineNumbers),
("/L", commands.MatchedLine),
})
{
text.Append(letter).Append(' ').Append(value ? "true" : "false").Append('\n');
}
return text.ToString();
}
// §5: scans args from index 0, alternating switch token and argument token, stopping
// at the first violation with no partial result. Index 0 and not 1: Main's array holds
// the arguments alone, with no program name to skip.
public static bool TryParse(
string[] args,
[NotNullWhen(true)] out ProgramCommands? commands,
[NotNullWhen(false)] out string? diagnostic)
{
var parsed = new ProgramCommands();
bool rootsSupplied = false;
for (int index = 0; index < args.Length; ++index)
{
string token = args[index];
if (!SwitchLetter(token, out char letter))
{
commands = null;
diagnostic = Diagnostic(token.Length > 0 && (token[0] == '/' || token[0] == '-')
? $"unrecognized switch: {token}"
: $"not a switch: {token}");
return false;
}
if (index + 1 >= args.Length)
{
commands = null;
diagnostic = Diagnostic($"missing argument for switch: {token}");
return false;
}
string value = args[++index];
switch (letter)
{
case 'P':
if (value.Length == 0)
{
commands = null;
diagnostic = Diagnostic($"empty root path for switch: {token}");
return false;
}
if (!rootsSupplied)
{
// §5 rule 4: a local flag, since -P . cannot be told from the default by value.
parsed.RootPaths.Clear();
rootsSupplied = true;
}
parsed.RootPaths.Add(value);
break;
case 'p':
parsed.Extensions = NormalizeExtensions(value);
break;
case 'r':
if (value.Length == 0)
{
commands = null;
diagnostic = Diagnostic($"empty expression for switch: {token}");
return false;
}
parsed.RegexText = value;
break;
default:
if (!Boolean(value, out bool flag))
{
commands = null;
diagnostic = Diagnostic($"invalid boolean for {token}: {value}");
return false;
}
switch (letter)
{
case 's': parsed.Recurse = flag; break;
case 'h': parsed.SuppressOnNoMatch = flag; break;
case 'v': parsed.Verbose = flag; break;
case 'H': parsed.Help = flag; break;
case 'n': parsed.LineNumbers = flag; break;
default: parsed.MatchedLine = flag; break;
}
break;
}
}
commands = parsed;
diagnostic = null;
return true;
}
// §5 rule 1: exactly two characters, an introducer then one of the nine letters.
private static bool SwitchLetter(string token, out char letter)
{
letter = '\0';
if (token.Length != 2) return false;
if (token[0] != '/' && token[0] != '-') return false;
if (!SwitchLetters.Contains(token[1], StringComparison.Ordinal)) return false;
letter = token[1];
return true;
}
// §5 rule 3: true or false only. bool.TryParse trims and accepts more, so it is not used.
private static bool Boolean(string value, out bool flag)
{
if (string.Equals(value, "true", StringComparison.OrdinalIgnoreCase))
{
flag = true;
return true;
}
if (string.Equals(value, "false", StringComparison.OrdinalIgnoreCase))
{
flag = false;
return true;
}
flag = false;
return false;
}
// §6: a reason line, a newline, then the usage line.
private static string Diagnostic(string reason) => reason + "\n" + UsageLine();
// §7: split on commas, trim the six named characters, strip one leading dot,
// discard empties, keep order and duplicates.
private static List<string> NormalizeExtensions(string argument)
{
var items = new List<string>();
foreach (string part in argument.Split(','))
{
string item = part.Trim(Trimmed);
// §7: TrimStart('.') would strip every leading dot, where §5 strips one.
if (item.Length > 0 && item[0] == '.') item = item[1..];
if (item.Length > 0) items.Add(item);
}
return items;
}
}
The nested switch in TryParse is worth one note. The outer one
dispatches on the switch letter and handles /P, /p, and
/r directly, since each has a rule of its own; its
default arm converts the boolean once and then dispatches again to assign it.
That arrangement puts the boolean conversion and its diagnostic in one place for all six
boolean switches, where six separate arms would have repeated both.
7. Prompt Records
This page carries none. Page_Structure.md §8 assigns it
Prompts_Spec_CSharp_TextFinder_Cmdline.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 corrections that record holds reach this component, the help-body literal and the
three control characters in the trim set.