Skip to content

Latest commit

 

History

History
475 lines (354 loc) · 28 KB

CLAUDE.md

File metadata and controls

475 lines (354 loc) · 28 KB

CLAUDE.md

Project Overview

jbeam-edit is a Haskell CLI tool for parsing, formatting, and editing JBeam files (BeamNG vehicle format). Key features: JBeam parsing with comment preservation, consistent formatting, JBFL rule-based formatting config, and (experimental) node renaming/reference updating.

Before investigating a transformation bug or writing a fixture for one, read TRANSFORMATION_DOCS.md. It covers what --transform does to a file, how nodes are classified, sorted and renamed, and what the config keys mean.

Build System

  • Build tools: Cabal (preferred) or Stack — both are supported. Use a GHC version listed in tested-with in package.yaml.
  • Source of truth for package config: package.yaml (hpack) — the .cabal file is generated from it
  • Do NOT manually edit jbeam-edit.cabal — it is auto-generated by hpack
  • GHC compatibility: The base upper bound in package.yaml defines the maximum supported GHC version. Code must build and all tests must pass on the latest GHC within that range, not only the versions listed in tested-with.
  • --prefer-oldest in CI: When tested-with has more than one GHC version, the CI Cabal build (cabal.project.ci) runs with --prefer-oldest for the oldest GHC entry, picking the minimum compatible version of every dependency. This does not apply to local builds. Package lower bounds in package.yaml must be set high enough to exclude versions that cause build failures under this mode.

Cabal project configs

File Purpose
cabal.project Default (no tests, no optional flags)
cabal.project.dev Development (tests enabled, all flags on, GHCI support)
cabal.project.ci CI builds
cabal.project.release Windows release build

Every release publishes two builds. The workflow runs on windows-latest and builds the matrix twice:

Build Flags Contains
stable cabal.project.release as it stands The formatter. -transformation
experimental the same, plus every experimental flag Also --transform, --update-names, --validate-beams

The split is automatic. .github/scripts/add_experimental_flags.sh runs extract_flags.awk over the .cabal file and turns on every flag whose description contains (experimental), which today is transformation alone. Marking a new flag that way puts it in the experimental artifact without touching the workflow.

So a transformation change does ship, but only to whoever downloads jbeam-edit-<tag>-experimental.zip. --transform, --update-names and --validate-beams sit behind #ifdef ENABLE_TRANSFORMATION in exe/jbeam-edit/CommandLineOptions.hs and do not exist in the stable build. Say which of the two you mean when you call a change user-visible.

Build commands

# Build (default)
cabal build

# Build with dev config (tests, dump-ast, transformation)
cabal build --project-file=cabal.project.dev

# Run tests
cabal test --project-file=cabal.project.dev

# Run the tool
cabal run jbeam-edit -- <file.jbeam>

Type-checking without building

cabal.project.dev sets write-ghc-environment-files: always, so a module and everything it imports can be checked without linking. About 1.3 seconds against 14 for the equivalent cabal build, with the same errors and warnings:

ghc -fno-code -Wall -package-env .ghc.environment.x86_64-linux-9.10.3 \
  -XGHC2021 -XOverloadedStrings -XImportQualifiedPost -XDerivingStrategies -XTupleSections \
  -isrc -isrc-extra/transformation -itest-extra/transformation \
  test-extra/transformation/Spec/Regression.hs

The environment file is named after the compiler, so there is one per GHC you have built with. Keep -Wall: -Werror is set in cabal.project.ci and nowhere else, so a local build stays green on a warning that fails CI. That is how a binding shadowing hspec's before reached a pull request.

jbeam-edit names both a library and an executable, so cabal build jbeam-edit fails with Ambiguous target instead of building. Say exe:jbeam-edit or lib:jbeam-edit. The failure is easy to miss when the command is piped, and a cabal list-bin afterwards still prints a path for the binary that was never built.

Cabal flags

Flags are defined in package.yaml under flags:. Some flags are marked (experimental) in their description and gate optional features. If your change touches code in a flag-gated module, enable the corresponding flag when building and testing. All experimental flags are enabled by default in cabal.project.dev, so using that project file is sufficient when in doubt. If build times are a concern, you can enable only the relevant flag instead of using cabal.project.dev — e.g. -f transformation for transformation-only changes.

Source Layout

src/JbeamEdit/
  Formatting.hs          # Main formatter
  Formatting/
    Config.hs            # Formatting config types
    Rules.hs             # JBFL rule parsing
  Parsing/
    Jbeam.hs             # JBeam parser (Megaparsec)
    DSL.hs               # JBFL DSL parser
    Common/              # Shared parser utilities
  Core/
    Node.hs              # Node types
    NodeCursor.hs
    NodePath.hs
  IOUtils.hs

src-extra/transformation/ # Experimental node transformation (flag-gated)

exe/                     # Main executable entry point
test/                    # HSpec test suite

Code Style — REQUIRED

All code written or modified must satisfy both tools before being considered done:

  1. fourmolu — formatter, config in fourmolu.yaml
    fourmolu --mode inplace src/ src-extra/ test/ test-extra/ exe/ tools/
    fourmolu --check-idempotence --mode check src/ src-extra/ test/ test-extra/ exe/ tools/
  2. HLint — linter, config in .hlint.yaml
    hlint src/ src-extra/ test/ test-extra/ exe/ tools/

CI runs both over all six directories, and fourmolu with --check-idempotence. A single --mode inplace can leave a file that is formatted but not a fixed point, which passes a plain --mode check here and fails in CI, so run the second line before pushing.

  • Language extensions and GHC warnings are specified in package.yaml — code must compile warning-free
  • Do not import data types qualified unless necessary. Use unqualified imports for types and constructors (e.g. import Foo (Bar (..))) and qualified imports for functions that would clash. The project-internal qualifiers N (Node), NC (NodeCursor), and NP (NodePath) are intentional conventions and should be kept.

Markdown — REQUIRED

Whenever editing any .md file in the repo, ensure all tables have aligned, padded columns.

Testing — REQUIRED

Always use cabal.project.dev (enables tests, dump-ast and transformation flags):

cabal test --project-file=cabal.project.dev

Never use plain cabal test — it won't run the test suite.

Test files: test/Spec.hs, test/FormattingSpec.hs, test/Formatting/RulesSpec.hs, etc.

Review checks specific to this repo

