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 tools: Cabal (preferred) or Stack — both are supported. Use a GHC version listed in
tested-withinpackage.yaml. - Source of truth for package config:
package.yaml(hpack) — the.cabalfile is generated from it - Do NOT manually edit
jbeam-edit.cabal— it is auto-generated by hpack - GHC compatibility: The
baseupper bound inpackage.yamldefines 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 intested-with. --prefer-oldestin CI: Whentested-withhas more than one GHC version, the CI Cabal build (cabal.project.ci) runs with--prefer-oldestfor the oldest GHC entry, picking the minimum compatible version of every dependency. This does not apply to local builds. Package lower bounds inpackage.yamlmust be set high enough to exclude versions that cause build failures under this mode.
| 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 (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>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.hsThe 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.
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.
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
All code written or modified must satisfy both tools before being considered done:
- fourmolu — formatter, config in
fourmolu.yamlfourmolu --mode inplace src/ src-extra/ test/ test-extra/ exe/ tools/ fourmolu --check-idempotence --mode check src/ src-extra/ test/ test-extra/ exe/ tools/
- HLint — linter, config in
.hlint.yamlhlint 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 qualifiersN(Node),NC(NodeCursor), andNP(NodePath) are intentional conventions and should be kept.
Whenever editing any .md file in the repo, ensure all tables have aligned, padded columns.
Always use cabal.project.dev (enables tests, dump-ast and transformation flags):
cabal test --project-file=cabal.project.devNever use plain cabal test — it won't run the test suite.
Test files: test/Spec.hs, test/FormattingSpec.hs, test/Formatting/RulesSpec.hs, etc.
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.
showon numbers reaches the output.showonScientific,DoubleorFloatgives the Haskell representation (2.0e-3), and JBeam wants0.002. Whenevershowproduces text that ends up in formatted output, check the representation against the domain.formatScientific Fixed Nothingis usually what is meant.Show/Readround-trips the AST fixtures.examples/ast/stores derivedShowoutput and reads it back. A change to a type'sShoworReadinstance breaks those fixtures even when nothing else looks affected.- Nothing checks the AST fixtures against the parser. The specs read
examples/ast/jbfl/*.hsand format with it, but nothing asserts it equalsparseDSLof the matching.jbfl. A change to what the parser produces leaves the fixtures stale and the suite green. Regenerate withjbeam-edit-dump-astwhenever parsing changes, and check by comparing the two incabal replif in doubt. src-extra/is flag-gated experimental code. Do not report missing unit tests there. Fixture-based tests throughcabal testare still worth flagging if clearly absent.- New JBFL properties need an example file.
FormattingSpecpairs everyexamples/jbeam/*.jbeamwith everyexamples/jbfl/*.jbfl, so a property that no example exercises is untested no matter how many specs mention it.
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.jbeamthe 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.
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.
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 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.
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.
- 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 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.
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 (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.
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.
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.
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.
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:
PropertyKey aGADT definitionpropertyNameeqKeyboolProperties,enumPropertiesorintPropertiesparseValueForKeyinParsing/DSL.hs- Formatting logic in
Formatting.hs propertyReach, saying whether the property reaches below the node its rule names
JBFL_DOCS.md has the user-facing table of which ones do.
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.devThis 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 |
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-dirsneeds nopackage.yamlchange at all. hpack buildsother-modulesby reading the directory, so a regeneration is the whole job. check_cabal_file.shdiffs the working tree againstHEAD, so it fails on any uncommitted change to the.cabalfile, correct or not. It only tells you something once the change is committed.
Do not add Co-Authored-By or any self-credit trailer to commit messages.
To extract JBeam files from BeamNG for testing, always use the extract script:
bash tools/extract-and-format-jbeam/extract-tune-examples.shThis 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.shThat 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.
jbeam-edit.cabal— auto-generated by hpack frompackage.yamlcabal.project.ci— CI configurationcabal.project.release— release configurationexamples/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, andjbeam-edit-dump-astwrites into it, so running it is allowed but committing what it produced needs review..github/workflows/— CI/CD pipelines