Spec-Driven

Spec-Driven C#: Entry

nine steps, a using statement, three exit codes, and no call to Environment.Exit

Synopsis:
This page covers CSharp_TextFinder_Entry, the binary. It wires the three libraries together and does no matching and no file I/O of its own.
  • It owns the skip list, the exit code, and - where both siblings own three lifetimes - the sink's disposal, which no collector can time.
  • 121 lines of code against a 112-line specification. Everything harder than wiring lives in a library.
Nine startup steps, two fewer than Rust needs, and the differences are all the runtime's doing.
  • There is no argument-decoding step at all, the runtime handing Main a decoded string[] - so this binary has nothing to decode and no failure to report.
  • Step 4 tests args.Length == 0 where the siblings test 1, since this vector does not carry the executable name.
  • The skip list is a List<string> in a static field, where Rust needs two constructs to reach the same place.
The flush is the one guarantee this language does not supply.
  • No destructor runs at scope exit and a finalizer is not a substitute, so the sink is held by a using statement and the guarantee lives in the caller.
  • The construction sits in its own try outside that statement, because a using declaration would put the one thing that can fail beyond the reach of a catch.
  • Every stderr write uses Console.Error.Write with an explicit \n, never WriteLine.

1.  What the Binary Owns

CSharp_TextFinder_Entry is the command-line entry point. It parses the program's arguments through CSharp_TextFinder_Cmdline, wires the three libraries together, and drives traversal. Matching and file I/O belong to CSharp_TextFinder_Dirnav and CSharp_TextFinder_Output; the binary performs neither. The binary does write process-level text of its own - help, the resolved option listing, and startup diagnostics - but it composes none of it. Each is a string CSharp_TextFinder_Cmdline hands it, and the binary decides only when to write it and to which stream. Block lines and announcements it neither composes nor writes. Three things it owns outright:
  • The skip list, and AddSkipDirectory as the build-time extension point Spec_TextFinder.md §3.5 defines. Section 3 covers it.
  • The lifetime of the sink. Not of all three objects, which is where this binary differs from both siblings: the garbage collector keeps the skip list and the parsed commands alive as long as the Dirnav holds them. What the binary owns is the sink's disposal, which no collector can time. Section 5 covers it.
  • The exit code. Section 4 covers the three values and how they map onto this binary's failure modes.
It is 121 lines, and the specification that fixes it is 112. That ratio is the point of the component: the design goal is to make the wiring itself readable, and everything harder than wiring lives in a library.

2.  The Startup Sequence

static int Main(string[] args) performs nine steps in order. The order is the specification rather than a consequence of it: three of the nine are placed where they are for reasons that would not survive rearranging.
Step What happens
1 Invoke CommandLine.TryParse. On false, write the returned diagnostic to stderr unaltered and return 1. A malformed /r is not detected here; step 6 reaches it
2 Construct the StdoutSink. On InvalidOperationException or IOException, write cannot initialize output to stderr and return 2
3 If /H true, write HelpText() through the sink with WriteText and return 0
4 If args is empty, write OptionsText(commands) through the sink and return 0. This is the bare command line of §3.1
5 If /v true, write OptionsText(commands) through the sink before traversal begins
6 Finalize the skip list, then construct Dirnav<StdoutSink>. Expression construction happens here, and the constructor throws RegexParseException when /r will not compile
7 For each root path, in the order /P gave them, invoke Search on the same instance
8 Call EmitRunSummary() on that same instance, once, after the last root path returns
9 Return 0
Step 8 is the whole of this binary's part in the run summary. Spec_TextFinder.md §3.6 puts the two counts in CSharp_TextFinder_Dirnav, so the binary supplies no count and composes no text. What it supplies is the one fact the library cannot have: that step 7 is finished. Every return above precedes step 7, which is what makes the summary a mark of a run that traversed. /H, the bare command line, a parse failure, and a malformed /r each leave at their own step and none reaches step 8 - so none writes a summary, which is exactly what §3.6 asks. The integration suite asserts the absence for all three of the cases a test can reach. There is no argument-decoding step. The Rust binary opens with one, and reserves an exit code for its failure; C++ takes char* argv[] and states a limit on what it can carry. The runtime decodes the command line before Main runs and hands it a string[], so this binary has nothing to decode and no failure to report. Spec_TextFinder.md §4 leaves the argument vector to this document, and an absent step is what it says here. Step 2 precedes every write to stdout, because the sink owns the process's only writer over it. The binary's own help text and option listing pass through that object rather than through a writer of their own, so they share one buffer with the search output and reach the stream in the order written. C++ needs this ordering for a second reason as well, its sink also setting the stream mode; here the shared buffer is the whole of it. Step 4 tests args.Length == 0, where C++ tests argc == 1 and Rust tests a length of 1. Both of those vectors carry the executable name and this one does not, so the same rule of §3.1 - a command line bearing no switch at all - is a different number in each implementation. The binary reads the array's length for this test and inspects no element of it. Steps 4 and 5 are mutually exclusive: a command line bearing /v is not empty. Step 6 is where a bad pattern is refused, and it takes four sub-steps to report:
  1. If step 5 did not already write it, write OptionsText(commands) through the sink now, so the user sees the /r line carrying the expression that failed.
  2. Call Flush on the sink, so the listing reaches the stream ahead of the diagnostic that explains it. The sink buffers stdout without per-line flushing, so without this the two arrive out of order.
  3. Compose the diagnostic - invalid regex for switch: /r, a newline, then UsageLine() - and write it to stderr. The binary composes it; neither library does, the one supplying only UsageLine() and the other only the failure that triggers it.
  4. Return 1, having traversed nothing.
The exception is caught as RegexParseException and not as ArgumentException, which it derives from. A broader catch would also swallow an argument defect in this binary's own call and report it to the user as a malformed pattern they typed. The message the framework puts in that exception is not written, and the specification gives a reason that outlives any wording change: that text belongs to the framework, so a runtime upgrade would alter this program's output with no document in this tree recording the change. What the user gets instead is the /r line, carrying the expression as typed. Step 7 passes a root-path failure to nobody. A root that cannot be searched - unopenable, a symbolic link, or neither a regular file nor a directory - is announced by CSharp_TextFinder_Dirnav itself, and the binary neither formats nor inspects that notice. No root-path outcome affects the exit code.

3.  The Skip List and AddSkipDirectory

The binary owns the process-wide skip list, initialized with the 11 defaults of Spec_TextFinder.md §3.2, and implements the extension point as
private static readonly List<string> SkipList = new() { /* the eleven defaults */ };

private static void AddSkipDirectory(string name);
§3.5 writes the function as addSkipDirectory(name) and requires each language's specification to fix the exact name, parameter type, and return type in its own idiom. Here that is PascalCase, a string, and void. Neither member is public, and both are declared in the binary's own class, so no library and no test can call them; the calls that extend the list are written into the binary's source and compiled with it. A List<string> in a static field needs nothing else. That is worth stating because the Rust implementation needs two constructs to reach the same place: a RefCell for interior mutability without unsafe, and a thread_local! to escape the Sync bound a plain static would impose. C# places no such bound on a static field, so §3.5's one-argument signature costs this implementation nothing to honor. AddSkipDirectory ignores a name the list already holds, and it compares with the same platform rule CSharp_TextFinder_Dirnav applies to a skip-list entry. A build that adds Build on Windows therefore does not lengthen a list that already holds build, which is the duplicate-handling §3.5 asks for read against the case-insensitivity §3.2 fixes. The list reaches the Dirnav at step 6 as an IReadOnlyList<string>, which that library consults and cannot modify through. That is weaker than what Rust gets from its type system, where a shared borrow makes modification impossible rather than inconvenient: a caller holding the underlying List<string> could still change it mid-traversal. Nothing here does, and the specification fixes that the binary stops extending the list before traversal begins. The source carries ExtendSkipList with an empty body, which is where such calls go. AddSkipDirectory therefore has no caller until a build adds one, and the comment above it says so rather than leaving a reader to wonder whether the method is dead.

4.  Exit Codes

The three codes Spec_TextFinder.md §3.4 fixes map onto this binary's failure modes as follows. No other value is returned from Main.
Code Cause in this binary What reaches which stream
0 Startup and traversal completed; or /H; or the bare command line Search output, help, or the option listing on stdout. Match count and unopenable roots do not change it
1 Parse failure, or a malformed /r A usage diagnostic on stderr. Parse failure leaves stdout empty; a malformed /r puts the option listing there first
2 Constructing the StdoutSink failed cannot initialize output on stderr, no usage line, stdout empty
Only one exit-code-1 path writes to stdout, and the asymmetry is deliberate: §5.2 requires the listing ahead of the invalid-regex diagnostic so the /r line shows what failed, and leaves stdout empty for every other violation. Every one of those strings is fixed in the C# specifications rather than the project specification. §2 leaves the wording of anything reaching stderr to each language, so Spec_CSharp_TextFinder_Cmdline.md §6 carries the six reason lines TryParse produces and Spec_CSharp_TextFinder_Entry.md §6 carries the seventh, invalid regex for switch: /r, plus cannot initialize output. What binds everyone is the shape of a usage diagnostic, the destination, the exit codes, and what each failure leaves on stdout. C# adopts §5.2's supplied wording without changing a character, so its stderr stays comparable with both siblings', which §6 no longer requires but does not forbid. Code 2 has one cause here where Rust has two. Rust reserves it for an undecodable argument as well, and this binary cannot meet that failure at all. What remains is the sink's constructor, which throws when a sink already exists or when standard output cannot be opened. This binary constructs exactly one sink, so its own code cannot provoke the first; it handles the case because a constructor with a failure mode callers ignore is a constructor whose failure mode will eventually be reached. A malformed regex taking code 1 is a decision rather than a deduction: the expression is something the user typed, so the failure is a usage failure even though it surfaces inside a library constructor.

5.  Why Every Exit Is a Return

Every exit in the sequence returns from Main, never calling Environment.Exit. The sink is held by a using statement, and only a return leaves that statement and disposes the sink, which flushes its stdout buffer. Steps 3, 4, and 6 each write to stdout and then leave, and Environment.Exit would terminate the process with the buffer unwritten. Main returns int, so a numeric status and a normal return are the same act. That much C# shares with C++; what it does not share is a destructor that runs at scope exit. The C++ binary gets its flush from the sink's destructor and the Rust binary from Drop, both of which the language runs for them. Here the flush happens because this binary wrote a using statement, and a finalizer would not do: the runtime is free never to run one before the process ends. That shifts the guarantee from the sink to its caller, and the specifications say so on both sides. Spec_CSharp_TextFinder_Output.md §7 states the requirement and notes that the type cannot enforce it alone; §4 of this document discharges it. The construction of the sink sits in its own try, outside the using statement that follows. The obvious code does not work, and the reason is worth one paragraph because a reader may reach for it:
// this cannot be written, since the catch cannot reach the construction
using var sink = new StdoutSink();   // throws on the failure that is exit code 2
A using declaration puts the construction inside the scope it introduces, and what can fail here is exactly the construction. Splitting the two - a try around new StdoutSink(), then using (sink) { ... } - is what lets the failure be handled and the disposal still be guaranteed. The C++ binary uses std::optional and a try around emplace for the same reason, which is the one place these two implementations solve a problem the same way for the same cause. One rule pairs with all of it: nothing writes to stderr before stdout has been flushed. Step 1 predates the sink and so has no buffer to flush; step 2 fails before one exists; step 6 flushes explicitly. CSharp_TextFinder_Output applies the same rule to its own output failed notice. Every stderr write in this binary uses Console.Error.Write with an explicit \n rather than Console.Error.WriteLine, which appends Environment.NewLine and would put CRLF there on Windows. §3.4 leaves the stderr terminator to the platform and would permit that; writing LF costs one method choice and makes this implementation's stderr identical on both platforms, so the specification fixes it here. C++ leaves the same half of the problem open by design, its std::cerr never being set to binary.

6.  Stated Limits

Three non-goals, and the second is the one worth reading: it is a limit stated in order to record its absence.
  • No per-file state in the binary. A single Dirnav<StdoutSink> is reused across every root path, and it carries no state from one Search call to the next.
  • No encoding limit on arguments. A string is UTF-16 and the runtime decodes the command line before Main runs, so every command line the shell can express reaches the parser. The undecodable-argument case the Rust implementation defines cannot arise here, and this implementation accepts root paths and expressions the C++ implementation cannot carry through its narrow argv.
  • No configuration file, and no runtime means of extending the skip list.
§6's consistency guarantee speaks only to the command lines every implementation's argument vector can carry, so the second limit narrows what a cross-implementation comparison covers rather than what this program accepts. Two of the three implementations now accept more than the third.

7.  Source

Program.cs in full. The step numbers in the comments are the nine steps of Section 2, and the citations name the specification section each step satisfies.
Program.cs
// Program.cs - CSharp_TextFinder entry point, per Spec_CSharp_TextFinder_Entry.md

