Create your own
Lesson illustration

Parsing Quoted REPL Commands into Typed Arguments and Options

Hello. This module turns the application use cases developed in the earlier architectural work into a usable terminal interface. We will begin at the narrowest, most important boundary: converting one raw line typed into a REPL into a safe, typed command envelope.

For this tool, a line such as:

probe-add "Fixture Math" --module=fixture.dll --export="Add Numbers" --once

must become data that later layers can use without knowing anything about quotes, whitespace, or terminal syntax. By the end of this lesson, you will have a small parser that preserves quoted Windows paths, distinguishes positional arguments from options, rejects ambiguous shell syntax, and returns typed failures rather than throwing.


Treat the REPL as its own language

A REPL reads a raw string from readline. Unlike a one-shot CLI, Node has not already separated that input into argv tokens. Your program must therefore define how spaces, quotes, escapes, flags, and errors work.

It is tempting to pass the line to a system shell, or to reuse a shell-oriented parser. That would be a poor boundary for this application:

  • Shell grammars differ between PowerShell, cmd.exe, and POSIX shells.
  • Shell features such as redirection, pipes, variable expansion, and command substitution are outside the scope of an instrumentation REPL.
  • Executing through a shell adds avoidable ambiguity and security risk.
  • The REPL should have predictable behavior regardless of which terminal hosts it.

The command language should be intentionally small. For the first usable version, adopt these rules:

Input formMeaning
attachCommand name
attach 8420Command with one positional argument
attach --pid=8420Command with a valued long option
record-start --file="C:\captures\run one.ndjson"Quoted option value
probe-add --onceBoolean option with value true
probe-add -- --not-an-option-- ends option parsing; remaining tokens are positional
list | findstr chromeRejected: this REPL does not implement shell pipes

A useful convention is:

  • Command names use lower-case kebab case, such as record-start.
  • Options are long options only, such as --file or --include-system.
  • A bare option means true.
  • An option with a string value uses --name=value.

Requiring = for a valued option is deliberate. Without command-specific knowledge, --verbose 8420 is ambiguous: is 8420 the value of verbose, or a positional PID? A generic parser should not guess. Later, command definitions can give richer meaning to the raw command envelope.


Why not use a POSIX shell parser?

The shell-quote package is useful for understanding the distinction between tokenization and shell interpretation. It correctly demonstrates that quoted text can form one argument, but its semantics are deliberately based on a POSIX shell rather than Windows terminal behavior.

shell-quote

Read the shell-quote documentation to see how quoted strings become tokens and, more importantly, why POSIX shell behavior is not an appropriate execution model for a Windows-focused REPL.

In the parse section, inspect the example beginning with a "b c" and note that quoted text is preserved as one argument. Then read parsing shell operators, where tokens such as || and > become special objects rather than ordinary arguments. Finally, in the quote(args) section, read the Windows warning. Our REPL will deliberately support neither shell operators nor environment-variable interpolation.

The key design decision is not “write a shell parser.” It is “define a compact grammar owned by the application.”

That grammar should be documented in your help output later, tested as a public interface, and changed cautiously. Once users record command histories or write automation around your tool, REPL syntax becomes part of its compatibility surface.


Define the typed boundary contract

The parser belongs in the terminal-interface layer, not in the domain layer. The domain should care about concepts such as instrumentation sessions and probes; it should not care whether a user wrote single quotes or double quotes around an export name.

A suitable location is:

src/
  interfaces/
    terminal/
      repl/
        parse-repl-line.ts

The parser’s output is a delivery-layer command envelope. It is typed, immutable, and free of Frida, Node terminal, or Windows API objects.

// src/interfaces/terminal/repl/parse-repl-line.ts

export type CommandName = string & {
  readonly __brand: "CommandName";
};

export type OptionName = string & {
  readonly __brand: "OptionName";
};

export type OptionValue = string | true;

export type ReplCommand = Readonly<{
  name: CommandName;
  args: readonly string[];
  options: Readonly<Record<OptionName, OptionValue>>;
}>;

export type ParsedReplLine =
  | Readonly<{
      kind: "empty";
    }>
  | Readonly<{
      kind: "command";
      command: ReplCommand;
    }>;

