Browse documentation

Tooling

Auto-formatting, editor configuration, and platform-specific pitfalls for FreePascal development.

Executive Summary

  • Auto-formatter — ./format.pas auto-fixes uses clauses, PascalCase naming, parameter prefixes, and stray spaces; runs via Lefthook pre-commit hook
  • Editor config — .editorconfig + VSCode/Cursor extensions for zero-config formatting on save
  • Platform pitfalls — stale FPC artifacts after branch changes, FPC 3.2.2 Int64→Double conversion bugs (all platforms + AArch64-specific), endian-dependent byte indexing
  • Fuzzing and probes — GocciaFuzzHarness fuzzes single inputs and the scheduled run checks memory safety; scripts/depth-probe.ts is a manual, memory-capped probe of depth limits in each execution mode

Auto-Formatting

The project includes ./format.pas, an instantfpc script that formats Pascal source files. It runs automatically as a pre-commit hook via Lefthook and can also be invoked manually — no build step needed.

Setup

Install Lefthook as described in Workflow — Local setup, then register hooks with lefthook install.

Manual Usage

# Format all project Pascal files
./format.pas
​
# Format specific files
./format.pas source/units/Goccia.Engine.pas
​
# Check only (exit 1 if changes needed)
./format.pas --check

What It Enforces

All of the following are auto-fixed (not just warned about):

  • Uses clauses: one unit per line, grouped (System > Third-party > Project > Relative), alphabetically sorted within each group, blank line between groups. Units with an in path are always in the Relative group; Goccia.* units are Project; known FPC standard library units are System; everything else is Third-party.
  • Function naming: capitalizes the first letter of function, procedure, constructor, and destructor names to enforce PascalCase. Renames all references within the same file. External C bindings are excluded.
  • Parameter naming: adds the A prefix to multi-letter parameters (e.g., Value -> AValue) and renames all references within the function scope (declaration, local variables, and body). Single-letter parameters and Pascal keyword conflicts are skipped.
  • Stray spaces: removes spurious spaces before ;, ), and , (e.g., string ; -> string;). String literals and comments are left untouched.

Editor Configuration

.editorconfig

The project uses .editorconfig for consistent formatting:

  • Indent: 2 spaces (no tabs)
  • Line endings: LF
  • Trailing whitespace: trimmed
  • Final newline: inserted
  • Charset: UTF-8

VSCode / Cursor Setup

The repository includes .vscode/settings.json and .vscode/extensions.json for a zero-config experience in VSCode and Cursor.

Open the project and accept the "Install Recommended Extensions" prompt, or install them manually:

ExtensionIDPurpose
Pascalalefragnani.pascalSyntax highlighting, code navigation, and symbol search for Pascal/Delphi
Run on Saveemeraldwalk.RunOnSaveTriggers ./format.pas automatically when a .pas or .dpr file is saved
EditorConfigeditorconfig.editorconfigApplies .editorconfig rules (indent size, line endings, etc.)

These are declared in .vscode/extensions.json so VSCode/Cursor will prompt to install them on first open.

Format on Save

.vscode/settings.json configures the Run on Save extension to run ./format.pas on every .pas and .dpr file when saved. This keeps code style consistent without manual intervention — the formatter fixes uses clause ordering, PascalCase naming, parameter prefixes, and stray spaces in the background.

The runOnSave command runs silently ("runIn": "backend"), so it will not open a terminal or interrupt your workflow. The file is re-read by the editor after formatting, so changes appear immediately.

Note: This requires instantfpc (ships with FreePascal) to be on your PATH. If you installed FreePascal via the methods in Getting Started, this is already the case.

How the Layers Work Together

LayerWhen it runsWhat it does
.editorconfigWhile typingSets indent size, line endings, trailing whitespace, charset
runOnSaveOn file saveRuns ./format.pas to auto-fix code conventions
Lefthook pre-commitOn git commitRuns ./format.pas on staged files as a safety net
CI --checkOn push / PRFails the build if any file needs formatting

All four layers enforce the same rules, providing defence in depth. The typical developer experience is: EditorConfig handles whitespace while you type, format-on-save fixes everything else when you save, and the pre-commit hook and CI catch anything that slips through.

Platform-Specific Pitfalls

Stale FPC Build Artifacts

After a branch switch, merge, PR sync, generated resource update, or unexplained compiler/resource failure, start with an explicit --clean build before diagnosing source code. FPC 3.2.2 can report stale compiled state as misleading internal compiler exceptions or resource-list errors.