using System.Text.RegularExpressions;
using CSharp_TextFinder_Cmdline;
using CSharp_TextFinder_Dirnav;
using CSharp_TextFinder_Output;

namespace CSharp_TextFinder_Entry;

internal static class Program
{
    // §5: the defaults of Spec_TextFinder.md §3.2, owned by the binary. A List needs no
    // interior-mutability type and no lock to be extended from a static method, so the
    // RefCell and thread_local pair the Rust implementation requires has no counterpart.
    private static readonly List<string> SkipList = new()
    {
        "archive", ".git", ".svn", ".hg", "build", "out",
        "target", "bin", "obj", "__pycache__", "node_modules",
    };

    private static readonly StringComparison NameComparison =
        OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;

    // §5: the build-time extension point of Spec_TextFinder.md §3.5. Private and declared
    // here, so no library and no test can call it; calls are compiled in alongside it.
    private static void AddSkipDirectory(string name)
    {
        foreach (string held in SkipList)
        {
            if (string.Equals(held, name, NameComparison)) return;
        }

        SkipList.Add(name);
    }

    // §5: every call compiled in here runs before traversal begins. None is at present,
    // so AddSkipDirectory above has no caller until a build adds one.
    private static void ExtendSkipList()
    {
    }

    // §4: the startup sequence, in order. Every exit below returns from Main, never
    // Environment.Exit, so the using statement disposes the sink on every path out.
    private static int Main(string[] args)
    {
        // Step 1. No decoding step precedes this one: the runtime hands Main a string[].
        if (!CommandLine.TryParse(args, out ProgramCommands? commands, out string? diagnostic))
        {
            Console.Error.Write(diagnostic);
            return 1;
        }

        // Step 2. Before anything reaches stdout, since the sink owns the only writer
        // over it. The construction sits outside the using statement so that a catch
        // can reach it and the disposal is still guaranteed.
        StdoutSink sink;
        try
        {
            sink = new StdoutSink();
        }
        catch (Exception e) when (e is InvalidOperationException or IOException)
        {
            Console.Error.Write("cannot initialize output\n");
            return 2;
        }

        using (sink)
        {
            // Step 3.
            if (commands.Help)
            {
                sink.WriteText(CommandLine.HelpText());
                return 0;
            }

            // Step 4. Spec_TextFinder.md §3.1: a command line bearing no switch at all
            // names no work. args holds the arguments alone, so the test is a length of
            // 0 where C++ tests argc == 1. No element of args is inspected.
            if (args.Length == 0)
            {
                sink.WriteText(CommandLine.OptionsText(commands));
                return 0;
            }

            // Step 5. Mutually exclusive with step 4: a command line bearing /v is not empty.
            if (commands.Verbose) sink.WriteText(CommandLine.OptionsText(commands));

            // Step 6.
            ExtendSkipList();

            Dirnav<StdoutSink> navigator;
            try
            {
                navigator = new Dirnav<StdoutSink>(sink, SkipList, commands);
            }
            catch (RegexParseException)
            {
                // Spec_TextFinder.md §5.2: the listing goes to stdout whatever /v says, so
                // the /r line shows the expression that failed, and is not repeated when
                // /v produced it. The framework's own message is not written.
                if (!commands.Verbose) sink.WriteText(CommandLine.OptionsText(commands));
                sink.Flush();   // ahead of stderr, since the sink defers its writes
                Console.Error.Write("invalid regex for switch: /r\n" + CommandLine.UsageLine());
                return 1;
            }

            // Step 7. One reused instance, one root path at a time, in the order /P gave
            // them. Every root-path failure is announced by Dirnav and affects nothing here.
            foreach (string root in commands.RootPaths)
            {
                navigator.Search(root);
            }

            // Step 8. Only Main knows the last root path has returned.
            navigator.EmitRunSummary();

            // Step 9.
            return 0;
        }
    }
}
Two properties of the project file carry decisions rather than defaults, and the Structure page shows both: AssemblyName separates the executable's name from the project's, and InvariantGlobalization makes a culture-sensitive comparison added later fail visibly rather than behave differently on two machines.

8.  Prompt Records

This page carries none. Page_Structure.md §8 assigns it Prompts_Spec_CSharp_TextFinder_Entry.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 that record's four delegated decisions reach this component, the argument vector and the skip-list signature.