export type ReplParseError =
  | Readonly<{
      kind: "unterminated-quote";
      quote: "'" | '"';
      index: number;
    }>
  | Readonly<{
      kind: "invalid-command-name";
      value: string;
    }>
  | Readonly<{
      kind: "invalid-option-name";
      value: string;
    }>
  | Readonly<{
      kind: "duplicate-option";
      name: string;
    }>
  | Readonly<{
      kind: "unsupported-shell-syntax";
      token: string;
    }>;

export type Result<T, E> =
  | Readonly<{
      ok: true;
      value: T;
    }>
  | Readonly<{
      ok: false;
      error: E;
    }>;

Several choices here matter:

  1. CommandName and OptionName are branded strings. A random string cannot be passed as a validated command name without an explicit cast.
  2. OptionValue is intentionally narrow: either a string or the literal true.
  3. ParsedReplLine distinguishes an empty line from a command. Pressing Enter should usually redraw the prompt, not display an error.
  4. Result<T, E> represents expected malformed input explicitly. A missing closing quote is user input, not an exceptional runtime failure.
  5. Every collection is exposed as readonly, preventing a handler from quietly mutating the user’s original command.

The parser does structural parsing, not command semantics. For example, it can recognize that --pid=8420 is an option named pid with raw value "8420", but it should not decide whether 8420 is a valid process identifier. That validation belongs in the attach command’s application handler.


Tokenize before interpreting options

Parsing is easier to reason about when split into two phases:

  1. Tokenization converts raw text into words while handling quotes.
  2. Interpretation identifies the first word as a command, classifies later words as arguments or options, and validates the command grammar.

This separation is particularly useful for testing. If a path is being split at spaces, the defect is in tokenization. If a duplicate option is accepted, the defect is in interpretation.

Tokenization rules

For this tool, use a conservative quote model:

  • Unquoted whitespace separates tokens.
  • Single and double quotes preserve whitespace.
  • Adjacent quoted and unquoted text is one token, so fixture" Math" becomes fixture Math.
  • Empty quoted values are valid, so --label="" produces an empty string value.
  • Backslashes in Windows paths remain literal.
  • Inside double quotes only, \" represents a literal double quote and \\ represents one backslash.
  • A quote that is never closed produces a typed unterminated-quote error.

Not treating every backslash as an escape is important on Windows. A rule copied from a Unix-like shell could corrupt the common path C:\captures\session.ndjson.

Add the tokenizer below the types:

type Quote = "'" | '"';

function isWhitespace(value: string): boolean {
  return /\s/u.test(value);
}

function tokenize(
  input: string,
): Result<readonly string[], ReplParseError> {
  const tokens: string[] = [];

  let token = "";
  let tokenStarted = false;
  let activeQuote: Quote | undefined;
  let quoteStartIndex = -1;

  for (let index = 0; index < input.length; index += 1) {
    const character = input[index]!;

    if (activeQuote !== undefined) {
      if (character === activeQuote) {
        activeQuote = undefined;
        tokenStarted = true;
        continue;
      }

      const nextCharacter = input[index + 1];

      if (
        activeQuote === '"' &&
        character === "\\" &&
        (nextCharacter === '"' || nextCharacter === "\\")
      ) {
        token += nextCharacter;
        tokenStarted = true;
        index += 1;
        continue;
      }

      token += character;
      tokenStarted = true;
      continue;
    }

    if (character === "'" || character === '"') {
      activeQuote = character;
      quoteStartIndex = index;
      tokenStarted = true;
      continue;
    }

    if (isWhitespace(character)) {
      if (tokenStarted) {
        tokens.push(token);
        token = "";
        tokenStarted = false;
      }

      continue;
    }

    const nextCharacter = input[index + 1];

    if (
      character === "\\" &&
      (nextCharacter === "'" || nextCharacter === '"')
    ) {
      token += nextCharacter;
      tokenStarted = true;
      index += 1;
      continue;
    }

    token += character;
    tokenStarted = true;
  }

  if (activeQuote !== undefined) {
    return {
      ok: false,
      error: {
        kind: "unterminated-quote",
        quote: activeQuote,
        index: quoteStartIndex,
      },
    };
  }

  if (tokenStarted) {
    tokens.push(token);
  }

  return {
    ok: true,
    value: Object.freeze(tokens),
  };
}

