Spec-Driven

Spec-Driven Behavior: Matching and Output

one compilation, three terminators, four block forms, and the case that reads nothing

Synopsis:
This page covers what happens once a file is admitted - how it is split, how it is matched, and what the block that results looks like.
  • The expression compiles once per invocation, before traversal, so a malformed pattern is refused before any file is opened.
  • A line is bounded by LF, CRLF, or bare CR, and an unterminated final line is a line. Splitting happens before matching, which is why ., ^, and $ agree across the four engines.
  • A match is one occurrence anywhere in the line. TextFinder asks the engine one question and uses one bit of the answer, so greediness and capture groups are unobservable.
/n and /L give four block forms, and one of the four is different in kind.
  • With both false the path line says everything, so evaluation of a file stops at its first match.
  • Add the default expression and no file content is needed at all - every selected non-empty file is reported without being opened.
  • The specification names the two costs it accepts for that speed rather than claiming there are none.
Two rules bind the implementation rather than describing the platform.
  • Every line ends in a single LF, which three of the four target runtimes translate by default or by idiom.
  • Emission is incremental - nothing is buffered to the end of a file, a directory, or the run.

1.  One Compilation per Invocation

Each implementation compiles the /r expression exactly once per invocation and reuses the compiled engine for every line of every file across every root path (§3.3). A build that compiled per file would do the same work once per candidate rather than once, and the requirement exists to rule that out. The rule has a structural consequence every implementation shares: compilation happens before traversal begins, so a malformed expression is refused before any file is opened. §5.2 gives that its own diagnostic, invalid regex for switch: /r, and exit code 1. A run with a bad pattern reads no files at all. §6.1 names the engine per language rather than leaving it to the implementation - std::regex with std::regex::ECMAScript for C++, the regex crate for Rust, System.Text.RegularExpressions.Regex for C#, and the re module for Python. What those four agree on, and what a pattern outside the agreement costs, is on the Contracts page. The expression must not be empty. The default . is the way to match every line, and /r "" draws empty expression for switch: /r rather than matching everything.

2.  Lines and Their Terminators

A line is a maximal run of characters bounded by a line terminator. Three terminators are recognized, which covers the Windows, Linux, and macOS conventions:
Terminator Bytes Convention
LF U+000A Linux, macOS
CRLF U+000D U+000A Windows
bare CR U+000D classic Mac OS
If the final line of a file lacks a terminator, it is treated as a line all the same. Line numbers count every line, including the ones that do not match, so a number reported under /n is the number an editor shows. Splitting happens before matching, and that ordering does more work than it appears to. §6.1 leans on it: ., ^, and $ agree across the four engines because a line holds no terminator by the time the engine sees it, so the engines' differing treatment of a terminator inside the subject never arises.

3.  The Anywhere-in-the-Line Match

The expression signifies a match by finding at least one occurrence anywhere within the line. It is not anchored, and a pattern that should be anchored says so with ^ or $. TextFinder asks the engine one question and uses one bit of the answer. Under /L it reports the whole line, never a match position, a matched substring, or a capture group. Greediness, alternation preference, and capture-group numbering are therefore unobservable, which is the ground §6.1 stands on when it claims the four engines differ on nothing TextFinder observes so long as the pattern is one they all accept. One consequence shows up in the output. A line holding three occurrences of the pattern produces one detail line, not three: §3.4 says one detail line is emitted for every matching line, however many occurrences that line holds.

4.  Block Forms and Their Gating

