Tooling
Auto-formatting, editor configuration, and platform-specific pitfalls for FreePascal development.
Executive Summary
- Auto-formatter —
./format.pasauto-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→Doubleconversion bugs (all platforms + AArch64-specific), endian-dependent byte indexing
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
inpath 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
Aprefix 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.
Recommended Extensions
Open the project and accept the "Install Recommended Extensions" prompt, or install them manually:
| Extension | ID | Purpose |
|---|---|---|
| Pascal | alefragnani.pascal | Syntax highlighting, code navigation, and symbol search for Pascal/Delphi |
| Run on Save | emeraldwalk.RunOnSave | Triggers ./format.pas automatically when a .pas or .dpr file is saved |
| EditorConfig | editorconfig.editorconfig | Applies .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 yourPATH. If you installed FreePascal via the methods in Getting Started, this is already the case.
How the Layers Work Together
| Layer | When it runs | What it does |
|---|---|---|
.editorconfig | While typing | Sets indent size, line endings, trailing whitespace, charset |
runOnSave | On file save | Runs ./format.pas to auto-fix code conventions |
| Lefthook pre-commit | On git commit | Runs ./format.pas on staged files as a safety net |
CI --check | On push / PR | Fails 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 142 pass / 8 fail — and reports
exactly that before and after any change you make, because the 8 are modules
that cannot import, not assertions that disagree with your edit.
Install first, then the same tree reports 204 pass / 0 fail and a genuine regression fails immediately and by name:
cd websitebun installbun run testbun 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 assignmentvar 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, overlay the Double with Int64 absolute and test via integer sign:
// WRONG — assumes little-endian byte ordervar V: Double; Bytes: array[0..7] of Byte absolute V;beginResult := (V = 0.0) and ((Bytes[7] and $80) <> 0);end;// CORRECT — endian-neutral sign bit checkvar V: Double; Bits: Int64 absolute V;beginResult := (V = 0.0) and (Bits < 0);end;
This works because Int64 and Double share the same sign bit position (bit 63) at the integer level, regardless of byte ordering.
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:
| Tool | Catches | Invocation |
|---|---|---|
heaptrc (-gh) | FPC-level leaks, double frees, unfreed blocks with allocation sites | mkdir -p DIR && fpc @config.cfg -gh -gl -FUDIR -oBIN source/app/GocciaTestRunner.dpr |
| Valgrind memcheck | Invalid reads/writes the allocator never sees, uninitialised values | valgrind --tool=memcheck --error-exitcode=42 ./build/GocciaTestRunner 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.