Notice that tokenization does not know what --file means. It only produces tokens such as:

[
  "record-start",
  "--file=C:\\captures\\session one.ndjson",
]

That narrow responsibility makes the function reusable for future features such as command-history replay or pasted multi-argument templates.


Interpret tokens as a command envelope

Now define the vocabulary accepted by the REPL. Keep the validation rules strict and readable.

const COMMAND_NAME_PATTERN = /^[a-z][a-z0-9-]*$/u;
const OPTION_NAME_PATTERN = /^[a-z][a-z0-9-]*$/u;

const UNSUPPORTED_SHELL_TOKENS = new Set([
  "|",
  "||",
  "&&",
  ">",
  ">>",
  "<",
  ";",
]);

function asCommandName(value: string): CommandName | undefined {
  if (!COMMAND_NAME_PATTERN.test(value)) {
    return undefined;
  }

  return value as CommandName;
}

function asOptionName(value: string): OptionName | undefined {
  if (!OPTION_NAME_PATTERN.test(value)) {
    return undefined;
  }

  return value as OptionName;
}

The next function completes the parser:

export function parseReplLine(
  input: string,
): Result<ParsedReplLine, ReplParseError> {
  const tokenization = tokenize(input);

  if (!tokenization.ok) {
    return tokenization;
  }

  const tokens = tokenization.value;

  if (tokens.length === 0) {
    return {
      ok: true,
      value: {
        kind: "empty",
      },
    };
  }

  const firstToken = tokens[0]!;
  const commandName = asCommandName(firstToken);

  if (commandName === undefined) {
    return {
      ok: false,
      error: {
        kind: "invalid-command-name",
        value: firstToken,
      },
    };
  }

  const args: string[] = [];
  const options: Record<OptionName, OptionValue> = {};
  let parsingOptions = true;

  for (const token of tokens.slice(1)) {
    if (UNSUPPORTED_SHELL_TOKENS.has(token)) {
      return {
        ok: false,
        error: {
          kind: "unsupported-shell-syntax",
          token,
        },
      };
    }

    if (parsingOptions && token === "--") {
      parsingOptions = false;
      continue;
    }

    if (parsingOptions && token.startsWith("--")) {
      const equalsIndex = token.indexOf("=");
      const rawOptionName =
        equalsIndex === -1
          ? token.slice(2)
          : token.slice(2, equalsIndex);

      const optionName = asOptionName(rawOptionName);

      if (optionName === undefined) {
        return {
          ok: false,
          error: {
            kind: "invalid-option-name",
            value: token,
          },
        };
      }

      if (Object.hasOwn(options, optionName)) {
        return {
          ok: false,
          error: {
            kind: "duplicate-option",
            name: optionName,
          },
        };
      }

      const optionValue: OptionValue =
        equalsIndex === -1 ? true : token.slice(equalsIndex + 1);

      options[optionName] = optionValue;
      continue;
    }

    args.push(token);
  }

  return {
    ok: true,
    value: {
      kind: "command",
      command: {
        name: commandName,
        args: Object.freeze(args),
        options: Object.freeze(options),
      },
    },
  };
}

This implementation makes several user-visible choices explicit:

  • --once becomes { once: true }.
  • --file="" becomes { file: "" }, which is distinct from an absent file option.
  • --file "capture.ndjson" is not interpreted as a file option followed by its value. It means a boolean file option and one positional argument. The user-facing help should therefore consistently show --file=<path>.
  • Repeating an option is rejected rather than silently accepting the first or last value.
  • -- makes remaining tokens positional, even if they start with --.
  • Shell operators receive a useful parse error instead of becoming accidental arguments.

For example:

const result = parseReplLine(
  'probe-add "Fixture Math" --module=fixture.dll --export="Add Numbers" --once',
);

produces this conceptual value:

{
  ok: true,
  value: {
    kind: "command",
    command: {
      name: "probe-add",
      args: ["Fixture Math"],
      options: {
        module: "fixture.dll",
        export: "Add Numbers",
        once: true,
      },
    },
  },
}