These are the ones a general Haskell review misses, because they depend on how this project uses the language rather than on the language itself.

  • show on numbers reaches the output. show on Scientific, Double or Float gives the Haskell representation (2.0e-3), and JBeam wants 0.002. Whenever show produces text that ends up in formatted output, check the representation against the domain. formatScientific Fixed Nothing is usually what is meant.
  • Show/Read round-trips the AST fixtures. examples/ast/ stores derived Show output and reads it back. A change to a type's Show or Read instance breaks those fixtures even when nothing else looks affected.
  • Nothing checks the AST fixtures against the parser. The specs read examples/ast/jbfl/*.hs and format with it, but nothing asserts it equals parseDSL of the matching .jbfl. A change to what the parser produces leaves the fixtures stale and the suite green. Regenerate with jbeam-edit-dump-ast whenever parsing changes, and check by comparing the two in cabal repl if in doubt.
  • src-extra/ is flag-gated experimental code. Do not report missing unit tests there. Fixture-based tests through cabal test are still worth flagging if clearly absent.
  • New JBFL properties need an example file. FormattingSpec pairs every examples/jbeam/*.jbeam with every examples/jbfl/*.jbfl, so a property that no example exercises is untested no matter how many specs mention it.

Investigating a transformation bug

Read TRANSFORMATION_DOCS.md first — it has the classification, sorting and naming rules, and what each config key does.

Then run the binary on a real file before reasoning about the pipeline. Reading the source has repeatedly produced a confident wrong answer where one command would have settled it. Treat a conclusion drawn only from reading as a hypothesis, and say so.

--transform rewrites every .jbeam file next to the one you name, and it reads .jbeam-edit.yaml from the current working directory. Both have contaminated results before, so always work in a directory of its own:

mkdir -p /tmp/repro && cd /tmp/repro
cp <one file> .
printf 'y-sorting-threshold: 0.05\nsupport-threshold: 20\n' > .jbeam-edit.yaml
jbeam-edit --transform <file>

Five checks that pay off on a body file, which is close to mirror symmetric:

  • Pair every left vertex with the right vertex at the same (Y, Z) and compare their output indices. "One side is fine, the other is a mess" usually turns out to be exactly one vertex in the wrong place, and this finds it at once.
  • Transform twice and diff. Anything that differs is a fixed-point defect.
  • Remove one property of the input, the metadata rows or the interleaved naming, and see whether the symptom survives. Isolating the cause that way beats tracing the pipeline by reading it.
  • Sweep the threshold and print the band boundaries before saying that no value separates two groups. On examples/regression_jbeam/body-columns-repro.jbeam the front edge splits into its two columns from 0.153 to 0.160 and the rear column's four nodes share one band from 0.300 to 0.325.
  • Sweep with exact decimals. A float reimplementation of the banding keeps bl47 in its band at 0.299, where the tool starts a new one.

Where each step lives

TRANSFORMATION_DOCS.md describes the behaviour without naming functions, because its readers do not need them. This is the map back to the code.

Step Function File
Split the node list into chunks breakVertices VertexExtraction.hs
Attach sticky metadata and comments nodesToAnnotatedVertices VertexExtraction.hs
Classify by X coordinate determineGroup VertexExtraction.hs
Classify by name, short-circuiting X determineGroup' VertexExtraction.hs
Pull out support vertices moveSupportVertices Transformation.hs
Band by Y and sort sortVertices, indexBand, compareAV Transformation.hs
Assign new names assignNames, vertexPrefix Transformation.hs

Two facts about this pipeline are not derivable from the output, and both have cost time:

breakVertices splits on the name prefix, not on which side a node sits. A file that calls both sides rl_f keeps them in one chunk. A file that alternates nl0, nr1, nl2 gets one chunk per vertex. That decides which vertices share a leading metadata block, and it is why examples/jbeam/frame.jbeam cannot reproduce defects that a body file reproduces on the first run. Check the naming scheme before concluding an existing example covers a case.

A wrong metadata model produces correct-looking output. The transformation only ever emits the difference against the previous vertex, so a key the model dropped is usually still standing in the file and still applies. The output reads fine while the model is wrong. It surfaces when the key is set again to a different value further down, or when aMeta decides an ordering, which it does in compareAV.

Input shapes that change the outcome

Vary one at a time, whether narrowing a bug or building a fixture for one.

Shape Why it matters
Metadata row ahead of the first vertex Applies to the whole section, and only the first chunk sees it as a leading block
Alternating vs shared name prefixes Decides where the chunk boundaries fall
Name ending in a letter (rlsm) vs a digit (rlsm1) Letter-ending names get a SupportKey, digit-ending ones a PrefixKey
A comment between a vertex and a following metadata row extractPreviousAssocCmt only inspects the head of the accumulator
Y gaps at, just under and just over the threshold The band boundary is >=, so an exact-threshold gap starts a new band
Already-transformed output fed back in Several defects need two runs to appear
A beams section, or none No beams means no support vertices

Regression fixtures go in examples/regression_jbeam/. Read that directory's README first: the bar for adding one is that the test genuinely cannot be built from what is already there.

Profiling

Profiling needs profiling: True and profiling-detail: all-functions in cabal.project. Neither belongs in a commit, so take them out again when you are done.

Each of the five below has cost a wrong conclusion at least once.

Read allocation, not time. Two runs of the same binary on the same input allocate the same number of bytes, reproducible to about a hundred bytes out of several hundred megabytes. Tick counts move by several percent between the same two runs, and a typical run is only a few hundred ticks, so a cost centre at 2% is two or three ticks. Rank by %alloc and treat %time as an indication.

Never profile against examples/. Every run rewrites the file it is given, with or without -i: -i only skips the .bak.jbeam copy that is otherwise made first (createBackupFile in Main.hs). There is no read-only mode, so a run that looks like a measurement is an edit. On top of that --transform rewrites every .jbeam beside the file you name, so one run overwrites all three fixtures. It also silently changes what the next run reads: an already-transformed file is shorter, which makes two consecutive runs incomparable. Copy the file to a directory of its own first, exactly as the section above says for transformation bugs.

Profile the large tail, not only the examples. examples/jbeam/frame.jbeam is close to a typical stock file, so it is a fair sample of the common case, but it says nothing about the expensive one. Extract the corpus with tools/extract-and-format-jbeam/corpus-extract.sh and measure the sizes before choosing: the median file is around 11 KB, the 90th percentile around 44 KB, and the largest is half a megabyte, roughly 36 times the frame example. Anything in the rule lookup that scales worse than linearly is invisible at the median and obvious at the tail, so profile one file from each end. Copy them out of the corpus directory first, for the same reason as above.

Give the binary its data directory, and keep stderr. The shipped ruleset is found through getDataFileName (Formatting/Config.hs), so a binary run from outside the build tree finds no examples/jbfl/minimal.jbfl and formats with no rules at all. Rule lookup is about two thirds of all allocation, so the profile then measures something the tool never does: frame.jbeam allocated 15 MB without rules and 62 MB with them. Export jbeam_edit_datadir=<repo> when running the binary directly, and keep stderr, because a failed lookup announces itself there. Do not look for Loading jbfl:, which is printed only when --rules-path is given, so its absence means nothing on an ordinary run.

--rules-path does not get you out of this. The named file is loaded and then discarded when the shipped default cannot be found, so the run formats with no rules while reporting that it loaded yours. The only sign is a second stderr line, Failed to parse default ruleset. This applies to any comparison between rulesets, not only to profiling: without the data directory every variant produces byte-identical output, which reads as "the rules make no difference".

Rebuild fully when build flags change. A partial rebuild leaves modules compiled under the old flags, and the result looks like a real difference. If allocation moves by more than a rounding error between two runs that should be identical, suspect the binary before believing the number.

Baseline

Measured with --transform, with the shipped ruleset loaded, reading bytes allocated in the heap from +RTS -s. Reproduce it before trusting a comparison against it.

The conditions decide the numbers, so they are part of the measurement: GHC 9.14.1, -O1, containers 0.8, master at 6e9c96f. Optimization is worth a factor of three and the compiler version four to five percent, so a figure quoted without them says nothing. -O1 because that is what ships: configure_project.sh passes it, and a -O0 baseline describes a build no user ever gets.

Input Size Allocation
examples/jbeam/frame.jbeam, alone 14 236 B 23 MB
a 150 KB stock body file, alone 149 896 B 173 MB
the same body file in its vehicle, 74 files 1.4 MB 1 465 MB

The third row is the one that describes the tool as users run it. --transform rewrites every neighbouring file that referenced a renamed node, 37 of the 74 here, and rewriting means reformatting the file in full. So the formatter runs 38 times for one command, and updateOtherFiles inherits two thirds of all allocation while the renaming inside it costs well under one percent. Anything saved in the rule lookup is saved 38 times over. Making that lookup a trie is what took these three rows down by about sixty percent.

Two things follow, and both have been mistaken for something else before. Allocation runs one to two thousand bytes per byte of input, so the absolute figures look alarming and say nothing on their own. And a tenfold larger input allocates eight times as much, so the cost is slightly sublinear: there is no asymptotic problem hiding in the rule lookup, only a large constant.

Where that constant sat was profiled once, at -O0, before the rule lookup became a trie: a linear sweep over every stored pattern, the Seq pattern match that compared each one against the cursor, and propertyName rebuilding a Text from a string literal on every Eq SomeKey comparison, together about two thirds of all allocation. The first two no longer exist. Nothing has been profiled since, so treat the shares as history and measure before acting on them.

Key Architectural Notes

  • Parser: Megaparsec-based, preserves comments and whitespace
  • Formatter: works on the parsed AST to produce normalized output
  • JBFL: mini-language for specifying formatting rules (path selectors + properties like PadDecimals, PadAmount, NoComplexNewLine, AutoPad, Indent)
  • Config files: ~/.config/jbeam-edit/rules.jbfl (global) or .jbeam_edit.jbfl (project-local override)

NodeCursor / NC.applyCrumb

NodeCursor tracks the current position in the AST as a sequence of breadcrumbs (ArrayIndex Int or ObjectIndexAndKey Int Text). NC.applyCrumb cursor f index node appends a breadcrumb for node at index and calls f updatedCursor node. Used throughout the formatter to match JBFL rules against the current path.

FormattingState

Passed through addDelimiters / formatWithCursor / doFormatNode:

Field Description
fsUsePad Whether AutoPad is active
fsColumnWidths Max width per column (computed once)
fsFormattedCache Pre-rendered cell texts, rows × cols
fsHeaderWasExtracted Whether the first row was a string header
fsCurrentRowIdx Nothing = outer (row) context; Just i = inner (cell) context, i indexes into fsFormattedCache

Object children include Comment nodes

Object (Vector Node) stores both ObjectKey and Comment nodes as direct children. Any code iterating object contents must handle both constructors. Using an ordered map was considered and rejected specifically because of this requirement.

isComplexNode

A node is complex if it contains an array or object with more than one child. Single-child chains ([[1]], [{a: 1}]) are not complex. ObjectKey delegates to its value's complexity. This flag drives all inline vs. multi-line decisions in the formatter — changing its definition affects output broadly.

Comment association direction

AssociationDirection (PreviousNode / NextNode) is determined during parsing (Jbeam.hs), not post-processing. It controls whether a comment renders inline after the preceding node or on its own line before the next one. Changing separator logic in the parser can silently break comment positioning.

Writing JBFL

JBFL_DOCS.md has a table of the patterns. It does not put the block syntax anywhere you can miss, and the block is what gets invented from memory:

.*.nodes, .*.beams {
    PadDecimals: 3;
    AutoPad : true;
}

A property is Name : value ;. Both the colon and the semicolon are required, spaces around the colon are optional, and several patterns are separated by commas. PadAmount 7 is not JBFL. Copy a rule out of examples/jbfl/complex.jbfl and edit it rather than typing one from memory.

A bad rule in a spec does not announce itself. rulesFromSource calls error only when the RuleSet is forced, so a spec whose subject throws first, an unimplemented function for instance, hits that exception and never parses the source at all. The spec then fails for a reason that has nothing to do with what it claims to test. Force the parse once before trusting any spec built this way:

cabal repl --project-file=cabal.project.dev jbeam-edit-test
> import SpecHelper (rulesFromSource)
> print (rulesFromSource ".*.nodes { PadAmount: 7; }")

An empty RuleSet is the other failure and is quieter still: the source parsed, the rule was dropped, and nothing threw. A spec built on one asserts Nothing everywhere and passes.

Adding a new JBFL property

PropertyKey is a GADT in Formatting/Rules.hs. Adding a new property requires updates in all of these places — missing any one causes a compile error:

  1. PropertyKey a GADT definition
  2. propertyName
  3. eqKey
  4. boolProperties, enumProperties or intProperties
  5. parseValueForKey in Parsing/DSL.hs
  6. Formatting logic in Formatting.hs
  7. propertyReach, saying whether the property reaches below the node its rule names

JBFL_DOCS.md has the user-facing table of which ones do.

examples/ directory structure

Used as test fixtures. To regenerate all fixtures after changing formatting logic or example files, run:

cabal run jbeam-edit-dump-ast --project-file=cabal.project.dev

This reformats all files in one pass. You may run this when needed, but do not suggest committing the results unless the user has clearly reviewed and approved the fixture changes.

Directory Contents
examples/jbeam/ Source .jbeam files
examples/formatted_jbeam/ Expected formatter output
examples/jbfl/ JBFL rule configs
examples/ast/ Haskell AST representations
examples/invalid_jbeam/ Malformed files for parser error tests
examples/transformed_jbeam/ Expected transformation output

package.yaml → hpack

After editing package.yaml, regenerate the .cabal file by running hpack directly.

The hpack version has to match the one that generated the committed file. Line 3 of jbeam-edit.cabal records it. Any other version reformats the whole file, and check_cabal_file.sh then rejects it even though nothing meaningful changed.

Do not regenerate through stack build --hpack-force. Stack bundles its own hpack, and the bundled one is currently older than the version the committed file was generated with, so it rewrites the entire file. If the matching hpack is not available, edit the .cabal file by hand to match what the change implies, and keep the diff to those lines. Ask before doing that.

Two things that are easy to get wrong here:

  • Adding a module under an existing source-dirs needs no package.yaml change at all. hpack builds other-modules by reading the directory, so a regeneration is the whole job.
  • check_cabal_file.sh diffs the working tree against HEAD, so it fails on any uncommitted change to the .cabal file, correct or not. It only tells you something once the change is committed.

Git Commits

Do not add Co-Authored-By or any self-credit trailer to commit messages.

Extracting BeamNG files

To extract JBeam files from BeamNG for testing, always use the extract script:

bash tools/extract-and-format-jbeam/extract-tune-examples.sh

This reads tools/extract-and-format-jbeam/transform-check-files.txt for the list of structural files to extract. Do not use unzip or manually navigate the BeamNG install directory.

For questions about how common something is ("does this shape occur in real files", "is this threshold sensible"), that curated handful is not a sample. Extract the whole corpus instead:

bash tools/extract-and-format-jbeam/corpus-extract.sh

That pulls every .jbeam from all 123 vehicle zips, roughly 4900 files, into /tmp/jbeam-corpus unformatted. A dozen files will give a confidently wrong answer to a prevalence question; the corpus has repeatedly given the opposite one.

Immutable Files — Do NOT modify

  • jbeam-edit.cabal — auto-generated by hpack from package.yaml
  • cabal.project.ci — CI configuration
  • cabal.project.release — release configuration
  • examples/ directory contents — used as test fixtures. examples/regression_jbeam/ is the exception and is meant to grow: see its README for when a new fixture is warranted. Adding a file counts as modifying the directory, and jbeam-edit-dump-ast writes into it, so running it is allowed but committing what it produced needs review.
  • .github/workflows/ — CI/CD pipelines