./build.pas --clean loaderbare
./build.pas --clean testrunner
./build.pas --clean

Treat messages such as Compilation raised exception internally and Error while compiling resources as "clean first, diagnose second". Only investigate the reported Pascal source line after the same target still fails from a clean build.

Website Checks Are Inert Without Its Dependencies

The website has its own dependency tree, and its checks fail to load rather than fail loudly when that tree is missing. In a fresh clone or worktree, bun run test inside website/ reports some modules failing to import — and reports exactly that before and after any change you make, because those failures are modules that cannot load, not assertions that disagree with your edit.

Install first; then every file loads, the suite reports 0 fail, and a genuine regression fails immediately and by name:

cd website
bun install
bun run test
bun run lint

Two regressions shipped in website/src/lib/positioning.ts during 0.11.0 because the unprovisioned numbers were compared before and after a change and read as "unchanged, therefore safe". They were identical because the file guarding that text never loaded. A check that cannot run is not a passing check — confirm the checker actually executed, not merely that it reported.

Shared -FU Directories Across Programs — Internal Error 200611011

FPC 3.2.2 aborts with Fatal: Internal error 200611011 when a second program is compiled against the .ppu files another program left in a shared -FU unit-output directory (the inliner trips while recompiling a unit it loaded from the first program's build). This is why build.pas compiles every target, including the TOML compliance runner, into its own build/compiled/targets/<target> directory. Any script that compiles more than one program with fpc @config.cfg must use a separate -FU directory per program.

Int64 to Double Conversion on FPC 3.2.2

FPC 3.2.2 has two bugs affecting Int64 -> Double conversion. Bug A is a Delphi-mode front-end issue that affects all platforms. Bug B is an AArch64-specific codegen issue.

Bug A: Double(Int64Var) bit reinterpretation — FPC #35886

In {$mode delphi}, an explicit Double(Int64Var) cast performs a Turbo Pascal-style bit reinterpretation instead of a value conversion. This produces garbage floating-point values (e.g., Double(Int64(1000)) yields ~4.94e-315 instead of 1000.0). This is a compiler front-end bug in defcmp.pas that affects all platforms in Delphi mode. Fixed in FPC trunk (3.3.1, commit 1da43f67) but not backported to 3.2.x.

Bug B: Int64 * 1.0 wrong results near +/-2^31 (AArch64 only)

Mixed Int64 * Double arithmetic produces wrong results for Int64 values near the LongInt boundary (+/-2,147,483,648). This affects all arithmetic operators (*, +, -, /) where one operand is Int64 and the other is Double. FPC appears to use a 32-bit SCVTF instruction instead of 64-bit when promoting Int64 through arithmetic expressions. This is AArch64-specific and has not yet been reported upstream.

// Observed on FPC 3.2.2 AArch64, all optimization levels:
Int64(-2147483647) * 1.0 -> -2147483648 // WRONG (should be -2147483647)
Int64(-2147483649) * 1.0 -> -2147483648 // WRONG (should be -2147483649)
Int64( 2147483649) * 1.0 -> 2147483648 // WRONG (should be 2147483649)
Int64(-3000000000) * 1.0 -> -3000000000 // correct (far from boundary)

Safe conversion

Use implicit assignment or function parameter passing — both use the correct 64-bit conversion path and are unaffected by either bug:

// WRONG — bit reinterpretation in Delphi mode (Bug A)
Result := Double(FEpochMilliseconds) * 1000000.0;
​
// WRONG — wrong results near +/-2^31 on AArch64 (Bug B)
Result := FEpochMilliseconds * 1.0;
​
// CORRECT — implicit assignment
var D: Double;
D := FEpochMilliseconds;
Result := D * 1000000.0;
​
// CORRECT — implicit promotion at function call boundary
// When passing Int64 to a function/constructor that takes Double,
// FPC generates the correct 64-bit SCVTF instruction.
Result := TGocciaNumberLiteralValue.Create(SomeInt64Value);

This affects any code that converts Int64 fields to Double for floating-point arithmetic. Note that Int64 / Int64 is safe — FPC's / operator already returns Extended for integer operands, so no explicit promotion is needed for division.

Endian-Dependent Byte Indexing

Do not inspect raw byte arrays of Double values to check the sign bit (e.g., Bytes[7] and $80). This assumes little-endian byte layout and breaks on big-endian platforms.

Instead, use the helpers in NumberBits (source/shared/NumberBits.pas), which compare the value as a whole 64-bit integer:

// WRONG — assumes little-endian byte order
var V: Double; Bytes: array[0..7] of Byte absolute V;
begin
Result := (V = 0.0) and ((Bytes[7] and $80) <> 0);
end;
​
// CORRECT — endian-neutral: NumberBits.IsNegativeZero compares
// DoubleToBits(V) with the sign bit alone (1 shl 63)
Result := NumberBits.IsNegativeZero(V);

This works because DoubleToBits copies the whole Double into a UInt64, so the sign is bit 63 of the integer regardless of byte ordering. Use DoubleToBits / BitsToDouble for any other bit-level test.

Fuzzing and Memory Safety

GocciaFuzzHarness drives a single input through lex, parse, and both executors under tight instruction, timeout, memory, and stack bounds. It exists because the engine is a from-scratch, manually memory-managed implementation whose stated purpose is running adversarial input — see VISION.md.

The harness classifies every outcome the engine models — parse error, runtime error, script throw, instruction limit, timeout, memory limit, denied module — as normal, and exits 0. Only an outcome the engine does not model (an unexpected Pascal exception, an access violation, a heap abort) exits nonzero. That is what makes a nonzero exit a finding rather than noise.

Building and running

./build.pas fuzzharness
./build/GocciaFuzzHarness --verbose path/to/input.js

Read from stdin with -. --verbose prints the per-executor classification; without it the harness is silent and communicates only through its exit code, which is what afl-fuzz needs.

Seed corpus

bun run scripts/build-fuzz-corpus.ts --verbose

Seeds are derived from fixtures/, tests/, and examples/, deduplicated by content hash and capped at 8 KiB, plus a small set of synthetic seeds for shapes the repo's own tests avoid by construction (deep nesting, unterminated literals, mixed dialect features). Set TEST262_PATH to include a strided sample of a local test262 checkout:

TEST262_PATH=../test262 bun run scripts/build-fuzz-corpus.ts

Fuzzing locally

afl-fuzz -n -i build/fuzz/corpus -o build/fuzz/out -- ./build/GocciaFuzzHarness @@

The -n is not optional. FPC emits no AFL instrumentation, so AFL++ runs in non-instrumented ("dumb") mode: pure random mutation with no coverage feedback. Practically this means the seed corpus carries the coverage, which is why it is derived from the real suites rather than generated. Instrumented builds were evaluated and deferred — afl-gcc-style instrumentation needs either an FPC assembler-pass rewrite or a GCC-compatible IR, and FPC 3.2.2 exposes neither. The tractable path is afl-clang-lto over bitcode from an LLVM-backend FPC build.

Reproducing a finding

A reproducer from CI or from build/fuzz/out/default/crashes/ replays directly:

./build/GocciaFuzzHarness --verbose build/fuzz/out/default/crashes/id:000000,...

The run is deterministic — same input, same classification, same exit code. The harness prints a backtrace on the finding path. On Linux the frames carry file and line. On macOS FPC emits DWARF into a separate .dSYM it does not read back, so frames print as bare addresses; resolve them with:

atos -o build/GocciaFuzzHarness 0x102d69c6c

To check the fault path itself is intact — useful when a fuzz run comes back suspiciously clean — inject a fault:

./build/GocciaFuzzHarness --self-test-fault

That must print a backtrace and exit 1.

Modules are denied, deliberately

The harness installs a content provider that refuses every module load. A fuzz input must not be able to reach the host filesystem — without this, import "/etc/passwd" would be a file read driven by attacker-shaped input. Inputs containing import are therefore classified module-denied, and the harness does not exercise the module loader.

Memory safety

The scheduled fuzz workflow runs the full JavaScript suite under two tools that answer different questions:

ToolCatchesInvocation
heaptrc (-gh)FPC-level leaks, double frees, unfreed blocks with allocation sitesmkdir -p DIR && fpc @config.cfg -gh -gl -FUDIR -oBIN source/app/GocciaTestRunner.dpr
Valgrind memcheckInvalid reads/writes the allocator never sees, uninitialised valuesvalgrind --tool=memcheck --error-exitcode=42 ./build/GocciaTestRunner -P tests

DIR is a unit-output directory of your own (CI uses build/compiled/targets/testrunner-heaptrc). Give the heaptrc build its own unit-output directory per the per-program -FU rule — and create the directory first, because fpc writes into a -FU directory but will not create one; against a missing directory it fails with Can't create object file: … (error code: 2) (on external-assembler targets such as macOS the message is Can't create assembler file: … instead).

Both run on Linux only. Valgrind slows the suite by roughly an order of magnitude, which is why neither runs per-PR. Findings upload as artifacts with a 30-day retention.

Depth and Fuzz Probe

scripts/depth-probe.ts runs depth and nesting shapes against a GocciaRunner binary you choose: Proxy nests forwarding each internal method, Proxy handler chains, native-trap nests, deep prototype chains, deep recursion, deeply nested values through JSON, structuredClone, join and flat, deep syntactic nesting, and recovery after a caught RangeError. Where the fuzz harness asks whether one input breaks the engine, the probe asks where its depth limits are and what happens past them, in each execution mode the runner supports.

It is manual-only. Nothing in CI, Lefthook, or the nightly runs it. Run it when you change recursion, stack limits, Proxy forwarding, the parser's nesting, or the collector, and when you investigate a depth crash.

./build.pas --prod runner
bun scripts/depth-probe.ts build/GocciaRunner # the whole catalog, every mode
npx tsx scripts/depth-probe.ts build/GocciaRunner --list # probes, growth class, default depths
bun scripts/depth-probe.ts build/GocciaRunner --probe=proxy-getOwnPropertyDescriptor --depth=30000
bun scripts/depth-probe.ts build/GocciaRunner --fuzz --count=200 --keep
bun scripts/depth-probe.ts build/GocciaRunner --fuzz --seed=1234 --index=17 --mode=bytecode