At this stage, "Fixture Math" is just a positional string. A later command handler can decide whether it is a probe label, module name, or invalid extra input.


Keep parsing separate from application behavior

Do not let REPL parsing leak into application handlers. The handler should not read a raw line, split strings, access process.argv, or print terminal output. Its job is to perform a use case using validated application data.

For now, the REPL-facing code will be able to follow this shape:

const parsed = parseReplLine(line);

if (!parsed.ok) {
  renderParseError(parsed.error);
  return;
}

if (parsed.value.kind === "empty") {
  return;
}

const command = parsed.value.command;

// A later command registry will locate the appropriate handler.
// The handler receives normalized command data, not the raw REPL line.
await dispatchCommand(command);

dispatchCommand is intentionally not implemented in this lesson. The important boundary is already in place:

  • Raw terminal text remains at the interface edge.
  • The parsed envelope is typed and immutable.
  • Expected syntax problems remain typed values.
  • Application handlers can later validate their own argument and option schemas.
  • Frida adapters remain entirely outside this parser.

This design also supports the course goal of routing one-shot CLI commands and REPL commands through the same use cases. The two interfaces may parse their input differently, but both can normalize toward the same application-level request model.


Verify the contract with focused tests

Because parsing is a boundary contract, tests should assert behavior users can observe rather than implementation details such as loop counters or helper-function calls.

// src/interfaces/terminal/repl/parse-repl-line.test.ts

import { describe, expect, it } from "vitest";
import { parseReplLine } from "./parse-repl-line.js";

describe("parseReplLine", () => {
  it("parses quoted arguments and typed options", () => {
    const result = parseReplLine(
      'probe-add "Fixture Math" --module=fixture.dll --export="Add Numbers" --once',
    );

    expect(result).toEqual({
      ok: true,
      value: {
        kind: "command",
        command: {
          name: "probe-add",
          args: ["Fixture Math"],
          options: {
            module: "fixture.dll",
            export: "Add Numbers",
            once: true,
          },
        },
      },
    });
  });

  it("preserves a quoted Windows path", () => {
    const result = parseReplLine(
      'record-start --file="C:\\captures\\session one.ndjson"',
    );

    expect(result).toEqual({
      ok: true,
      value: {
        kind: "command",
        command: {
          name: "record-start",
          args: [],
          options: {
            file: "C:\\captures\\session one.ndjson",
          },
        },
      },
    });
  });

  it("keeps option-like text positional after the terminator", () => {
    const result = parseReplLine("probe-add -- --not-an-option");

    expect(result).toEqual({
      ok: true,
      value: {
        kind: "command",
        command: {
          name: "probe-add",
          args: ["--not-an-option"],
          options: {},
        },
      },
    });
  });

  it("returns a typed error for an unterminated quote", () => {
    const result = parseReplLine('attach "target process');

    expect(result).toMatchObject({
      ok: false,
      error: {
        kind: "unterminated-quote",
        quote: '"',
      },
    });
  });

  it("rejects shell syntax", () => {
    const result = parseReplLine("list | findstr chrome");

    expect(result).toEqual({
      ok: false,
      error: {
        kind: "unsupported-shell-syntax",
        token: "|",
      },
    });
  });
});

These tests establish important compatibility guarantees:

  • Quoted target names and Windows paths are not split at spaces.
  • Boolean flags and valued options are distinct.
  • -- has predictable behavior.
  • Invalid syntax does not crash the REPL.
  • The tool will not quietly become a shell interpreter.

Key takeaways

A reliable REPL starts with a deliberately small input language rather than inheriting terminal-specific shell semantics.

  • Tokenize quoted input before classifying command parts.
  • Use branded names, readonly collections, and discriminated unions to make the boundary explicit.
  • Return typed parse errors for expected input failures.
  • Keep Windows backslashes intact instead of applying broad shell-style escaping.
  • Reject pipes, redirection, and similar shell constructs rather than partially supporting them.
  • Keep parsing in the terminal interface layer; command handlers should receive normalized data rather than raw text.

Next, you will define application command handlers whose behavior is independent of terminal parsing and rendering. The ReplCommand envelope built here will become the bridge from the interactive interface to those application use cases.

Can't find a good explanation? Sign up and we'll make it for you

Sign up