Spec-Driven

Spec-Driven C#: Cmdline

an argument array to ProgramCommands, through a static class and a Try method

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.
  1. 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.
  2. 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.
  3. 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.
  4. 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.