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 Int64Double conversion 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 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 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 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, overlay the Double with Int64 absolute and test via integer sign:

// 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 sign bit check
var V: Double; Bits: Int64 absolute V;
begin
Result := (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:

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 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.