Skip to content

feat: parse command - #13

Merged
bananaplain merged 4 commits into
mainfrom
feat/parse-command
Oct 14, 2026
Merged

bananaplain merged 4 commits into
mainfrom
feat/parse-command

Conversation

@bananaplain

@bananaplain bananaplain commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Adds plain-parser parse <file.plain>. It prints the parse result as one JSON document on stdout.

parse takes the same options as check: --template-dir DIR and --config-name NAME. Lookup directories are the .plain file's own directory, then --template-dir, which falls back to the template-dir key of config.yaml next to the .plain file or in the working directory, exactly as check does today. Where a project should declare its lookup directories is an open question and is not touched here.

Why

  • We want to test every compliant parser of ***plain against a golden corpus the same way. A parse command that prints the parsed and validated trees makes that possible without a per-parser harness.
  • Today the only way is to call the parser's library functions, so each parser needs its own harness written against its own functions to produce the trees.

Main decisions

  • parse and check have different jobs.
    • parse prints the parse result as JSON, in a format that stays stable, or the first error.
    • check validates a spec: the module, its requires chain and its linked files. Today it is parse plus codeplain's rules for linked files.
    • They share one function, cli.parse_spec.
    • Why: check is meant to grow into a diagnostic check that lists every error at once and runs further checks. Two commands let it grow without changing the output contract of parse.
  • parse never reads linked files.
    • check keeps codeplain's rule: no binary files, no large base64 blobs.
    • Why: whether a file's content is acceptable is a renderer's decision.
  • The output holds every module of the requires chain, each with its tree. Deepest ancestor first, the entry last.
    • Why: the parser has to parse the whole chain to parse the entry, so it returns everything it built instead of throwing the ancestors away. A consumer that needs them does not parse them a second time. Tools with the same job do the same: cargo metadata and go list -deps -json resolve a project for other tools and print the dependencies with the root. go list -deps also lists each package after its dependencies, the order used here.
  • resources maps each link target, as written, to its path relative to the entry module's directory, with / as separator: notes.md when found next to the module, ../tpl/notes.md when found in a template directory.
    • The directory part is computed lexically and the target is appended as written, so .. is kept and symlinks are not resolved. Joining the value onto the entry module's directory gives exactly the path codeplain builds when it opens the file.
    • When no relative path exists (a template directory on another drive on Windows) the value is the absolute path, still with /.
    • cli.parse_spec holds the output contract and parse serialises it, so a library caller and a subprocess caller get the same values. Resolution is unchanged: loaders.find_resource_dir is the one search, used by the link existence check and by parse.
    • Error messages still name lookup directories as absolute paths, as check does today. The same argument applies to them, but their text is codeplain's and is compared as a whole, so changing it is part of the move to language-neutral error codes, not of this PR. Until then the corpus replaces those directories with a case-root placeholder.
    • Why relative: the only information in the resolved path is which lookup directory matched. An absolute path adds where the project sits on the machine and, on Windows, mixes \ and /. The relative value states the match and nothing else, so the same project prints the same result on every machine and a success golden needs no path normalisation.
    • Why .. is kept: removing it as text can name a different file when a folder on the path is a symlink. Resolving symlinks would print a path that differs from codeplain's and depends on how the machine is set up.
  • Errors are JSON on stdout too.
    • A parser error prints format_version, type and message and exits 1.
    • Usage errors exit 2 with no JSON.
    • Ctrl-C exits 130.
    • ☝️ Same codes as check.
    • type is the Python class name for now. Language-neutral error codes are a separate change.
    • Why: a test runner reads one stream in one format for both outcomes (successful parse or error), and corpus goldens (with successes and errors) are exactly what parse prints. Nobody parses stderr text.
  • The output is always UTF-8, whatever the system encoding.
    • A file name that is not valid UTF-8 (allowed on Linux) gives a JSON error, not a crash.
  • One absolute list of template dirs for both the chain parse and the resource lookup.
    • A relative --template-dir now appears as an absolute path in "link does not exist" errors, as in codeplain.

Library

  • Existing functions are unchanged.
  • Added: cli.parse_spec, loaders.find_resource_dir, loaders.relative_resource_path.

Where to look

  • cli.parse_spec: the operation both commands share, and the output contract.
  • loaders.find_resource_dir, loaders.relative_resource_path: the search and the value it prints.
  • cli.parse: JSON output, encoding and exit codes.
  • README: the output format and exit codes.

The chain parse, link resolution and FRID walk move from check into
parse_spec, which reads the linked files only when load_resources is
set. check calls it with the flag on and behaves as before.
New CLI command, plain-parser parse <file.plain>, with the same options
as check. It prints every module of the requires chain with its tree
and each link target with its resolved path. Unlike check, it does not
read the linked files. A relative --template-dir is now searched, and
shown in errors, as an absolute path, as codeplain does.
The `resources` map of `parse` held the absolute path of each linked
file, so the same project printed a different result on every machine
and, on Windows, a path mixing `\` and `/`. The only information in
that path is which lookup directory matched, so the value is now that
directory relative to the entry module's directory, with `/` as
separator, and the target appended as written. Joining the value onto
the entry module's directory names the file the parser matched.

Resolution itself is unchanged: `find_resource_dir` is the one search,
`resolve_linked_resource` still returns the joined path for the link
existence check, and `parse_spec` holds the output contract that the
`parse` command serialises. When no relative path exists (another drive
on Windows) the value is the absolute path, still with `/`.
@bananaplain
bananaplain requested a review from NejcS October 13, 2026 13:32

@NejcS NejcS left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested parse and check on a few projects

@bananaplain
bananaplain merged commit 063c8e0 into main Oct 14, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants