blaise/docs/language-rationale.adoc
Graeme Geldenhuys 5894411a35 chore(license): relicense from BSD-3-Clause to Apache 2.0 + Runtime Library Exception
Adopts the Swift/LLVM model: Apache License 2.0 with the Runtime Library
Exception text used verbatim by the Swift project. SPDX identifier:
Apache-2.0 WITH Swift-exception.

Apache 2.0 brings an explicit patent grant and patent-retaliation clause
that BSD-3 lacks. The Runtime Library Exception ensures binaries
produced by the Blaise compiler do not inherit attribution obligations
from the linked-in RTL.

* LICENSE — full Apache 2.0 + RLE text (replaces the deleted LICENCE).
* NOTICE — project header plus QBE attribution (MIT, vendored).
* docs/language-rationale.adoc — new Project Governance section
  capturing the decision, alternatives considered, and rationale.
* SPDX headers updated across all .pas, .pp, .inc, .c source files,
  build scripts, PasBuild plugins, and the Makefile.
* project.xml license field updated.
* tools/migrate_full.py HEADER template updated so generated
  self-hosting source carries the new licence.
2026-05-03 19:45:29 +01:00

1142 lines
44 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

= Blaise Language Design Rationale
:toc: left
:toclevels: 3
:sectnums:
This document records design decisions made for the Blaise Pascal dialect,
together with the alternatives considered and the reasons each choice was
made. It is not a language reference or user guide. Its purpose is to
preserve the reasoning behind decisions so that:
* future contributors can understand constraints without reconstructing them
from git history;
* the eventual full language guide can be written from a solid foundation
rather than from code archaeology.
Entries are added as decisions are made. Each entry records the decision,
the alternatives that were rejected, and the rationale.
---
== Type System
=== String
==== Decision
Blaise has exactly one string type: a reference-counted, UTF-8 encoded
byte sequence, compatible with FPC's `AnsiString` memory layout
(12-byte header: refcount / length / capacity, followed by character
data and a null terminator).
==== Alternatives rejected
* *Multiple string types* (ShortString, AnsiString, WideString,
UnicodeString as in FPC/Delphi) — rejected. The primary source of
confusion in FPC codebases is implicit conversion between string kinds
and the mismatch between storage width and semantic meaning. A single
type eliminates the entire class of bugs and portability issues that
arise from that multiplicity.
* *UTF-16 internal encoding* (as used by Java, C#, Delphi's
UnicodeString, Swift prior to 5) — rejected. UTF-8 is the dominant
encoding for files, network protocols, and operating system interfaces
on all target platforms. Storing UTF-16 internally would require
conversion at every system boundary. UTF-8 also preserves ASCII
compatibility, which matters for the common case of identifier-like
strings in compiler and tool code.
* *Immutable value-type strings* (as in Go, Rust `str`) — deferred.
ARC reference counting gives near-value semantics for most practical
purposes without the allocation cost of copy-on-write. Revisit if
concurrency becomes a first-class concern.
==== Open questions
* Code page annotation on the string header exists in the current layout
(inherited from FPC) but is not yet used. Blaise strings are always
UTF-8; whether the code page field should be removed or remain as a
compatibility shim is undecided.
---
=== No Char Type
==== Decision
Blaise does not have a `Char` type. String subscript (`S[N]`) returns
`Byte`. Single-quoted ASCII literals coerce to their `Ord` value (a
`Byte`) when used in a context that expects a numeric type.
==== Alternatives rejected
* *`Char` as a one-byte alias for `Byte`* — rejected. The name `Char`
implies a Unicode character (a codepoint), but a one-byte value can
only represent code points U+0000..U+00FF. Naming it `Char` reproduces
the original sin of FPC's `AnsiChar`: code that operates on `Char`
values silently breaks on any input outside the ASCII range. `Byte` is
honest about what is actually being held.
* *`Char` as a 16-bit UTF-16 code unit* (as in Java, C#, Delphi
`WideChar`) — rejected for the same reason, one level higher. Java's
experience demonstrates that a 16-bit `char` that cannot represent
supplementary characters (U+10000 and above) creates persistent
confusion: `String.charAt(n)` returns a UTF-16 code unit, not a
codepoint, and most Java code silently ignores the distinction.
* *`Char` as a full Unicode codepoint (21-bit, stored as 32-bit
integer)* — rejected for now. A codepoint subscript on a UTF-8 string
requires an O(N) walk to find the Nth codepoint, which is incompatible
with the mental model that subscript is O(1). If codepoint-level access
is needed, an explicit `CodePointAt(S, N): Integer` function or a
`Runes(S)` iterator is the appropriate API. Neither is implemented yet.
==== Consequences for porting
Code ported from FPC that declares `var C: Char` and assigns `C := S[1]`
will require mechanical substitution of `Char` with `Byte`. This is
intentional friction: it forces the porting author to reason about whether
byte access is actually what is intended.
==== Related
See <<_string_subscript_s_n>> and <<_char_literal_coercion>>.
---
=== String Subscript S[N]
==== Decision
`S[N]` returns the Nth byte of the string (1-based), as `Byte` (stored
as `Integer` in the current implementation). Indexing is into raw UTF-8
bytes, not into Unicode codepoints.
==== Rationale
UTF-8 encodes ASCII characters (U+0000..U+007F) as single bytes that never
appear as part of a multi-byte sequence. The overwhelming majority of
string subscript operations in systems and compiler code check for or
compare against ASCII characters (delimiters, flags, punctuation). Byte
indexing is correct for all such uses.
Byte indexing also matches Go's `string[N]` semantics, which has proven
workable in practice.
==== What is not supported
Codepoint-level indexing is not supported via subscript syntax. It is
deferred to a future `CodePointAt(S, N)` function or `Runes(S)` iterator.
These will be O(N) in the codepoint index because UTF-8 is variable-width.
==== Implementation note — string memory layout and pointer convention
Blaise uses the *data-pointer convention*: the 8-byte slot in a variable or
field holds a pointer directly to the first character byte. The 12-byte ARC
header (refcount, length, capacity) lives immediately before that pointer at
negative offsets:
----
data_ptr 12 RefCount (Integer, 4 bytes; 1 = immortal literal)
data_ptr 8 Length (Integer, 4 bytes; byte count)
data_ptr 4 Capacity (Integer, 4 bytes)
data_ptr + 0 char data (Length UTF-8 bytes, null-terminated)
----
`S[N]` therefore emits: `byte_ptr = data_ptr + (N 1)`, followed by a
`loadub` instruction. This is simpler than the header-pointer form because
there is no 12-byte skip — the pointer already is the character data.
`PChar(s)` is an identity operation: the data pointer is already a valid C
`char*` with no arithmetic needed.
This layout matches how class instance pointers work (the user pointer points
to the first user field; the ARC header is at negative offsets before it),
making the two reference-counted types fully consistent.
==== Why data-pointer rather than header-pointer
The original implementation used a *header-pointer* convention (the variable
slot held a pointer to the start of the ARC header block, with character data
12 bytes later). That was changed for several reasons:
* *Consistency with class objects* — class instances already use the
data/user-pointer convention (`user_ptr` points to the vtable; the ARC
header lives at `user_ptr 16`). Strings following the same idiom means
there is one rule for all reference-counted types, not two.
* *Simpler subscript arithmetic* — `S[N]` with header-pointer required
`ptr + 12 + (N 1)`. With data-pointer it is `ptr + (N 1)`, matching
C array indexing.
* *Zero-cost `PChar` cast* — the `PChar(str)` cast is an identity operation;
no `+ 12` is needed. This matters for cross-platform C interop even on
non-Win32 targets: POSIX `write(2)`, `fopen`, and similar system calls all
take `char*`.
==== Why not the FPC convention
FPC also uses a data-pointer convention, but the signed offsets differ: FPC's
header on x86-64 places length at `data_ptr 8` (SizeInt, 8 bytes), refcount
at `data_ptr 12`, codepage at `data_ptr 16`, and element-size at
`data_ptr 14`. Blaise's header is smaller (12 bytes, no codepage/elemsize)
and length is always a 32-bit `Integer` because Blaise has a single string
type with no codepage.
FPC's choice of data-pointer was driven by Delphi compatibility and Win32 API
usage where `PChar(s)` had to be a zero-cost cast — exactly the same
motivation applies to Blaise's cross-platform C interop requirement. The
header layout differs, but the pointer-to-content invariant is shared.
==== Implication for the Migration Analyser (Phase 8)
FPC code that manually accesses the `AnsiString` header via pointer arithmetic
(e.g. `PInteger(PChar(s) - 12)^` to read the refcount) uses negative offsets
which are correct in Blaise too. Code that used the old Blaise header-pointer
form (positive offsets from a header pointer) is a porting error that the
Migration Analyser should flag.
---
=== Char Literal Coercion
==== Decision
A single-quoted string literal that contains exactly one byte (i.e. a
single ASCII character, U+0000..U+007F) is implicitly coerced to its
`Ord` value when used in a comparison with a `Byte` (or `Integer`) value.
A single-quoted literal that encodes to more than one byte (e.g. an emoji
or any non-ASCII codepoint) in that context is a compile-time error.
==== Rationale
This allows ported FPC code of the form `if S[1] = '-' then` to compile
unchanged, without introducing a `Char` type. The coercion is always
safe because:
. The literal value is known at compile time.
. Whether the literal fits in one byte is checkable at compile time.
. An emoji or non-ASCII character in this position is almost certainly a
mistake; making it an error is the safest default.
==== Example error
[source,pascal]
----
if S[1] = '😀' then { Error: literal '😀' is 4 bytes; cannot coerce to Byte }
----
---
=== Numeric Char Literals (#N)
==== Decision
`#N` (where N is a decimal integer in 0..255) produces a `Byte` value
equal to N. There is no separate `Char` type for these literals.
==== Implementation note
Standalone `#N` literals work without any additional machinery. The lexer's
`UnescapeString` already converts `#N` to the actual byte value (as it does
for `#N` embedded inside string literals). When a `#N` literal appears in a
comparison with a string subscript result, the existing `CoerceToCharOrd`
path validates the byte length and emits `copy N` in the QBE IR.
---
=== PChar
==== Decision
`PChar` is a built-in opaque pointer type that maps to a 64-bit pointer
(`l` in QBE IR). It is not a string type. It exists solely for C
interoperability.
* `PChar(str)` — built-in cast; returns the pointer to the string's
character data (the data pointer, not the header pointer). Because the
Blaise RTL guarantees null termination, the result is a valid C string.
* `string(pchar)` — built-in conversion; constructs an ARC string from a
null-terminated byte sequence by measuring `strlen` and copying.
==== Implementation
`PChar` is a distinct type kind (`tyPChar`) registered as a built-in type.
It maps to `l` (64-bit pointer) in QBE IR. Variable declarations
(`var p: PChar`) allocate an 8-byte nil-initialised pointer slot.
`PChar(str)` emits `add str_ptr, 12` — skipping the 12-byte ARC header
(4 bytes refcount + 4 bytes length + 4 bytes capacity) to reach the
character data. Because the RTL null-terminates all strings, the result
is a valid C `char*`.
`string(pchar)` calls `_StringFromPChar(p: char*): void*` in `blaise_str.c`.
That function measures `strlen`, allocates a new ARC string via `str_alloc`,
copies the bytes, and returns the header pointer with `RefCount = 0`. The
compiler's ARC machinery calls `_StringAddRef` at the assignment site,
bringing the refcount to 1.
`PChar(pchar_expr)` is an identity cast: the value is returned unchanged.
This allows code that obtains a `PChar` from one source to pass it
onwards without an explicit type annotation.
==== Alternatives rejected
* *No `PChar` at all* — rejected. The compiler itself calls external
processes (QBE, cc) and reads/writes files, all of which ultimately
require null-terminated byte pointers at the C boundary. Eliminating
`PChar` would require an alternative C-interop mechanism.
* *`PChar` as an alias for `^Byte`* — rejected for now. Functionally
equivalent but requires the typed-pointer dereferencing machinery,
which is not yet in place. `tyPChar` is a simpler, opaque type that
covers the immediate C-interop use cases without pointer arithmetic.
---
=== Integer Types
==== Decision
Blaise defines three integer types with fixed, platform-independent widths:
[cols="1,1,2", options="header"]
|===
| Type | Width | QBE IR type
| `Integer` | 32-bit signed | `w`
| `Int64` | 64-bit signed | `l`
| `Byte` | 8-bit unsigned (stored as 32-bit in registers) | `w`
|===
`Integer` is 32-bit on all platforms. This matches FPC's behaviour and
makes arithmetic portable across Blaise's supported targets (linux-x86_64,
macos-arm64) without requiring a platform-sized integer type.
`Int64` is 64-bit signed on all platforms. It is used wherever values may
exceed the 32-bit range.
`Byte` is 8-bit unsigned. In registers and temporaries it is widened to
`w` (32-bit QBE word). Array element allocation uses 1 byte per `Byte`
element, consistent with FPC.
==== Built-in constant
`MaxInt: Integer = 2147483647` is a compiler built-in constant. It
represents the maximum value of a 32-bit signed integer (2^31 1). In
Blaise source it is used exclusively as a sentinel meaning "copy to end of
string" in `Copy(S, N, MaxInt)` calls; every such call passes `2147483647`
as the `count` argument to the RTL's `_StringCopy`, which clamps any count
≥ remaining length to the actual remainder.
This differs from FPC, where `MaxInt = High(SizeInt) = 9223372036854775807`
(the 64-bit maximum on 64-bit targets). The Blaise value is deliberately
kept 32-bit: the compiler's Integer type is 32-bit, `Copy`'s `count`
parameter is `Integer`, and passing a 64-bit literal through the QBE `w`
calling convention truncated it to 1, causing `_StringCopy` to return an
empty string. The 32-bit value avoids this without loss of expressiveness
for any realistic string length.
==== Conversion built-ins
[cols="2,1,2", options="header"]
|===
| Built-in | Signature | Notes
| `IntToStr` | `(Integer): string` | 32-bit integer to decimal string; when the argument resolves to `Int64` the codegen automatically routes the call to `_Int64ToStr`, matching FPC's overloaded resolution
| `Int64ToStr` | `(Int64): string` | 64-bit integer to decimal string
| `StrToInt` | `(string): Integer` | Decimal string to 32-bit integer; truncates silently on overflow
| `StrToInt64` | `(string): Int64` | Decimal string to 64-bit integer
|===
==== Alternatives rejected
* *Platform-native integer width* — rejected. A 64-bit `Integer` on 64-bit
targets would break FPC ABI compatibility and cause silent data loss when
interoperating with existing 32-bit FPC code or C functions that expect
`int32_t`. Explicit `Int64` covers the cases that genuinely need 64 bits.
* *`LongInt`, `SmallInt`, `Word`, `Cardinal` aliases* — deferred. These
FPC/Delphi aliases map to fixed widths and could be added as type aliases
in a future release. They are not present in the current implementation
because the compiler's own source uses only `Integer`, `Int64`, and `Byte`.
---
=== Indexed Properties
==== Decision
Blaise supports indexed properties — a class property with a single
index parameter:
----
property Items[Index: Integer]: Pointer read Get write Put;
----
The index parameter is declared inside square brackets in the property
declaration and is automatically passed to the getter and setter
accessors. An indexed property read `Obj.Items[I]` emits a call to the
getter with `(Self, I)`. An indexed write `Obj.Items[I] := V` emits a
call to the setter with `(Self, I, V)`.
Only a single index parameter is supported. Multi-index properties (as
in Delphi) are not implemented.
==== Rationale
Indexed properties are required for `TObjectList.Items[i]`,
`TStringList.Strings[i]`, and `TStringList.Objects[i]` — the three
list-access patterns used throughout the multi-file compiler source.
Without them, every list access requires a direct method call rather than
the idiomatic subscript syntax the compiler source already uses.
==== Alternatives rejected
* *Array-typed plain properties* — the getter and setter approach is how
FPC and Delphi implement all container properties. Returning an array
would require value-copy semantics and would not allow write access
through the subscript.
* *Untyped `__getitem__`/`__setitem__` hooks* — the explicit getter/setter
names in the declaration are more transparent and consistent with the
existing non-indexed property design.
---
=== Constructor and Destructor Keywords
==== Decision
Blaise recognises `constructor` and `destructor` as reserved keywords.
Both are treated as aliases for `procedure` in the method declaration and
implementation position: the compiler parses and emits them identically to
`procedure`. The "constructor" nature of a call is determined at the call
site (when `TypeName.Create(...)` is parsed), not from the method
declaration keyword.
==== Rationale
FPC and Delphi use `constructor` and `destructor` as distinct keywords.
The Blaise RTL (`classes.pas`, `contnrs.pas`, `sysutils.pas`) follows
FPC conventions so that units can be compiled by either compiler without
source modification. Without keyword support the parser treated
`constructor` as an identifier, causing the next token (`Create`) to be
misread as the start of a field declaration.
Treating `constructor` as a pure alias for `procedure` is sufficient for
the current compilation model: object allocation is emitted by the
call-site codegen (via `_ClassAlloc`) regardless of how the method was
declared, and `destructor Destroy` is called the same way as any other
method.
==== Alternatives Rejected
* *Encoding constructor semantics in the declaration* — if the method
declaration keyword drove allocation, every constructor override and
inherited call would need special handling. The current call-site
model is simpler and consistent with how FPC's codegen works at the
IR level.
* *Treating `constructor`/`destructor` as soft keywords* — would require
context-sensitive disambiguation in the lexer; reserved-keyword status
is unambiguous and matches FPC behaviour.
---
== Memory Management
=== Automatic Reference Counting (ARC)
==== Decision
Blaise uses Automatic Reference Counting (ARC) for all class instances
and strings. There is no garbage collector. `Free` is retained as a
manual release operation (it calls `_StringRelease` / decrements the
refcount) to ease porting, but under ARC it is optional for correctly
written code.
==== Alternatives rejected
* *Manual memory management* (as in C, original FPC default) — rejected.
The primary motivation for Blaise is a safer, more maintainable Pascal
dialect. Requiring explicit `Free` everywhere is the dominant source of
bugs and leaks in FPC codebases.
* *Tracing garbage collection* — rejected. GC introduces unpredictable
pause times and makes the runtime significantly more complex. ARC gives
deterministic destruction timing, which matters for resource management
(file handles, network connections) and is more predictable in
latency-sensitive or systems contexts.
==== Known limitation: reference cycles
ARC cannot automatically collect reference cycles. Blaise provides a
`weak` reference mechanism (analogous to `weak` in Swift) to break cycles.
Identifying cycles in a codebase is the principal migration cost when
porting from manual-free FPC code.
==== Porting note
The migration analyser (Phase 8) is intended to identify `try..finally
Obj.Free` patterns and flag them for removal. The `Free` call itself
is harmless under ARC (it simply triggers an early release) but is
unnecessary and misleading in new code.
---
== Parameters
=== `out` Parameters
==== Decision
`out` parameters are parsed and treated identically to `var` parameters
at the code-generation level: both are passed by pointer (the caller
passes the address of the variable; the callee writes through it).
==== Rationale
In FPC/Delphi, the semantic distinction between `var` and `out` is:
* `var` — the callee may read the initial value before writing.
* `out` — the initial value is undefined; the callee must write before
reading. Some compilers zero-initialise `out` parameters on entry.
At the machine level both are pointer parameters. The distinction is
purely a static-analysis / documentation concern. For the current
implementation phase the distinction is not enforced; both produce
identical QBE IR.
==== Future work
A future lint pass could warn when an `out` parameter is read before it
has been assigned within the callee.
---
=== Open Array Parameters (`const A: array of T`)
==== Decision
Blaise supports open-array parameters using the standard two-register ABI
inherited from FPC and Delphi:
* `l %_par_A` — pointer to the first element (a `l` / pointer-width value).
* `l %_par_A_high` — the high index (count 1); negative means the array
is empty.
Both registers are passed as separate QBE parameters and allocated to local
variables (`%_var_A` and `%_var_A_high`) at function entry.
The `const` qualifier is syntactically required for open-array parameters
in the current implementation. It is advisory only: no mutation check is
performed. This matches FPC's treatment, where `const` on an open array
prevents re-assignment of the parameter variable but does not enforce deep
immutability of the referenced elements.
`Low(A)` always returns the constant `0`. `High(A)` loads and truncates
`%_var_A_high` to `Integer` (`w` in QBE).
Element access `A[I]` computes `base + I × sizeof(element)` using QBE
pointer arithmetic and emits the appropriate load instruction for the
element type.
==== ABI rationale
The two-register layout matches FPC/Delphi so that Blaise can eventually
call RTL functions that accept open arrays, and so that ABI knowledge
transfers directly from existing Pascal documentation.
An alternative would be a single-pointer struct (pointer + length), but
this would require a hidden temporary at every call site and would diverge
from the established Pascal ABI without benefit.
==== Forwarding
Passing an open-array parameter `A` to another open-array formal forwards
both `%_var_A` (data pointer) and `%_var_A_high` (high index) as two
separate QBE arguments, preserving the ABI invariant.
==== ARC exclusion
Open-array parameters are excluded from the ARC addref/release loops
emitted at function entry and exit. The array elements are owned by the
caller; the callee holds only a borrowed pointer.
==== Array literal call sites
Array literals `['a', 'b', 'c']` may be passed directly as arguments to
open-array formals. The literal is a stack-allocated temporary:
* A buffer of `N × sizeof(element)` bytes is allocated on the stack using
QBE's `alloc8` (for pointer-sized elements) or `alloc4` (for 32-bit
elements) at function entry.
* Each element expression is evaluated and stored into the buffer at the
corresponding offset.
* The high index `N 1` is a compile-time constant; no hidden slot is
needed.
* The call receives two QBE arguments: the buffer pointer and the literal
integer high index.
The element type is inferred from the first element. All elements must
resolve to the same type. Empty literals `[]` are a semantic error because
the element type cannot be inferred without type-context propagation (which
is deferred).
---
=== Static Arrays (`array[L..H] of T`)
==== Decision
Blaise supports fixed-size, stack-allocated arrays declared as
`array[L..H] of T`, where `L` and `H` are compile-time integer literals and
`T` is any scalar or record type.
* The array occupies a contiguous block of `(H L + 1) × sizeof(T)` bytes
on the stack, allocated with QBE's `alloc4` or `alloc8` instruction.
* The block is zero-initialised with `memset` at function entry (Pascal
default initialisation semantics; also required by QBE's SSA checker).
* Element access uses `base + (I L) × sizeof(T)`. Non-zero-based ranges
are supported: `array[5..9] of Integer` stores 5 elements; `A[5]` maps to
offset 0.
* `sizeof(T)` for array element sizing uses the _raw_ byte footprint of the
element type: 1 for `Byte`/`Boolean`, 4 for `Integer`/`UInt32`, 8 for
`Int64`/`String`/pointer types. This differs from the standalone-variable
allocation size (which pads `Byte` to 4 for alignment), because array
elements are packed consecutively.
* Write access `A[I] := val` is parsed as a `TStaticSubscriptAssign`
statement node distinct from `TAssignment`, because the LHS is not a bare
identifier.
* Read access `A[I]` in an expression is handled by the existing
`TStringSubscriptExpr` / `EmitStringSubscriptExpr` dispatch, extended with
a `tyStaticArray` branch before the `tyOpenArray` branch.
==== Syntax variants and scope
[cols="2,1"]
|===
| Syntax | Status
| `array[0..N] of T` — zero-based
| Supported
| `array[L..H] of T` — non-zero-based
| Supported
| `array[Word] of T` — ordinal-type index
| Deferred
| `array[L..H, M..N] of T` — multi-dimensional
| Deferred
| `array of array of T` — multi-dim open array
| Separate feature
|===
==== Deferred items
* `Low(A)` and `High(A)` for static arrays (returns `LowBound`/`HighBound`).
* `@A[I]` — address-of subscript.
* Global static array variables (require QBE data-section layout for
variable-size blocks).
* Static arrays as value parameters (pass-by-copy semantics).
* Chained subscript on record fields: `Rec.Arr[I]` (local arrays only for now).
---
== Compiler / Build System Integration
=== Build-Tool-Drives-Compiler
==== Decision
The Blaise compiler accepts `--unit-path <dir>` (repeatable) to specify
unit search directories, and resolves `uses` clauses by searching those
directories for matching `.pas` files. No project file format is baked
into the compiler.
The build tool (PasBuild, or any Makefile / shell script) is responsible
for passing the correct `--unit-path` flags. This mirrors the approach
taken by Go (`go build` passes `--importcfg`), Rust (`cargo` passes
`--extern`), and Swift (`swiftc` takes `-I` paths).
==== Alternatives rejected
* *`--project project.xml`* — rejected. A compiler that requires a
project manifest couples the compiler to a specific build tool and file
format. Changes to the manifest format require compiler changes. All
modern compiled-language toolchains avoid this coupling.
==== FPC-style invocation
When invoked with FPC-style single-dash flags (as PasBuild does when
`--fpc /path/to/blaise` is configured), the compiler translates `-Fu<path>`
to `--unit-path` entries internally. This allows PasBuild to drive the
Blaise compiler without modification.
---
=== Unit File Name Casing
==== Decision
Unit file names are stored in lowercase on disk. The unit loader
(`TUnitLoader.Locate`) normalises the name from the `uses` clause to
lowercase before appending `.pas`, with a fallback to the exact case as
written. This means `uses Classes`, `uses classes`, and `uses CLASSES`
all resolve to `classes.pas` on a case-sensitive filesystem.
==== Rationale
Pascal is a case-insensitive language. Delphi runs on Windows (case-insensitive
filesystem) so `uses Classes` finds `Classes.pas` or `classes.pas` equally.
Linux and FreeBSD filesystems are case-sensitive: without normalisation,
`uses Classes` would fail to find `classes.pas`.
FPC on Linux searches in this order: exact case, then lowercase, then
all-uppercase. Blaise simplifies this to: lowercase first, exact case
as fallback. Because the convention is that all Blaise-native unit files
use lowercase names, the lowercase probe succeeds for every well-formed
unit and the fallback is only needed for files authored outside the
convention (e.g. a user's legacy `MyUnit.pas`).
==== Alternatives rejected
* *Case-sensitive lookup only* — rejected. Would break valid Pascal code
the moment a developer writes `uses Classes` and the file is `classes.pas`.
Pascal programmers should not have to think about disk capitalisation.
* *Full directory scan for case-insensitive match* — rejected. Scanning
each directory on every lookup is O(n) in directory size. Lowercase
normalisation is O(1) and sufficient given the lowercase convention.
---
== Platform Interoperability
=== POSIX and Xlib (Linux, macOS BSD layer)
POSIX APIs accept `char*` — null-terminated byte strings. Modern Linux
and macOS (POSIX layer) are universally UTF-8. Blaise's `PChar(str)` cast
returns a pointer to the string's character data, which is already
null-terminated by the RTL. This is a valid `char*` with no conversion
required.
Xlib provides two generations of text functions. The older functions
(e.g. `XDrawString`) treat strings as Latin-1 bytes. The modern
`Xutf8*` family (e.g. `Xutf8DrawString`) accept UTF-8 directly. Blaise
code targeting Xlib should use the `Xutf8*` functions; `PChar(str)` is
the correct way to obtain the argument.
fpGUI already targets the Xlib UTF-8 functions on Linux. No conversion
layer is needed.
=== Windows Win32 / GDI
Win32 provides two API variants:
* `A` (ANSI) — accepts code-page-dependent byte strings. Not suitable
for Unicode-correct code; should not be targeted.
* `W` (Wide) — accepts `wchar_t*`, which is UTF-16LE null-terminated.
Blaise always targets the `W` variants. Since Blaise strings are UTF-8
internally, a UTF-8 to UTF-16LE conversion is required at each Windows
API call site.
==== Chosen approach: RTL boundary conversion
The conversion is handled entirely by the RTL, not the language. The
language does not gain a UTF-16 string type. Instead:
* The RTL provides a `PWideChar` pointer type (an opaque 64-bit pointer,
analogous to `PChar`).
* An RTL function `_StringToWideZ(str: string): PWideChar` converts a
Blaise string to a temporary null-terminated UTF-16LE buffer using the
platform's `MultiByteToWideChar` or equivalent.
* Windows RTL wrapper functions accept Blaise strings directly and perform
the conversion internally before forwarding to the `W` API.
User code calls the RTL wrapper and passes a Blaise string; the UTF-16
buffer is created, used, and freed within the wrapper. The conversion
never surfaces in user code.
==== Rationale for not adding a UTF-16 string type
Every major cross-platform toolkit (Qt, Java, .NET, Go, Python, Swift)
that adopted UTF-16 internally has accumulated conversion overhead and
complexity at POSIX boundaries. The boundary conversion problem does not
disappear — it simply moves from the Windows boundary to the POSIX
boundary. Keeping UTF-8 as the single internal encoding and converting
only at the Windows API boundary is strictly simpler and is the approach
now recommended by Microsoft's own documentation for new cross-platform
code.
==== Status
_Deferred to Phase 6 (LLVM backend + Windows target). No implementation
required for the current Linux x86_64 target._
=== Set Types (`set of TEnum`)
==== Decision
Blaise supports Pascal bit-set types declared as `set of EnumType`, where
`EnumType` must be a previously-defined enumeration. A set variable holds a
bitmask in which bit N is set when the enum member with ordinal N is included.
==== Storage model
- ≤ 32 enum members: stored as a single QBE `w` (32-bit word).
- 3364 members: stored as a single QBE `l` (64-bit long).
- More than 64 members: deferred (no use case in the current codebase).
Sets are value types (copied on assignment), not heap-allocated.
==== Set literals and disambiguation
Set literals use the same `[elem, ...]` bracket notation as array literals.
The semantic pass resolves the ambiguity by examining the assignment target
type: when the LHS is a set type, the bracket expression is analysed as a set
literal; otherwise it is treated as an array literal.
An empty literal `[]` is valid only in a set context (the empty set, bitmask 0).
In an array context, `[]` remains a semantic error (element type cannot be
inferred without type-context propagation).
==== Set operators
[cols="1,2,2"]
|===
| Operator | Meaning | QBE IR
| `S1 + S2` | union | `or`
| `S1 - S2` | difference | `xor` then `and`
| `S1 * S2` | intersection | `and`
| `elem in S` | membership test | `shr` then `and 1`
| `S1 = S2` | equality | `ceqw`
| `S1 <> S2` | inequality | `cnew`
|===
==== Include and Exclude
`Include(S, elem)` and `Exclude(S, elem)` are built-in procedures (not
language keywords) that modify a set variable in-place using bitwise OR and
AND-NOT respectively. This matches FPC's implementation model.
==== Rejected alternatives
- *Heap-allocated bitsets* — rejected. All current set uses are flag sets over
small enumerations (≤ 32 members). A single word is the correct choice.
- *`not` for set complement* — deferred. Not required for current self-hosting
targets. Can be added as a unary `not` on set operands when needed.
---
== File Path Manipulation Built-ins
=== Decision
`ChangeFileExt`, `ExtractFileName`, `ExtractFilePath`, and
`IncludeTrailingPathDelimiter` are implemented as compiler built-in functions
backed by C RTL helpers (`_ChangeFileExt`, `_ExtractFileName`,
`_ExtractFilePath`, `_IncludeTrailingPathDelimiter` in `blaise_io.c`).
=== Rationale
These are the four `SysUtils` path functions required by the self-hosting
compiler source (`Blaise.pas` and related units). The todo notes acknowledged
that a Blaise-native `SysUtils` shim unit would be a cleaner long-term design,
but that approach requires multi-unit compilation support, which is not yet
available. Implementing them as compiler built-ins matches the precedent set
by `GetEnvVar`/`GetEnvironmentVariable` (step 10) and unblocks the multi-source
rebuild immediately.
Once the compiler can compile multiple units, these built-ins can be
migrated to a Blaise-native `SysUtils.pas` shim; at that point the compiler
knowledge of these names should be removed.
=== Semantics
All four functions take `string` arguments and return `string`:
[cols="2,1,3"]
|===
| Function | Args | Behaviour
| `ChangeFileExt(Path, Ext)` | 2 | Replaces extension in `Path` with `Ext`
(which should include the leading dot, or be empty to strip the extension).
Only the last dot in the base-name portion (after the final `/`) is replaced.
| `ExtractFileName(Path)` | 1 | Returns everything after the last `/`, or
`Path` unchanged when no `/` is present.
| `ExtractFilePath(Path)` | 1 | Returns everything up to and including the last
`/`, or an empty string when `Path` contains no directory separator.
| `IncludeTrailingPathDelimiter(Path)` | 1 | Returns `Path` with a trailing
`/` appended; returns `Path` unchanged when it already ends with `/`.
|===
=== Grammar
These built-ins are resolved as regular function calls; no grammar change is
required.
---
== Iteration
=== `for..in` Loop — Class-Based Enumerators
==== Decision
Blaise supports `for X in Collection do` iteration via the _GetEnumerator
protocol_, matching FPC and Delphi semantics exactly:
. The collection expression must be a class instance.
. The class must have a zero-argument method `GetEnumerator` returning a class.
. The returned enumerator class must have a `MoveNext: Boolean` method and a
`Current: T` property whose getter yields the element type.
. The loop variable must be compatible with the element type `T`.
The compiler desugars the loop as follows:
[source,pascal]
----
{ for X in Collection do Body }
__enum := Collection.GetEnumerator;
while __enum.MoveNext do
begin
X := __enum.Current;
Body
end;
----
The synthetic `__enum` variable is injected into the enclosing function's
local variable scope by the semantic pass so that ARC scope-exit cleanup
releases the enumerator automatically.
==== What is supported
* Class-based enumerators (Step 7a): any class with `GetEnumerator` /
`MoveNext` / `Current` property — covers `TStringList`, `TObjectList`,
and user-defined collections.
* Static arrays (Step 7b): `for X in Arr do` where `Arr: array[L..H] of T`
uses index-based iteration; non-zero-based ranges are supported.
* String byte iteration (Step 7c): `for B in S do` iterates the raw UTF-8
bytes of `S`. The loop variable must be an ordinal type (`Byte`, `Integer`,
etc.); each iteration yields one byte via `loadub`. There is no `Char` type
in Blaise — `S[N]` and `for B in S do` both expose bytes, not codepoints.
This is consistent with the language-rationale decision documented under
_No Char Type_.
==== What is not yet supported
* Iterating over sets (`for X in [a, b, c] do`) — deferred.
* Generic collection iteration (`for X in List do` where `List: TList<T>`) —
requires Step 9b: property handling in `InstantiateGeneric` plus enumerator
additions to `generics.collections.pas`.
==== Alternatives rejected
* *FPC-style `IEnumerable` interface* — Blaise does not require the
collection to implement an interface. The structural protocol
(GetEnumerator / MoveNext / Current) is checked by the semantic pass
via name lookup. Requiring an interface declaration would add
boilerplate for simple use cases.
* *Index-based desugaring for arrays* — deferred to a follow-on commit;
using the same protocol for all iterable types keeps the compiler simpler.
---
== Exception Handling
=== Typed `except` Handlers (`on E: TClass do`)
==== Decision
Blaise supports typed exception dispatch inside `except` blocks using the
`on [Var :] TypeName do Stmt` syntax, matching FPC and Delphi semantics.
Multiple `on` clauses are checked in order; an optional `else` clause runs if
no typed handler matches. A catch-all `except ... end` block (no `on` clauses)
remains supported for simple cases.
==== Grammar
[source,ebnf]
----
TryExceptStmt = TRY StmtList EXCEPT ExceptBody END ;
ExceptBody = OnClause { ";" OnClause } [ ";" ] [ ELSE StmtList ]
| StmtList ;
OnClause = "on" [ IDENT ":" ] TypeRef DO Stmt ;
----
==== Semantics
* The type after `:` must resolve to a class type; primitive types are rejected
by the semantic pass with an error.
* When a variable binding is present (`on E: EFoo do`), a synthetic local
variable of the handler's type is injected into the enclosing function's
declaration block so that `EmitVarAllocs` allocates a stack slot at function
entry. The slot is initialised to the current exception pointer at the start
of each matched handler body.
* Handler matching uses `_IsInstance` (the same C helper used by the `is`
operator), which walks the vtable TypeInfo parent chain — so `on E: EBase do`
correctly catches instances of any subclass of `EBase`.
* If no `on` clause matches and there is no `else` clause, the exception is
re-raised to the enclosing handler via `_Reraise`.
* Bare `raise` inside a typed handler retrieves the live exception from
`g_current_exception` (via `_CurrentException()`) and re-raises it.
Using `g_current_exception` (set by `_Raise` at raise time) rather than
`g_exc_top->exception` (which reads the _current_ frame's field) is critical
for correctness: by the time the handler body runs, `_PopExcFrame` has already
unlinked the handler's own frame, so `g_exc_top` either points to an enclosing
frame (with `exception = NULL`) or is NULL at the outermost level.
==== Alternatives Rejected
* *Storing exception in the frame before popping* — the frame is on the stack
and its lifetime ends with `_PopExcFrame`. Capturing via `g_current_exception`
(a thread-local global) avoids frame lifetime issues.
* *Requiring `on E: T` to always have a variable binding* — Delphi and FPC
both permit the no-binding form `on T do`; Blaise follows suit.
---
== Project Governance
=== Licensing
==== Decision
The Blaise compiler, RTL, and bundled tooling are licensed under the
*Apache License, Version 2.0 with Runtime Library Exception*.
The SPDX identifier used in source headers and project metadata is:
Apache-2.0 WITH Swift-exception
The Runtime Library Exception text used is the one defined by the Swift
project, reproduced verbatim at the foot of `LICENSE` in the project
root. Binaries produced by the Blaise compiler that incorporate
portions of the RTL do not inherit attribution obligations from the
licence.
==== Alternatives considered
*BSD-3-Clause* — the project's original licence, in use during early
development. Permissive, short, and well-recognised.
* Rejected for one specific reason: it grants no patent licence. As
Blaise grows and accepts external contributions, every contributor
becomes a potential future patent risk under BSD-3. For a *compiler*
— which by nature implements algorithms (parsing, optimisation,
code generation) that may be patent-encumbered — this is the wrong
default.
*BSD-3-Clause + custom patent grant + custom Runtime Library Exception*
— a bespoke combination, ~50 lines.
* Rejected because it is not OSI-approved, has no SPDX shorthand, and
trades concise text for unfamiliar text. A custom licence is *more*
friction for adopters' legal review than a 12-page Apache 2.0,
because every clause must be read from scratch rather than
recognised from precedent.
*BSD-2-Clause-Patent* — an OSI-approved short licence with patent
grant.
* Rejected because it is not widely adopted in compiler/runtime land
and would still require a custom Runtime Library Exception bolted on,
putting us back in bespoke territory.
*Modified-LGPL with linking exception* — the Free Pascal Compiler's
licence, familiar to the Pascal community.
* Rejected because it carries weak-copyleft obligations on the file
level. Apache 2.0 is fully permissive and avoids any ambiguity for
commercial adopters.
*MIT* — minimal permissive licence.
* Same patent-grant gap as BSD-3, but shorter. Same rejection.
==== Rationale
The Apache 2.0 + Runtime Library Exception combination is the modern
consensus for compiler/runtime projects:
* *Swift* (Apple) — Apache 2.0 with their Runtime Library Exception
text. The wording Blaise uses is theirs, verbatim.
* *LLVM* — Apache 2.0 with the LLVM Exceptions (relicensed in 2019
from a BSD-style licence specifically to gain the patent grant).
* *Rust* — dual MIT / Apache 2.0; Apache 2.0 is the patent-grant path.
Specific properties that mattered for this decision:
. *Explicit patent grant* (Apache 2.0 Section 3). Each contributor
grants users a patent licence covering their contributions. A
contributor who later sues a user over patents in their contribution
loses their grant. This is the headline feature versus BSD-3.
. *Patent retaliation clause* (Apache 2.0 Section 3, second sentence).
Anyone who sues an Apache 2.0 user over the project loses their
licence. This deters submarine patent claims.
. *Runtime Library Exception*. Without an exception, every binary
compiled by Blaise would technically inherit Apache 2.0 obligations
because the RTL is linked in. The Swift-style exception explicitly
releases that obligation, allowing closed-source applications to
ship binaries built with Blaise without Apache attribution noise.
. *NOTICE file mechanic* (Apache 2.0 Section 4(d)). Concentrates
third-party attribution requirements (currently QBE) in one
well-known file that downstream redistributors must preserve.
. *OSI-approved and SPDX-listed*. Tooling (FOSSology, ScanCode,
GitHub licence detection) recognises both the licence and the
exception identifier without configuration.
. *Battle-tested in legal review*. Major compiler projects use exactly
this combination; corporate legal teams have pre-approved it.
==== Notes
* The relicensing happened pre-public-release. No external contributor
consent was needed because Graeme Geldenhuys was the sole
contributor at the time of the change.
* All `.pas`, `.pp`, and `.inc` source files in `compiler/`, `rtl/`,
`tools/`, and `tests/` carry an `SPDX-License-Identifier:
Apache-2.0 WITH Swift-exception` header.
* `LICENSE` at the project root contains the full Apache 2.0 text
followed by the Runtime Library Exception clause.
* `NOTICE` at the project root contains attribution for vendored
third-party code (currently QBE under MIT).
* `vendor/qbe/` retains its original MIT licence; QBE is invoked as a
subprocess and is not statically linked into the Blaise compiler.
---
== Open Language Questions
These items are under active consideration but not yet decided.
=== Integer Width
*Resolved:* `Integer` is 32-bit on all platforms, matching FPC. `Int64`
is the explicit 64-bit type. See the _Integer Types_ section in the Type
System chapter for the full decision, rationale, and alternatives rejected.
=== Enumeration Underlying Type
*Resolved:* Enumeration values are stored as QBE `w` (32-bit word) on all
platforms, matching the current Blaise `Integer` type. FPC's
smallest-type-that-fits approach is not followed: the compiler always uses `w`
for simplicity and ABI predictability. Set types also use `w` for sets with
≤ 32 members and `l` for 3364 members.
=== `Result` vs Return Variable
Blaise currently follows FPC's `Result` convention inside functions.
No change is planned, but this should be formally documented.