--modes chooses the execution modes (default interpreted,bytecode; --mode=NAME is one of them). Before probing, the tool runs a trivial program in each mode: a mode the runner rejects is listed as skipped rather than counted as a finding, so the tool keeps working against a runner with fewer executors.

Probe production and development builds separately: the production build compiles without FPC's stack checking (-Ct), so a native stack overflow that the development build often reports as a RangeError or as Fatal error: Stack overflow is a segmentation fault there.

Every probe process is memory-capped and timed out by default (2 GiB, 60 s; --memory and --timeout change them). Exponential shapes and uncollected garbage have taken a runner past 8 GB and 27 GB, and the kernel OOM killer then picks processes across the whole host. The cap is a systemd user scope (MemoryMax, MemorySwapMax=0) when systemd-run --user can apply it, else an address-space rlimit (ulimit -v). Prefer the systemd cap where both work. An address-space limit counts reserved as well as used memory, and a runner refused an allocation does not always fail cleanly, so under the rlimit an Out of memory, an access violation or a silent exit 217 is reported as memory-cap. Confirm such an ending under the systemd cap or a larger --memory. Where neither cap works the tool refuses to run unless you pass --no-memory-cap; that flag never turns off a cap that works. Probe processes also run with core dumps minimised, so a crash records a few kilobytes instead of the runner's heap.

Each run ends in one of these outcomes:

OutcomeMeaningFails the run
completedThe probe returned a valueNo
errorAn error the engine models (a caught RangeError, an uncaught parse-time SyntaxError, ...), with its constructor and messageNo
timeoutNo result within the timeoutNo
memory-capKilled by, or refused memory under, the capNo
fatalThe runner printed Fatal error: ...Yes
crashKilled by a signal such as SIGSEGV, or an FPC runtime errorYes
unexpectedAny other ending, including a wrong answer after a caught errorYes

Probes are labelled by how their cost grows with depth. Linear probes default to depths 1,000 and 30,000 (--depths); quadratic and exponential ones run at small depths (--quadratic-depths, --exponential-depths). A few probes fix their own depths, and for recover-recursion the depth is a repeat count; --list shows them, and --depth overrides them too. Each fuzz program's exponential layers share a depth budget, --max-exponential-depth, so stacking layers cannot multiply past it. An exponential probe at a large depth is expected to end in timeout or memory-cap; the cap is what keeps that from reaching the host.

--fuzz composes the catalog's shapes and depths pseudo-randomly. It prints the seed and each program's identity (fuzz:<seed>:<index>) and shape, and every finding comes with the exact command that replays it. --keep writes each crash, fatal error, unexpected ending, timeout and memory-cap program to tmp/depth-probe/ (or --out) with that command in its header. Replay a kept file with that command, or run it under GocciaRunner inside the same systemd-run memory cap: it was kept because it crashed, hung or ran out of memory. Pass extra runner options, such as --runner-arg=--max-stack=0, to probe a configuration other than the default.