/n and /L select the fields a detail line carries, and their four combinations give four block forms. Both default to false.
/n /L Detail line carries Detail lines per block
false false nothing - the block has none 0; the path line says everything
true false the line number, after its indent one per matching line
false true the line text, after its indent one per matching line
true true number,  - , text one per matching line
The three-character separator  -  appears only when both fields are present. Each detail line is indented exactly two spaces, and the indent is fixed in §3.4 rather than left to the implementation, because a comparison of two runs would otherwise diff on whitespace. The first row is the interesting one. With no detail lines, a block's path line says everything the block can say, so one block is emitted per matching file and evaluation of that file stops at its first match. A 4,000-line file whose second line matches costs two lines of reading. Emission is incremental. A block's path line is written the moment that file's first match is found, ahead of the detail line for that same match, and each further detail line as its own line is evaluated. Nothing is buffered to the end of a file, a directory, or the run. Announcements go out the same way, at the point TextFinder deals with the entry they name, so none waits on the directory holding it to finish. The gating interacts with /h exactly once. A file that matched is never announced, its block already naming it, so a file announcement reports only a file that produced no block. Under the default /h true the output holds blocks and error announcements; under /h false every examined file appears exactly once, as either a block or an announcement.

5.  The No-Content Case and Its Two Costs

One state needs no file content at all. When the expression is the default . and /n and /L are both false - the state any command line reaches that overrides none of the three - a block carries only its path line and a single match settles it. TextFinder reports every selected file of non-zero size without opening it. The size test still applies, since it reads only filesystem metadata, but the NUL and UTF-8 tests do not. No file is opened, so no file announcement arises: not searched, which reports a file that was read and matched nothing, and not skipped, which reports one a content test rejected. Every selected file matches, so every one produces a block instead. A selected file of zero size produces neither a block nor an announcement, having no line to match. §3.3 names the two costs it accepts for the speed this buys:
  1. A file the content tests would have rejected, a binary file among them, is reported.
  2. So is a file whose lines are all empty, which . would not in fact have matched.
Both are visible in a demonstration run. Invocation 2 of the C++ demonstration uses the default expression and reports 46 lines against the project tree, one per selected non-empty file, and it opens none of them. The error announcements still work in this case, because they rest on metadata too: a file above the size limit draws too large, and one whose metadata cannot be read draws cannot open.

6.  The Line That Closes a Run

A run that traversed writes one more line after its last block or announcement, and §3.6 fixes its text:
accessed <fileCount> files, <dirCount> directories
It counts what the run reached rather than what it found, so it is not a record of matching and nothing in §3.4 governs it. It names no path, which is why none of this page's rendering and gating rules reach it: /h leaves it in place, no switch suppresses it, and the two-level block form does not apply to a line that belongs to no file. The Overview page states what each count includes. Two of this page's cases meet it in a way worth naming. The no-content case of §5 opens no file, yet every file it selects is counted, so a run under the default expression reports a file count equal to the number of selected files whose size it read - including the zero-length ones that produced no block at all. And a run whose expression matched nothing still writes the line, so an empty result is now distinguishable from a search that never reached a file: the first ends accessed 40 files, 7 directories, the second accessed 0 files, 0 directories. The counts move with the tree, which is why every demonstration capture in this project states the date it was taken.

7.  The LF Terminator

Every line of a block, every announcement, and the run summary of §3.6 is terminated by a single LF on every platform. An implementation must prevent its runtime from translating that terminator to CRLF, or the same tree would yield byte-different output on Windows and POSIX and the comparison of §6 would hold only within a platform. The rule binds the implementation rather than describing the platform, and that phrasing is deliberate: three of the four target runtimes translate by default or by idiom. C#'s Console.WriteLine uses Environment.NewLine; the C runtime behind C++'s std::cout translates on Windows unless the descriptor is put into binary mode. The rule lived in the C++ output specification for several turns, where it bound one language, and was moved to the parent for exactly the C# reason. The rule governs stdout alone. What terminator an implementation writes to stderr is whatever its runtime and platform produce, and the specification does not fix it. The reasoning is stated: comparing one implementation against another means comparing stdout, a run that writes to stderr has written a usage diagnostic and traversed nothing, so it has no search output to compare, and holding its stderr to the byte would test the platform rather than the implementation. One boundary case follows from the binary-mode requirement and caught the C++ implementation. Text the binary writes itself - help and the option listing - reaches the same stdout, so it must be written after the output component has configured the stream, not before. The C++ Entry specification states that as a step ordering with the reason attached, and the integration suite tests it: "help text carries LF only, being written after the sink configures stdout."