Skip to content

Let the engine return a status report and the CLI own the process #742

Closed

Description

@mathiasrw

Today

The engine and the CLI both act on the process:

  • kill() in src/output.ts calls process.exit(). It is used by die() (like a regex that is not valid, an unknown encoding or no data for --replacement-pipe) and by error() with --halt.
  • The engine resolves with nothing useful, so the exit code for errors (Exit code is 0 when a file gives an error #728) has to be set from inside the engine or from global state.
  • The JS tests have to mock process.exit to see that a run was stopped.

Change

One rule: the engine never touches the process. The CLI is the only part that does. The engine takes a config and gives back a status. The CLI reads argv and stdin, and turns the status into an exit code.

1. The engine always resolves with a status

{
	"files": {"searched": ["a.md", "b.md", "c.md"], "updated": ["a.md"]},
	"pipe": null,
	"warnings": [],
	"errors": ["Error: EACCES: permission denied, open 'c.md'"]
}
  • files.searched: each file is added when the search in it starts. So the order is the order the files were started in, and after a stop it shows how far the run got.
  • files.updated: each file is added when its content was changed by the search and replace. With --output and --output-match it is the files where the content would change. These are the same files as the names that are printed.
  • pipe: null when nothing is piped in. Else {"bytes": 1234, "type": "CONTENT", "updated": true}:
    • bytes: the size of the piped data that the engine got (after --trim-pipe).
    • type: how the piped data was used: CONTENT (the text to search), REPLACEMENT (--replacement-pipe), JS (pipe in --replacement-js, also with --replacement-pipe --replacement-js) or UNUSED (read with --pipe, but not used).
    • updated: true if the piped data was the content and was changed. Always false for the other types.
  • warnings and errors: the same text as is printed.
  • It is all collected as the run goes, also with --quiet and --quiet-total. They only stop the printing.

The status is config.statusInfo. It is made at the start of the engine, so src/output.ts (warn(), error(), die()) and the engine add to the same object, and the engine resolves with it. It is not config.status, as that is the --status flag.

The CLI sets the exit code to 1 if status.errors.length.

2. A panic is thrown, not an exit

  • die() and --halt throw a panic: a small error type of our own with the message.
  • The engine catches it at the top, adds the status so far as panic.status, and throws it on.
  • kill() is removed, so there is no process.exit() in the engine or in src/output.ts.
  • The --nl line break at the end is done in a finally in the engine, so it comes after a normal end and after a panic.

3. The CLI acts on the status and on a panic

rexreplace.engine(config).then(
	(status) => {
		if (status.errors.length) {
			process.exitCode = 1;
		}
	},
	(e) => {
		if (!e.panic) {
			throw e;
		}
		process.exit(1);
	}
);
  • A normal end sets process.exitCode, so the process ends by itself when all output is written.
  • A panic uses process.exit(1), so files that are still in work in the async flow are not handled further. This is what --halt means.
  • Anything thrown that is not a panic is a bug, so it is thrown on with the stack trace.

4. --status and --status-compact

At the end of the run the CLI prints the status report to stdout, as JSON. Also after a panic, from panic.status.

  • --status: a line break, and then the JSON formatted with an indent of two spaces, to be read by a person.
  • --status-compact (-S): the same JSON on one line, with no line break in front, so it is easy to pipe on (like to jq). The short flag is on the compact one, so the compact one is also the short one to type.

It is a report of what the run did, not output along the way:

Flag What it is for
--verbose Tell what happens during the run
--status Print the outcome of the run
--debug Print only the status report, with what went into the engine and what it did

--debug

--debug (-d) prints only the status report, with what went into the engine and what it did. Nothing else is printed, but the result of --output and --output-match before it.

The debug output along the way is removed: debug(), step() and its calls (some of them for each match, on the hot path) and the raw error with a stack trace for a pattern that is not valid. What the run did is in the status report. The three lines that were only shown with --debug (Work on content from, Output result from, Write new content to) are now told by --verbose.

With --verbose (on --status or --status-compact) the status report also has engine, with all that went into the engine and what it did. Without --verbose it is left out:

"engine": {
	"version": "8.0.0",
	"runtime": "Node V8",
	"config": {},
	"execution": {"search": "fo+", "flags": "gimu", "replacement": "bar", "replacementSrc": null},
	"steps": ["2 files found", "Open async: a.md", "Open async: b.md", "Work on content from: a.md", "Write new content to: a.md", "Work on content from: b.md", "Nothing changed in: b.md"]
}
  • config: a copy of the config as it came into the engine. It is the first thing the engine does, before statusInfo is added to the config, so the copy does not hold itself. JSON.parse(JSON.stringify(config)) leaves out the functions. pipedData is left out, as pipe.bytes tells about it and the text can be very big.
  • execution: what the regex engine was asked to do.
    • search: the final regex as regex.source, and flags: the flags that were used (regex.flags).
    • replacement: the final replacement string, after the € and § aliases and the escape sequences. With --replacement-pipe it is the piped text. null with --replacement-js and --output-match, as a function is used.
    • replacementSrc: the JavaScript that ran with --replacement-js. null in all other cases.
  • steps: what happened during the run, in order. They are the same lines as the chatter of --verbose, also when they are not printed. In the async flow they show the order the files were opened and handled in. Nothing is added for each match, so the hot path is not touched.

The names (and the result of --output and --output-match) are still printed before the JSON. Use --quiet-total to hide the names and the messages. Unless --output or --output-match is used, stdout then only has the status report.

5. --verbose is not hidden by --quiet, but by --quiet-total

--verbose prints what was asked for, so --quiet does not hide it. --quiet still hides the names and the warnings. --quiet-total wins: it hides everything but the result, also the chatter of --verbose. Then --quiet-total --verbose --status gives only the status report (with engine) on stdout, and nothing on stderr.

6. How the flags work together

Flags stdout stderr
(none) The result: the names, or the content with --output and --output-match Warnings and errors, and the names with --output and --output-match
--verbose The result Also the chatter: what happens to each file
--status The result, then a line break and the status report as JSON with an indent of two spaces As without the flag
--status-compact (-S) The result, then the status report as JSON on one line As without the flag
--status --verbose As --status, and the status report also has engine Also the chatter
--quiet The result, but not the names Errors only
--quiet --verbose The result, but not the names Errors and the chatter
--quiet-total The result, but not the names Nothing, also with --verbose
--debug Only the status report, with engine (and the content with --output and --output-match) Nothing
--quiet-total --status Only the status report (and the content with --output and --output-match) Nothing
--quiet-total --verbose --status Only the status report, with engine Nothing

The warnings and errors are always in the status report, also when the quiet flags hide them.

7. Tests

  • rr() in test/js/helper.js gets the status from the engine, and catches a panic in place of mocking process.exit.
  • Tests can look at status.files and status.errors in place of searching stderr.

To decide

  • If --status is its own PR after 1-3 and 7.

Good to know

  • process.exit() can drop writes to stdout that are not done yet when stdout is a pipe (on macOS writes to a pipe are async). That is why a normal end only sets process.exitCode.
  • The tests must not leave anything on process: a process.exitCode that is left set by a test becomes the exit code of bun test, even when all tests pass.
  • The number of replacements is not in the status. Counting them would cost on the hot path for every run.
  • --verbose prints to stderr, so --status can be used with it and the status report on stdout stays apart.

Reference

Activity

  1. changed the title [-]Let the engine return a status and the CLI own the process[/-] [+]Let the engine return a status report and the CLI own the process[/+] on Oct 22, 2026
  2. added 2 commits that reference this issue on Oct 22, 2026
    7c726df
    447c22c
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions