blaise/docs/language-rationale.adoc
Graeme Geldenhuys ac6e0b0d88 docs: document array[NamedSubrange] index type
grammar.ebnf: the ARRAY [IDENT] OF T form is an ordinal-indexed array;
clarify it admits both an enumeration type and a named integer subrange
as the index, with a note on how each folds (enum -> array[0..N-1],
subrange -> array[lo..hi]).

language-rationale.adoc: add the array[TSubrange] of T row to the
supported array-index table, noting the subrange supplies the index
range while its values remain ordinary unchecked integers.
2026-06-30 21:23:35 +01:00

5606 lines
234 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.
---
=== 0-Based String Indexing
==== Decision
String indexing in Blaise is 0-based. `S[0]` is the first character.
The built-in functions `Pos`, `Copy`, `Delete`, `PosEx`, and `OrdAt` all
use 0-based positions. `Pos` returns `-1` when the substring is not found.
==== Rationale
1-based string indexing originates from Niklaus Wirth's original Pascal
(1970), where `String` was a `packed array [1..N] of Char`. The `[1]`
lower bound was simply the array's natural lower bound — there was nothing
semantically meaningful about starting at 1. Borland's introduction of
`ShortString` (with a length byte at index 0 and characters at indices
1..255) then locked the convention in for backward-compatibility reasons.
Every subsequent implementation — Turbo Pascal, Delphi, FPC — carried it
forward unchanged.
Blaise has no `ShortString`, no legacy length-byte layout, and no existing
code base to be backward-compatible with. The data-pointer convention used
by Blaise strings (`str_ptr + 0` is the first character byte) maps
directly to 0-based indexing. Supporting 1-based access would require a
`-1` adjustment in `EmitStringSubscriptExpr` and every RTL string function
for no benefit other than mimicking an accidental 1970s convention.
The rest of the language is already consistently 0-based: dynamic arrays
start at 0, open-array parameters are 0-based, and pointer arithmetic is
byte-addressed from a base pointer. Adopting 0-based strings removes the
sole exception and eliminates an entire category of off-by-one errors that
arise when mixing string indices with array indices.
The `-1` not-found sentinel for `Pos` aligns with the convention used by
most modern languages (Python `str.find`, Go `strings.Index`, Rust
`str.find`, Java `String.indexOf`).
Explicitly ranged arrays (`array[5..10] of T`) are unaffected: a user who
declares a lower bound of 5 expects `A[5]` to be the first element. That
indexing is explicit and intentional, not an inherited convention.
==== Alternatives rejected
* *Keep 1-based indexing for compatibility with FPC/Delphi* — rejected.
There is no existing Blaise code base to protect. Carrying 1-based
indexing forward would permanently embed a known design mistake into a
language that is explicitly trying to clean up Pascal's baggage.
* *Offset transparently in the compiler, expose 1-based to the user* —
rejected. This would make pointer arithmetic on strings unpredictable and
would require every string function to document an exception to the
otherwise-universal 0-based convention.
==== Bootstrap and migration notes
The Blaise compiler source is compiled by FPC during development (as the
stage-1 bootstrap compiler) and must therefore work under both FPC
(1-based) and Blaise (0-based) semantics simultaneously. The shim unit
`uStrCompat.pas` provides thin wrapper functions (`StrAt`, `StrHead`,
`StrCopyFrom`, `StrCopyTail`, `StrPos`) that translate between the two
conventions under FPC and pass through directly under Blaise. Once a
Blaise release exists that is self-hosting on a 0-based string runtime,
the shim can be dropped and the calls replaced with plain built-ins.
The migration analyser (`tools/migration-analyser`) must flag code ported
from FPC/Delphi that uses 1-based string idioms. See
`docs/future-improvements.adoc` for the enumerated checklist.
---
=== 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, use `CodePointAt(S, N)` from `StrUtils` or iterate with
`for CP: Integer in S do`.
==== 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.
==== `Ord()` is not needed for strings
In FPC and Delphi, `for C: Char in S do` yields a `Char` value, so
developers must call `Ord(C)` to obtain the numeric byte value. In
Blaise, `for B in S do` already yields a `Byte` (or any compatible
ordinal), and `S[N]` already returns `Byte` — the numeric value is
available directly. There is nothing to convert.
`Ord()` remains meaningful in Blaise for enumerations (obtaining the
underlying integer of an enumeration value), but it has no role in
string processing.
==== Related
See <<_string_subscript_s_n>> and <<_char_literal_coercion>>.
---
=== String Subscript S[N]
==== Decision
`S[N]` returns the Nth byte of the string (0-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.
==== Writable subscript — `S[N] := <byte>`
`S[N]` is also assignable: it overwrites the Nth byte of the string in
place. The right-hand side is a single byte ordinal — a numeric value, a
`Chr(n)` call, or a one-character string literal (Blaise has no `Char`
type, so these are the byte-shaped forms used in its place), symmetric with
what the read side yields.
Because Blaise strings are reference-counted and string literals are
immortal (RefCount = 1, stored in read-only memory), an in-place byte
write must not touch a buffer that is shared or constant. `S[N] := b`
therefore performs *copy-on-write*: the RTL helper `_StringUnique(S)`
returns the string unchanged when it is uniquely owned (RefCount = 1), and
otherwise allocates a fresh RefCount = 1 copy, releases the old reference,
and returns the copy. The compiler stores the (possibly new) pointer back
into the variable slot before the `storeb`, so the slot keeps exactly one
owned reference. This matches Delphi/FPC `UniqueString` semantics: mutating
one alias never disturbs another, and a literal reused elsewhere stays
pristine. Both backends emit the same `_StringUnique` → write-back →
`storeb` sequence.
==== What is not supported
Codepoint-level indexing is not supported via subscript syntax. Use
`CodePointAt(S, N)` from the `StrUtils` unit for O(N) codepoint-indexed
access, or `for CP: Integer in S do` for sequential codepoint iteration.
Assigning a multi-byte value through `S[N] :=` is not meaningful: the
subscript addresses a single byte, so only a byte-sized right-hand side is
accepted.
==== 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.
---
=== `Low` and `High` on Strings
==== Decision
`Low(S)` always returns the constant `0`. `High(S)` returns
`Length(S) - 1`, i.e. the index of the last valid byte. Both return
`Integer`. For an empty string, `High(S)` returns `-1`; a standard
`for I := Low(S) to High(S)` loop executes zero iterations, which is the
correct behaviour.
==== Rationale
Blaise strings are 0-based. `Low` and `High` exist on arrays to make
`for I := Low(A) to High(A)` idiomatic regardless of whether the array
is zero-based or non-zero-based. Extending the same intrinsics to strings
means that developers do not need to remember a different idiom when
iterating a string by index:
[source,pascal]
----
for I := Low(S) to High(S) do
Process(S[I]);
----
`Low(S)` is a compile-time constant `0` — no runtime overhead.
`High(S)` loads the length field from the ARC header (`data_ptr - 8`) and
subtracts one, identical to `Length(S) - 1`.
==== Preferred idiom
For simple byte iteration, prefer `for B in S do` (see
<<_for_in_loop_class_based_enumerators>>), which avoids index arithmetic
entirely and reads the intent clearly. `Low`/`High` are intended for
cases where the index itself is needed (e.g. comparisons, slice
computations, or passing the position to another function).
==== Alternatives rejected
* *`Low(S)` returning `1`* — rejected. Blaise is 0-based; returning `1`
would be a lie that silently reintroduces the 1-based confusion the
0-based design was adopted to eliminate.
* *Not supporting `Low`/`High` on strings at all* — rejected. The
intrinsics already exist for arrays; omitting them for strings would
create an arbitrary inconsistency that forces developers to write
`0` and `Length(S) - 1` as magic constants.
---
=== `for B in S` — String Iteration (Bytes and Codepoints)
==== Decision
The loop variable's declared type selects the iteration mode:
* `Byte` — iterates raw UTF-8 bytes (one byte per iteration)
* `Integer` — iterates Unicode codepoints (one codepoint per iteration,
advancing 14 bytes as needed)
* Any other type — compile-time error
There is no `Char` type in Blaise — both modes expose numeric values.
==== Rationale
Since Blaise has no `Char` type (see <<_no_char_type>>), the natural
element type for string iteration is `Byte` (raw bytes) or `Integer`
(codepoints). Byte iteration is consistent with `S[N]` returning `Byte`.
Codepoint iteration provides the API users expect for Unicode text
processing without requiring a separate function or iterator wrapper.
The loop variable type acts as a discriminator: `Byte` signals intent to
work with raw encoding units (systems code, protocol parsing), while
`Integer` signals intent to work with Unicode characters (text processing).
==== Byte iteration
The compiler desugars `for B: Byte in S do Body` as:
[source,pascal]
----
__idx := 0;
while __idx < Length(S) do
begin
B := S[__idx];
Body;
Inc(__idx);
end;
----
The synthetic `__idx` variable is injected into the enclosing function's
local variable scope. No heap allocation is required; iteration is a
simple index-and-load loop.
==== Codepoint iteration
The compiler desugars `for CP: Integer in S do Body` as:
[source,pascal]
----
__idx := 0;
while __idx < Length(S) do
begin
packed := _Utf8DecodeAt(PChar(S), __idx);
CP := packed and $FFFFFFFF; { low 32 bits = codepoint value }
__adv := packed shr 32; { high 32 bits = byte advance }
Body;
__idx := __idx + __adv;
end;
----
`_Utf8DecodeAt` is a runtime helper that decodes one UTF-8 codepoint at
a given byte offset. It returns both the codepoint value and the byte
count packed into a single `Int64` (byte count in upper 32 bits, codepoint
in lower 32 bits). This avoids a double call and keeps the loop to one
function-call per iteration.
Both synthetic variables (`__idx` and `__adv`) are injected into the
local scope.
==== Alternatives rejected
* *Loop variable typed as `Char`* — rejected. Blaise has no `Char` type.
See <<_no_char_type>> for the full rationale.
* *Separate `Runes(S)` iterator function* — rejected. The type-based
dispatch is simpler to use and does not require importing a separate unit
or learning a new API.
* *Allowing `Word`, `SmallInt`, or other ordinals for codepoint iteration*
— rejected. Codepoints range from 0 to U+10FFFF (1,114,111), which
does not fit in 16-bit types. Restricting to `Integer` avoids silent
truncation bugs.
---
=== 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 five integer types with fixed, platform-independent widths:
[cols="1,1,2,2", options="header"]
|===
| Type | Width | QBE IR type | Aliases
| `Integer` | 32-bit signed | `w` |
| `Int64` | 64-bit signed | `l` |
| `UInt32` | 32-bit unsigned | `w` | `Cardinal`
| `UInt64` | 64-bit unsigned | `l` | `QWord`, `PtrUInt`
| `SmallInt`| 16-bit signed | `w` (2 bytes in storage) | `Int16`
| `Word` | 16-bit unsigned | `w` (2 bytes in storage) | `UInt16`
| `Byte` | 8-bit unsigned | `w` (1 byte in storage) |
|===
`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.
`UInt32` and `UInt64` are the unsigned counterparts of `Integer` and
`Int64`. `Cardinal` is provided as an alias for `UInt32` and `QWord` as
an alias for `UInt64` so code ported from either FPC or Delphi compiles
unchanged. `PtrUInt` is the pointer-sized unsigned integer; on 64-bit
targets it is identical to `UInt64`.
`Byte` is 8-bit unsigned. In registers and temporaries it is widened to
`w` (32-bit QBE word). Field and array element storage uses 1 byte per
`Byte` element, consistent with FPC.
==== Int64 ↔ UInt64 conversion is explicit
`Int64` and `UInt64` are not implicitly compatible — mixing them in an
arithmetic or comparison expression is a type error. An explicit cast
(`UInt64(x)` or `Int64(x)`) is required, and the cast reinterprets the
bit pattern rather than performing a numeric conversion. This matches
FPC's strict-mode behaviour and avoids the silent footgun where a
negative `Int64` becomes a huge `UInt64` (or vice versa) without
warning. All narrower integer types (`Integer`, `UInt32`, `Byte`) widen
implicitly into both `Int64` and `UInt64`.
==== 16-bit integer types: SmallInt / Word
`SmallInt` (16-bit signed, range 32768..32767) and `Word` (16-bit
unsigned, range 0..65535) are provided alongside the established
32-/64-bit integer types so that code ported from FPC or Delphi
compiles unchanged and so that `record` field layouts can match
fixed-width binary formats without packing tricks.
In registers and temporaries both types are widened to `w` (32-bit
QBE word); only field and array element storage uses 2 bytes per
element (`storeh` / `loadsh` for `SmallInt`, `loaduh` for `Word`).
Implicit widening into `Integer`, `Int64`, `UInt32` and `UInt64` is
permitted in the same way `Byte` widens, since 16-bit values fit
losslessly in all of those. `Int16` and `UInt16` are accepted as
aliases for `SmallInt` and `Word` respectively.
==== Integer literal typing
Untyped integer literals are typed by magnitude:
* `[MinInt32..MaxInt32]` → `Integer`
* outside Int32 range but in `[MinInt64..MaxInt64]` → `Int64`
* in `(MaxInt64..MaxUInt64]` → `UInt64`
The bit pattern is preserved in every case; the literal type only
selects which signed/unsigned interpretation the surrounding expression
sees.
==== Radix prefixes
Integer literals may be written in four radixes, following FPC (not
Delphi):
* decimal — `42`
* hexadecimal — `$2A` (also Delphi)
* binary — `%101010` (Delphi 11+ also accepts this)
* octal — `&52` (FPC only; Delphi has never supported octal — there `&`
is the identifier-escape prefix)
Underscore digit separators are permitted in any radix (`$FF_FF`,
`%1010_1010`). All four forms are accepted everywhere an integer literal
is — scalar and typed constants, expressions, static-array bounds, and
*array-constant element lists*. The last was the gap behind issue #129:
`array[0..1] of Int64 = ($1, $2)` rejected the hex elements while the
scalar form `= $1` accepted them. Array-constant elements now fold
through the same `ParseIntLiteral` radix parser the rest of the compiler
uses, so every radix behaves identically inside an initialiser.
The decision to keep FPC's `&` octal (rather than Delphi's no-octal /
`&`-as-escape) follows the project rule of preferring FPC syntax where the
two dialects diverge; the lexer already tokenised all four radixes, so the
fix only made the array-constant path consistent with the rest of the
language.
==== 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`, and when it resolves to `UInt64` / `QWord` to `_UInt64ToStr`, matching FPC's overloaded resolution
| `Int64ToStr` | `(Int64): string` | 64-bit signed integer to decimal string
| `UInt64ToStr` | `(UInt64): string` | 64-bit unsigned 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 signed 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` alias* — deferred. The FPC/Delphi `LongInt` name maps to
a 32-bit signed integer (identical to Blaise's `Integer`) and could
be added as a plain type alias in a future release. `SmallInt`,
`Int16`, `Word` and `UInt16` are now first-class 16-bit types, and
`Cardinal` remains an alias for `UInt32`.
---
=== Packed Records
==== Decision
Blaise supports the `packed record` qualifier with strict semantics:
* `packed record ... end` lays out fields at successive byte offsets
with no alignment padding between them, and applies no tail padding
to the record as a whole.
* `packed` is permitted only directly in front of `record`. The forms
`packed class` and `packed array`, accepted (often as no-ops) by FPC
and Delphi, are parse errors in Blaise.
* ARC-managed field types — `string`, class references, interfaces,
and dynamic arrays — retain their natural 8-byte alignment inside a
packed record. Other field types (`Byte`, `Word`/`SmallInt`,
`Integer`/`UInt32`, `Int64`/`UInt64`, `Double`, enums, sets,
static arrays of these, and nested packed/non-packed records)
pack tight.
* `MaxAlign` of a packed record collapses to `1` unless it contains
an ARC-managed field (in which case it stays at `8`). Arrays of
fully POD packed records therefore stride at the record's exact
field-byte sum.
==== Rationale
The intended use of `packed` is interop with binary formats whose
field layout is byte-addressed: file headers, network protocol
records, and ABI structures defined by external libraries. In every
such case the user wants byte-exact layout that matches an external
specification, and any silent padding the compiler inserts is a bug
from the caller's point of view.
The decision to *forbid* `packed class` and `packed array` rather
than silently accept them is deliberate: a `class` in Blaise carries
a vptr and lives on the heap behind an `_ClassAlloc` header, so
"packing" it would either lie (the qualifier is ignored) or break
runtime invariants. Arrays in Blaise are already either dynamic
(heap-managed, no inter-element padding) or static (element-strided
by the element's own size). Pretending `packed` applies to them
gives users false confidence in code that won't behave the way the
keyword suggests.
The exception for ARC-managed field types preserves a hard runtime
invariant: `_StringRelease`, `_ClassRelease`, `_IntfRelease`, and
`_DynArrayRelease` all perform 64-bit aligned loads through the
field pointer. Honouring a 1-byte-aligned ARC field would silently
slow x86_64 code and outright fault on stricter ARM targets. A
1-byte-stride layout for these types would also break the existing
ARC invariant that every reference-holding slot is loadable as a
single 8-byte word. Users who genuinely need a byte-exact layout
that includes a string field should use `Byte` arrays or a typed
pointer instead.
==== Examples
[source,pascal]
----
type
TPlain = record
A: Byte;
B: Int64;
end; { SizeOf = 16 (A at 0, pad to 8, B at 8, no tail) }
TPacked = packed record
A: Byte;
B: Int64;
end; { SizeOf = 9 (A at 0, B at 1, no tail) }
THybrid = packed record
A: Byte;
S: string;
end; { SizeOf = 16 (A at 0, pad to 8, S at 8) — S stays aligned }
----
==== Alternatives rejected
* *Always pack, even ARC-managed fields* — rejected. Unaligned 64-bit
loads through ARC pointers would either slow down or trap at runtime.
* *Per-field `packed` annotation* — rejected. The Delphi/FPC syntax
already covers the common case; finer-grained control is rare and
can be expressed with manual `Byte` arrays.
* *`{$ALIGN n}` / `{$PACKRECORDS n}` directives* — deferred. Blaise
does not yet have a compiler directive subsystem; if a use case for
intermediate alignments (2, 4) emerges, this is the obvious place
to add it later.
---
=== 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.
==== Default array property
An indexed property may carry the `default` directive, marking it as the
class's default array property:
----
property Items[I: Integer]: T read Get write Put; default;
----
A subscript applied directly to an object of that class — `Obj[I]` — is then
sugar for `Obj.Items[I]`: it lowers to the getter on read and the setter on
write, exactly as the named form does. This is the mechanism behind the
familiar `List[i]` syntax. Resolution walks the inheritance chain, so a
default property declared on a base class is usable through a derived class.
The `default` flag is carried across units in the `.bif` interface so a
default property declared in one unit keeps its subscript sugar when the unit
is used elsewhere.
Only one default property per class is meaningful (the first one found wins);
the directive is only valid on an indexed property. The accessor dispatch
honours `virtual`/`override` on the getter/setter, the same as a named indexed
property access.
---
=== 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 two
non-owning reference attributes to break cycles and to model borrowed
references — `[Weak]` and `[Unretained]` — applied to class- or
interface-typed fields and local variables. Identifying cycles in a codebase
is the principal migration cost when porting from manual-free FPC code.
===== `[Weak]`
`[Weak]` (analogous to `weak` in Swift) is a *safe* non-owning reference. The
runtime registers each weak slot in a weak table; when the referent is freed,
every weak slot pointing at it is automatically zeroed. Reading a weak slot
therefore yields either a live object or `nil` — never a dangling pointer.
[source,pascal]
----
TObserver = class
[Weak] FSubject: TSubject; { does not keep FSubject alive }
end;
----
Assignment lowers to `_WeakAssign` (which registers the slot) and scope
exit / field cleanup lowers to `_WeakClear` (which unregisters it). There is
a per-assignment cost for the registry bookkeeping.
Use `[Weak]` when the referent may be destroyed while the reference is still
reachable, and the holder must observe that (the slot going `nil`).
===== `[Unretained]`
`[Unretained]` (analogous to `unowned(unsafe)` in Swift) is an *unsafe*
non-owning reference. It performs *no* reference counting and *no* weak-table
registration: the field assignment is a plain pointer store, and field
cleanup does nothing for it. There is zero per-assignment overhead.
[source,pascal]
----
TNode = class
[Unretained] Parent: TNode; { back-pointer; parent outlives the child }
end;
----
Because there is no auto-nil, an `[Unretained]` field becomes a dangling
pointer if the referent is freed first. Use it *only* when the referent is
guaranteed to outlive the field — for example a back-pointer to an owner, or
a reference into a long-lived pool (the Blaise compiler uses it for the
`ResolvedType` set by semantic analysis, which points into the symbol table's
type pool that outlives the AST).
`[Weak]` and `[Unretained]` are mutually exclusive on the same declaration.
===== Choosing between them
[cols="1,1,1", options="header"]
|===
| | `[Weak]` | `[Unretained]`
| Keeps referent alive | no | no
| Auto-nil on referent free | yes | no (dangles)
| Per-assignment cost | weak-table register | none (plain store)
| Use when | referent may die first | referent outlives the field
|===
==== 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).
The `out` mode is, however, recorded distinctly from `var` on the AST:
`TMethodParam.IsOutParam` is set in addition to `IsVarParam`. The flag is
carried through unit-interface export/import (bit 3 of the param-flags pack
in the `.bif` format) so that tooling — the loader's `TRoutineSig`, the
debugger, and any future static-analysis pass — can recover the original
declared mode rather than seeing every by-reference parameter as `var`.
==== 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, so they produce
identical QBE IR. The distinction is purely a static-analysis /
documentation concern and is not yet enforced — but it is now *preserved*
in the AST and interface metadata (`IsOutParam`), which is the prerequisite
for enforcing it later.
==== Future work
A future lint pass could warn when an `out` parameter is read before it
has been assigned within the callee, and could zero-initialise `out`
parameters on entry. Both are now unblocked by the `IsOutParam` flag.
---
=== 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).
---
=== Calling-Convention Directives (`cdecl`, `stdcall`, …)
==== Decision
A routine or method declaration may carry a calling-convention directive —
`cdecl`, `stdcall`, `register`, `pascal`, or `safecall`. The directive is
recorded on the declaration (`TMethodDecl.CallingConv`), propagated into the
exported interface signature (`TRoutineSig.CallingConv`) and persisted in the
`.bif` interface metadata so it survives separate compilation.
==== Rationale
The directive is currently *metadata only*. The native backend emits the
System V AMD64 convention for every routine regardless of the directive; on
Linux x86_64 that is already the C ABI, so `cdecl` and the default agree and
external C calls link correctly without honouring the keyword at the ABI
level.
Preserving the directive rather than discarding it keeps the door open for:
* a future Windows/x86 target where `stdcall` and `cdecl` differ in stack
cleanup and must be emitted distinctly;
* FFI tooling and the debugger, which need the declared convention to
describe a symbol faithfully.
Discarding the keyword at parse time — the previous behaviour — would have
forced a parser and interface-format change later to recover information that
was present in the source all along. Recording it now is cheap and avoids
that churn.
---
=== 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`/`UInt64`/`String`/pointer types. This differs from the
standalone-variable allocation size on the stack, which always allocates
at least 4 bytes (QBE's smallest `alloc4`), 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[TEnum] of T` — enum-type index
| Supported
| `array[TSubrange] of T` — named integer-subrange index
| Supported. The subrange (`type TIdx = lo..hi;`) supplies the index range, so `array[TIdx] of T` folds to `array[lo..hi] of T`; the subrange's values remain ordinary unchecked integers of its underlying storage type.
| `array[L..H, M..N] of T` — multi-dimensional
| Supported
| `array of array of T` — multi-dim open array
| Separate feature
| `type TArr = array[L..H] of T` — named array type alias in type section
| Supported
|===
==== Named array type aliases
A named type alias for a static array may be declared in a `type` section:
[source,pascal]
----
type
TByteBuffer = array[0..63] of Byte;
TIntRange = array[1..10] of Integer;
var
Buf: TByteBuffer;
R: TIntRange;
----
The alias name is registered as a `tyStaticArray` symbol in the symbol table,
identical to an inline anonymous declaration. Any context that accepts an
inline `array[L..H] of T` declaration also accepts a named alias.
*Decision rationale:* The parser already handled `array[L..H] of T` as part of
`ParseTypeName` (used in variable declarations, parameter types, and record
fields), but the `ParseTypeDecl` path did not include `tkArray` as a valid
token on the right-hand side of a `type` section alias. The fix adds `tkArray`
to the existing `tkCaret | tkIdent` branch (which handles pointer aliases and
simple name aliases) so all three use the same `TTypeAliasDef` → `ParseTypeName`
path. The semantic pass already resolved `'array[L..H] of T'` style names via
`FindTypeOrInstantiate`; the only change needed there was to try that function
when `FTable.Lookup` returns nil for a type-alias name, rather than immediately
raising an error.
*Alternatives rejected:*
* Storing the array alias as a `TStaticArrayTypeDef` node: would require a new
AST node type and a new semantic pass. The alias-via-string-name approach
reuses the existing on-demand instantiation machinery at zero code cost.
* Requiring the alias to use a distinct keyword or syntax: rejected because FPC
and Delphi both use the plain `type TArr = array[...] of T` form.
==== 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` / `set of Byte` / `set of 0..255`)
==== Decision
Blaise supports Pascal bit-set types declared as `set of BaseType`, where
`BaseType` is a previously-defined enumeration, one of the small ordinal
types `Byte` (256 values, 0..255) or `Boolean` (2 values), or an integer
subrange within 0..255 (e.g. `0..9`, `1..10`, `0..255`). A set variable
holds a bitmask in which bit N is set when the member with ordinal N is
included.
==== Ordinal-based sets
In addition to enum-based sets, Blaise supports `set of Byte` and
`set of Boolean`. These ordinal-base sets use the same bitmap
representation as enum sets: `set of Boolean` is a 2-bit (1-byte) small
set, while `set of Byte` is a 256-bit (32-byte) jumbo set.
Set literals for ordinal-base sets accept integer literals and range
expressions:
[source,pascal]
----
type TByteFlags = set of Byte;
var F: TByteFlags;
begin
F := [1, 5, 10..20];
Include(F, 200);
if 15 in F then WriteLn('yes');
end.
----
Integer range expressions `[lo..hi]` are expanded to individual members at
compile time using `EvalConstIntExpr`. Bounds are validated against the
set's bit count (0..255 for Byte, 0..1 for Boolean).
A Boolean operand is accepted on either side of `in` and as the second
argument of `Include`/`Exclude` for a `set of Boolean`; the operand only
needs to be ordinal-compatible with the set's base type, not strictly
numeric. `True in s` and `Include(s, False)` therefore work directly,
without an intervening `Ord()`.
Larger ordinal types (`Integer`, `Word`, `Int64`) are rejected because they
would require impractically large bitmaps.
==== Integer-subrange base (`set of 0..255`)
The set base may also be an anonymous integer subrange, `set of L..H`, as
in standard Pascal, Delphi, and FPC:
[source,pascal]
----
type TDigits = set of 0..9; (* type-declaration form *)
var Small: set of 1..10; (* inline form in a var declaration *)
begin
Small := [1, 5, 8];
if 5 in Small then WriteLn('yes');
end.
----
The subrange lowers to the same bitmap machinery as `set of Byte`: the
bitmap is sized to `H + 1` bits (member ordinals are the integer values
themselves), so `set of 0..63` is a 64-bit small set and `set of 0..255` is
a 256-bit jumbo set. Both bounds may be integer literals, named constants,
or constant expressions, resolved through the same `ResolveArrayBound` path
used for static-array bounds.
The lower bound must be `>= 0`, the upper bound `<= 255`, and the range must
be ascending (`H >= L`). A descending subrange (`set of 5..3`) is a
semantic error rather than a silently-empty set, and an upper bound above
255 is rejected for the same 256-element ceiling that applies to every set.
The bitmap always starts at ordinal 0 — `set of 1..10` still reserves bits
for 0 — which keeps the representation identical to `set of Byte` and avoids
a base-offset in the `_Set*` RTL helpers.
==== Storage model
Storage scales with the base type's value count, so the common small-set
case stays a single fast machine register and larger sets remain correct:
- ≤ 32 values: a single QBE `w` (32-bit word) register bitmask.
- 3364 values: a single QBE `l` (64-bit long) register bitmask.
- 65256 values ("jumbo" sets): an inline byte-array bitmap of `ceil(N/8)`
bytes (≤ 32 for the 256-value ceiling). QBE has no integer type wider than
`l`, so a jumbo set cannot live in one register; it is treated as a value
aggregate (like a record or static array) — passed by reference, returned via
sret, zero-initialised with `memset`, copied with `memcpy` — and its
operations (`in`, `+`, `-`, `*`, `=`, `<>`, `Include`, `Exclude`, `for..in`)
are performed by the `_Set*` RTL helpers in `blaise_set.pas` rather than
inline register instructions.
- More than 256 values: rejected by the semantic analyser. 256 is the classic
Pascal maximum — a byte-indexed set covering ordinals 0..255 — matching FPC's
32-byte fixed-size set.
In every form, member ordinal N occupies bit `N mod 8` of byte `N div 8`, so
the bitmap layout is identical whether the set lives in a register or memory.
Sets are value types (copied on assignment), not heap-allocated.
The Java `EnumSet` design is the closest analogue: ≤ 64 members use a single
machine word (`RegularEnumSet`), and a wider universe transparently switches to
a multi-word bitmap (`JumboEnumSet`) with the same operations applied across the
words.
==== Set literal sizing (`X in [a, b, c]`)
The anonymous set type synthesised for an `in`-literal is sized to the **largest
ordinal actually listed** (when every element is a compile-time constant), not
the base enum's full member count. So `Token in [tkBegin, tkEnd]` over an
86-member token enum stays a fast register set, and only widens to a jumbo set
if a listed member's own ordinal is ≥ 64. The register membership test carries
a range guard so that testing an element whose ordinal exceeds the set's width
correctly yields `False` (rather than relying on an out-of-range shift). This
keeps the compiler's own pervasive `Kind in [...]` token tests off the jumbo
path and avoids a latent miscompile that the old fixed-`l` representation hid
(any listed ordinal ≥ 64 was silently dropped).
==== Set literals and disambiguation
Set literals use the same `[elem, ...]` bracket notation as array literals.
The semantic pass resolves the ambiguity from the target type:
- *Assignment* — when the LHS is a set type, the bracket expression is analysed
as a set literal; otherwise it is an array literal.
- *Call argument* — a bracket literal passed where a `set of <enum>` parameter
is expected is treated as a set constructor. Overload resolution matches it
when the members share the parameter's base enum (or the literal is empty);
the chosen call then re-types the argument to the parameter's set type so the
bitmask is emitted. This means a set value can be passed inline, e.g.
`Configure([optA, optC])`, without a named intermediate variable.
An empty literal `[]` carries no element type of its own, so it is only valid
where a set target supplies the type — a set assignment or a `set of`
argument. Used anywhere else (e.g. assigned to a non-set variable) it is a
semantic error, not a silent no-op.
==== 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.
==== Set-valued constants
A `const` may take a set literal on its right-hand side:
[source,pascal]
----
type
TDir = (dNorth, dSouth, dEast, dWest);
TDirSet = set of TDir;
const
Both = [dNorth, dEast]; // type inferred: set of TDir
Horizontal : TDirSet = [dEast, dWest]; // type annotated
None : TDirSet = []; // empty set
----
The members must all belong to a single enumeration. The constant's type is
that enum's set type: when the declaration is annotated (`: TDirSet`) that
declared set type is used (and the members are checked against its base enum);
otherwise the type is the inferred `set of <Enum>`. The bitmask is folded at
compile time into the constant's value, so a set const is emitted exactly like
any other integer set value — referencing it costs nothing at runtime.
Because set values are structural (not nominal), an inferred `set of TDir`
constant is assignment-compatible with a variable declared as the named alias
`TDirSet`; `CheckTypesMatch` treats two `tySet` types over the same base enum
as the same type.
The empty set `[]` is only allowed with a type annotation: with no members and
no declared type there is nothing to infer the base enum from, so
`const Bad = [];` is a semantic error.
Set-const resolution runs in the second constant-analysis pass (alongside
array constants), after enum types and their members have been registered —
the member identifiers are not in scope during the first pass.
==== 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.
- *`set of Integer`* — rejected. Integer has 2^32 possible values; a bitmap
would require 512 MB. Only Byte (256 values) and Boolean (2 values) are
accepted as ordinal base types alongside enumerations.
- *Integer-literal set constants* — deferred. The `const X = [1, 2, 3]`
parser path currently only accepts identifier (enum) members. Ordinal-base
set literals work in var/assignment/argument context; set constants with
integer members require parser extensions.
---
=== Procedural Types (`function`/`procedure` pointers)
==== Decision
Blaise supports two procedural type forms:
- *Bare procedural types* — pointers to standalone (non-method) functions.
Storage: a single QBE `l` (8-byte code pointer).
- *Method-pointer types* — declared with the `of object` suffix. Pointers
to instance methods carry both a code pointer and a `Self` pointer.
Storage: a 16-byte block, `Code` at offset 0, `Data` at offset 8.
[source,pascal]
----
type
TIntFn = function: Integer;
TStrFn = function(const S: string): Integer;
TLogProc = procedure(Level: Integer; const Msg: string);
TRunMethod = procedure of object;
TIsTrue = function (X: Integer): Boolean of object;
var
F: TIntFn;
G: TRunMethod;
begin
F := @MyFn;
X := F(); // indirect bare-pointer call
G := TRunMethod(SomeMethod); // assigned via TMethod cast
G; // calls Code(Data, ...)
end;
----
A bare-procedural variable holds a single function code pointer. An
indirect call `F(args)` loads the pointer from `F` and emits a QBE
indirect call.
A method-pointer variable holds a 16-byte block. An indirect call loads
both halves, then emits `call code(l data, args...)` — `Data` is passed
as the implicit first argument so the callee sees it as `Self`. The
intrinsic record `TMethod = record Code, Data: Pointer end` has the
exact same byte layout, so the cast `TMyMethod(m)` for `m: TMethod` is a
no-op at the QBE level. This is the canonical xUnit dispatch pattern:
[source,pascal]
----
m.Code := MethodAddress(Self, FName);
m.Data := Self;
RunMethod := TRunMethod(m);
RunMethod;
----
Type compatibility requires return types to match (both nil or both the
same `TTypeDesc`), parameter lists to match pairwise on type and mode
(`var`/`const`/value), AND the `IsMethodPtr` flag to match — a bare
procedural pointer and a method pointer with otherwise-identical
signatures are *not* compatible. Parameter names do not participate.
==== Rejected / deferred
- *`reference to function/procedure`* (anonymous methods / closures) —
*deferred.* Closures need an environment record carrying captured
locals, far beyond what a (Code, Data) pair represents. No current
Blaise feature needs them.
- *Calling-convention markers* (`cdecl`, `stdcall`) on procedural types —
*deferred.* The current self-hosting target uses the System V x86_64
ABI uniformly; no use case yet requires non-default conventions on
procedural pointers.
==== Rationale
Bare procedural types are sufficient for callback APIs that do not need
instance state (the original punit RTL test framework's `TTestRun` /
`TTestSetup` hooks, sort comparators, signal handlers).
Method pointers are required by every xUnit-style test framework: both
fpcunit (`TRunMethod = procedure of object`) and fptest
(`TTestMethod = procedure of object`) dispatch their tests through them.
The 16-byte fat-pointer representation matches FPC and Delphi exactly;
the byte-equivalence with `TMethod` lets the canonical
`TRunMethod(LMethod)` cast pattern work unchanged in ported code.
A single `IsMethodPtr` flag on the type descriptor keeps both forms in
one descriptor class. The flag participates in `IsCompatibleWith` so a
method pointer cannot accidentally bind to a bare-procedural variable,
preventing ABI mismatches at call sites that would otherwise corrupt
arguments.
The `reference to` form is deliberately scoped out: closures interact
with ARC lifetimes through the environment record's captured class
references, which would push the design into territory that no current
Blaise consumer requires.
---
== 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_.
* Generic collection iteration (Step 9b): `for X in List do` where `List` is
a generic class instantiation (e.g. `TList<Integer>`) — requires
`GetEnumerator` / `MoveNext` / `Current` property on the instantiated type.
Property cloning in `InstantiateGeneric` is implemented; works for any
generic class that exposes the enumerator protocol.
* Set iteration (Step 7d): `for X in S do` where `S` is a `set of TEnum`
value — iterates the members of the set in ascending ordinal order. The set
expression is evaluated once before the loop begins. The loop variable must
be an ordinal type compatible with the set's base enum.
==== What is not yet supported
All known for..in collection kinds are now implemented.
==== Set iteration desugaring
`for X in S do Body` where `S: set of TEnum` desugars to a bit-scan loop:
[source,pascal]
----
{ for X in S do Body }
__setmask := S; { evaluate S exactly once }
__idx := 0;
while __idx < BitCount do
begin
if (__setmask shr __idx) and 1 <> 0 then
begin
X := TEnum(__idx);
Body
end;
__idx := __idx + 1
end;
----
The set expression is evaluated into a synthetic `__setmask` slot before the
loop so that side effects (if any) occur once, matching Delphi semantics.
Iteration is always in ascending ordinal order (bit 0 first), regardless of
the order in which members were added to the set literal.
The loop variable must be an ordinal type; using a non-ordinal variable (e.g.
`string`) is a semantic error. Assigning to an `Integer` variable is
permitted (enum/integer ordinal compatibility applies, as elsewhere in Blaise).
==== 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.
=== Integer division by zero
==== Decision
Integer `div` and `mod` by zero raise a catchable `EDivByZero` exception
(declared in `SysUtils`, a subclass of `Exception`) rather than trapping in
hardware. The code generator emits a divisor-equals-zero check before each
integer `idiv`; when the divisor is zero it calls the runtime helper
`_RaiseDivByZero`, which raises `EDivByZero('Division by zero')` through the
normal exception machinery. Both backends (QBE and native x86-64) emit the
guard.
The guard is emitted *only when `SysUtils` is in scope* — i.e. when
`EDivByZero` resolves through the active symbol table. A program that does
not use `SysUtils` cannot name the exception class anyway, and its integer
division falls through to the hardware behaviour (a `SIGFPE` trap) as before.
This mirrors Delphi, where `EDivByZero` lives in `System.SysUtils` and the
catchable behaviour is tied to that unit.
==== Rationale
A bare hardware trap is uncatchable: a `try`/`except` around `a div b`
cannot recover from a zero divisor, so a single bad input terminates the
whole process. Routing division by zero through the exception system makes
it recoverable, matching standard Pascal/Delphi semantics and the
expectations of code that validates input by catching the exception rather
than pre-checking every divisor.
==== Alternatives Rejected
* *Leave it as a hardware trap* — zero runtime cost, but a zero divisor
crashes hard and uncatchably; unacceptable for code that handles untrusted
input.
* *Define `EDivByZero` in the always-linked runtime so the guard is
unconditional* — the runtime (`blaise_exc.pas`) deliberately does not
depend on stdlib, and exception classes (with their vtable + TypeInfo) are
a stdlib concern. Gating the guard on `SysUtils` keeps the runtime free of
class machinery and matches Delphi's unit placement.
---
== 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.
---
== Function and Method Overloading
=== Decision
Blaise supports overloading of standalone procedures, standalone
functions, and class methods. The `overload` directive is *required* on
every declaration that participates in an overload set: omitting the
directive on any same-named declaration is a compile-time
"duplicate identifier" error. Resolution at the call site follows
Delphi semantics — exact-type match is preferred over widening match,
and an unresolvable tie is a compile-time "ambiguous overload" error.
=== Rationale
Requiring the directive on every overload (rather than inferring it
from duplicate names, as FPC's `{$mode objfpc}` does) keeps overload
sets explicit at the source level: a reader can see at a glance that a
declaration is intended to participate in dispatch, and accidental
shadowing of an unrelated symbol cannot silently introduce an
overload. Delphi's preference for exact match avoids the "promotion
ambiguity" pitfalls that surface when an `Integer` argument matches
both an `Integer` and a `Double` overload — exact match always wins,
ambiguity is escalated to the user.
=== Overload sets merge across inheritance
When a derived class declares an `overload` method whose name matches an
overload inherited from a base class, the two sets *merge*: both the
inherited and the newly declared variants remain callable on the derived
type.
[source,pascal]
----
TBase = class
function F(x: Integer): string; overload;
end;
TDerived = class(TBase)
function F(s: string): string; overload; // adds to, does not replace
end;
var d: TDerived;
begin
d.F(5); // resolves to TBase.F(Integer)
d.F('a'); // resolves to TDerived.F(string)
end;
----
This follows Delphi: a derived method marked `overload` extends the
inherited overload set rather than hiding it. A derived method declared
*without* `overload` still hides every inherited variant of that name (the
conventional "redeclaration hides" rule) — only the `overload` directive
opts into merging. Resolution collects candidates up the inheritance
chain until it reaches a level that declares the name without `overload`,
then scores the combined set with the normal exact-over-widening rules.
=== Name mangling
Each overload is emitted under a distinct QBE symbol name derived from
its parameter signature. The mangling scheme uses short type codes
appended after `$`:
[cols="1,3"]
|===
| Code | Meaning
| `i` | Integer (32-bit signed)
| `l` | Int64
| `d` | Double
| `s` | Single
| `b` | Boolean
| `y` | Byte
| `S` | string
| `p` | Pointer / PChar
| `R<Name>` | record or class type
| `^X` | typed pointer to X
| `@X` | var/out parameter of X
|===
Example: `procedure Log(const S: string; N: Integer); overload;` is
named `Log$Si` after type-code mangling, then escaped through `QBEMangle`
to the QBE symbol `$Log_D_Si` (the leading `$` sigil is preserved; any
embedded sigil characters are escaped as listed below). Phase A of the
implementation uses an arity-only suffix (`$N<arity>`) as a temporary
mangling — the type-code scheme above becomes the permanent form once
Phase B (type-distinct resolution) lands.
The mangled name still contains the original sigil characters (`$`,
`@`, `^`) which conflict with the QBE symbol grammar in some downstream
toolchains. `QBEMangle` rewrites them as alphabetic escapes before the
name is emitted to IR:
[cols="1,3"]
|===
| Sigil | Escape
| `$` | `_D_` (overload delimiter)
| `@` | `_V_` (var-param prefix)
| `^` | `_P_` (pointer prefix)
|===
Function definitions, call sites, and vtable data entries all route
through `QBEMangle`, so the same overload always reaches the same QBE
symbol — a previous mismatch between definition and call site is now
covered by the regression tests in `cp.test.overload.pas`.
=== Alternatives rejected
* *Implicit overloading on duplicate names.* FPC's objfpc mode allows
this. Rejected because it makes accidental shadowing
indistinguishable from intentional overloading.
* *Numeric suffixes (`Greet$1`, `Greet$2`).* Simpler to implement but
obscures the signature in debugger backtraces and IR diffs. Type
codes are deterministic and human-decodable.
* *FPC-style "first-fit" resolution.* Picks the first declared
overload whose parameters can be coerced. Rejected in favour of
Delphi's exact-match preference, which surfaces ambiguity rather
than silently picking an order-dependent answer.
=== Class methods
A virtual method that is overloaded must carry both `virtual` and
`overload`. Overrides in descendants must carry both `override` and
`overload`. Each `(virtual, overload)` pair is a *distinct* vtable
slot — the override targets the slot whose mangled signature matches
its own. This means you cannot override the wrong overload by
accident, and the compiler will report a missing override target if
the descendant's signature matches no base-class slot.
---
== Default Parameter Values
=== Decision
Trailing parameters of a procedure, function, constructor, destructor,
or class method may carry a default value introduced with `=`. A call
site may omit any trailing argument whose corresponding parameter has
a default. The compiler fills the missing slots with a fresh AST
clone of the default expression at each call site.
A default value is permitted only when *all three* conditions hold:
* the parameter group declares a single name (no comma list);
* the parameter is not `var`/`out`;
* the parameter is not an open array.
A default value must be a literal of the parameter's type or a named
constant whose resolved type matches. Permitted forms: integer
literal, float literal, string literal, the `nil` keyword, and a bare
identifier that resolves to a `skConstant` symbol. Arbitrary
constant expressions are *not* permitted in this iteration.
=== Rationale
Default values close the most common ergonomics gap between FPC and
Blaise: porting library code (such as the `punit` test framework's
`AddTest('name', @proc)` four-arity wrapper around the three-arity
core) requires either a small explicit overload pyramid or default
arguments. The pyramid bloats the source and the QBE IR by a constant
factor; default values let one declaration cover all the omission
patterns Delphi callers expect.
Restricting the default to literals and named constants keeps the
constant-expression evaluator out of the parameter-decl path. An
arbitrary constant expression would require running the full constant
folder during forward-decl analysis, which currently happens only
during statement analysis. Literals and named constants suffice for
the FPC RTL, the Blaise RTL, and every port we have surveyed; the
restriction can be relaxed later without invalidating earlier code.
Restricting to single-name groups keeps the syntax unambiguous —
`A, B, C : Integer = 0` could plausibly mean "all three default to
zero" or "only `C` has a default", and the compiler refuses to guess.
=== Forward declarations and overrides
A default value declared on a *forward* (unit interface) declaration
is transferred by ownership to the matching parameter on the
*implementation* declaration during interface↔impl reconciliation
(`TransferDefaultValues` in `uSemantic`). This means a unit author
writes the default once, on the interface, and the implementation may
either repeat it or omit it — both are accepted.
Defaults are not inherited by overrides: a descendant's overriding
method must repeat any defaults it wants to expose. This matches
Delphi semantics and avoids surprising callers of an override that the
defaults silently changed underneath them.
=== `inherited` in expression position
`inherited Method(args)` is accepted both as a statement and as an
*expression*. The statement form (`inherited Setup();`) calls the
parent method for its side effects; the expression form lets an
overriding *function* build on its parent's result:
[source,pascal]
----
function TDerived.Value: Integer; override;
begin
Result := inherited Value() + 100; // call parent, then extend
end;
----
Both forms lower to the same thing — a *static* (non-virtual) call to
the parent's method slot, bypassing vtable dispatch (that is the whole
point of `inherited`: reach the specific parent implementation, not
re-dispatch to `Self`). The only difference is what happens to the
result: the statement form stores it into the current method's `Result`
slot (so `inherited F();` still sets `Result`), while the expression
form yields the value to the surrounding expression.
Like every other call, an `inherited` call carries mandatory
parentheses even with no arguments (see _Mandatory parentheses on
zero-argument calls_): `inherited Create()`, never `inherited Create`.
`inherited` is not an exception to that rule — it is an ordinary method
call that happens to target the parent slot, so a bare `inherited
Method` is rejected at parse time with the same _requires () for a
call_ diagnostic as any other parenless call.
The expression form was added because the statement-only form forced an
awkward two-step (`inherited F(); X := Result + 1;`) and could not be
used inside a larger expression at all. AST: `TInheritedCallExpr`
(the sibling of `TInheritedCallStmt`); both resolve and lower through
the same parent-method lookup and the same static-dispatch call
sequence (`EmitInheritedCallSeq` on native, `EmitInheritedCallExpr` on
QBE).
=== Overload resolution and tie-break
The arity filter in `ResolveStandaloneOverload` and
`ResolveMethodOverload` accepts a candidate when
`MinArity(Cand) <= ArgCount <= ParamCount(Cand)`, where `MinArity` is
the count of leading parameters before the first one carrying a
default. Where two candidates score equally on type compatibility,
the one needing fewer defaulted slots wins:
[source]
----
CompositeScore = (TypeScore * 16) - (ParamCount - ArgCount)
----
So given:
[source,delphi]
----
function F(A: Integer; B: Integer = 0): Integer; overload;
function F(A: Integer): Integer; overload;
----
the call `F(42)` resolves to the second overload (no defaulting
needed), not the first — even though both type-score equally.
=== Alternatives rejected
* *No default values; force explicit overload pyramids.* Every port
that wants `AddTest('name', @proc)` short-form would have to either
add an explicit two-arg overload or rewrite the call site. For a
feature whose semantic and codegen impact is small (literal clone +
arity-range filter + tie-break), the porting cost outweighed the
saving in compiler complexity.
* *Default values as arbitrary constant expressions.* Requires
hoisting the constant folder into forward-decl analysis. Deferred
until a real use case appears.
* *Defaults stored as a token sequence and re-parsed at each call
site.* Would avoid the AST-clone step. Rejected because it
fragments the semantic model — every other AST node is fully parsed
before semantic analysis begins.
---
== Metaclass References
=== Decision
A bare class type identifier (e.g. `EError`) used in a value position
is a *metaclass reference*: a compile-time constant of type `Pointer`
whose value is the class's typeinfo address. This matches the value
that `vtable[0]` holds for any instance of that class, so the
identity comparison `Obj.ClassType = EError` is true exactly when
`Obj` is an instance of (the same exact) `EError`.
The semantic analyser sets `IsMetaclassRef := True` on the
`TIdentExpr`, retypes the expression to `FTable.TypePointer`, and
codegen emits `$typeinfo_<Name>` as an `l copy` immediate.
=== Rationale
A class identifier in value position is the canonical Pascal/Delphi
spelling for the runtime class object: `class of TObject` (i.e.
`TClass`) values, `Obj is EError` checks, exception filters in `try ...
except on E: EError do`, and the `AClass: TClass` parameter pattern
all expect to receive the typeinfo pointer. Without the metaclass
ref, every such site has to be rewritten as `EError.ClassType` (which
is a method call requiring an instance) or otherwise hand-rolled —
none of which port cleanly from existing FPC/Delphi sources.
The result type is `Pointer` rather than a dedicated `TClass`/`tyMetaclass`
kind because most receiving signatures already use `Pointer` (the
`AClass: Pointer` parameter of punit's `ExpectException`, the
`TClass(EError)` cast result, etc.). A dedicated `tyMetaclass` kind
would be more precise and would enable RTTI dispatch on the metaclass
itself, but is left for a future iteration.
=== Examples
[source,delphi]
----
ExpectException('msg', EError); // metaclass ref → Pointer
P := Pointer(EFail); // explicit cast — same value
if A.ClassType = EError then … // identity match
----
=== Alternatives rejected
* *Require explicit `EError.ClassType` everywhere.* Loses Delphi
source-level compatibility; every port has to be rewritten.
* *Treat the bare identifier as type `tyClass` and rely on `tyClass`
`tyPointer` compatibility at call sites.* Rejected because codegen
would still try to load it through a non-existent variable slot.
The semantic mark-up has to drive the codegen path explicitly.
* *Introduce a dedicated `tyMetaclass` kind right now.* Defers the
feature until the symbol-table descriptor work lands and is not
necessary for the present porting needs.
---
== `ClassCreate` — Runtime Construction from a Metaclass Value
=== Decision
The compiler exposes a built-in function `ClassCreate(Cls, ...args)` that
allocates an instance of the class denoted by `Cls` (a value of type `class
of T`) and dispatches the constructor body via vtable, so the most-derived
override runs.
[source,pascal]
----
var
Cls: class of TTestCase;
Inst: TTestCase;
begin
Cls := TFooTests; // metaclass = typeinfo pointer
Inst := ClassCreate(Cls, 'TestSomething'); // vtable dispatch → TFooTests.Create
end;
----
The first argument is required and must be of type `tyMetaClass`. Any
remaining arguments are passed to the constructor on `Cls.BaseClass`.
The result type is `Cls.BaseClass`, so the call expression assigns
cleanly to a variable of the base class type.
`ClassCreate` is the lower-level primitive; for most code the
`MetaclassVar.Create(args)` syntax (see next section) is preferred.
=== Rationale
The xUnit pattern needs to instantiate one test fixture per published
test method, but the fixture class is only known through a `class of
TTestCase` registered earlier. Lowering this to the standard
`TFoo.Create(args)` codegen path is impossible — that lowering bakes
the concrete TotalSize, vtable label, and field-cleanup label into the
emitted IR, all of which must come from the runtime class value.
The split design — a generic RTL helper `_ClassCreate(TInfo)` that
allocates and installs the vtable, plus a vtable-indirect call to the
constructor body — dispatches to the most-derived constructor without
the caller knowing the concrete class. The helper reads the three
additional typeinfo slots (totalsize, fieldcleanup, vtable) added in
Step 11e for exactly this purpose.
The constructor lookup is by name (`Create`). Overload resolution on
the constructor signature is not yet implemented; in practice the only
two shapes used are `TObject.Create` (zero args) and `TTestCase.Create
(AName: string)`, neither of which collide. When a richer constructor
mix appears, the resolution path can be widened without changing the
ClassCreate surface.
=== Alternatives rejected
* *Use a factory function pointer per class.* Forces every test class
declaration to add `, @TFoo.Create` (or similar) at the
`RegisterTest` call site, polluting the cp.test.* migration
mechanically required by Step 11f. The runtime construction path
exists either way; this just moves where the boilerplate lives.
---
== Implicit-Virtual Constructor Dispatch via Metaclass
=== Decision
When a constructor is called through a metaclass-typed variable
(`C.Create(args)` where `C: class of TBase`), the compiler emits
runtime vtable dispatch so the most-derived constructor body runs.
Direct calls (`TFoo.Create(args)` where `TFoo` is a concrete type
name) remain fully static.
Constructors are auto-slotted into the vtable by the semantic pass.
A constructor name that matches an ancestor's constructor overrides
the ancestor's vtable slot; new constructor names get a fresh slot.
No `virtual` or `override` keywords are required on constructors —
the dispatch is implicit and transparent.
[source,pascal]
----
type
TAnimal = class(TObject)
constructor Create;
end;
TDog = class(TAnimal)
constructor Create; // implicitly overrides TAnimal.Create's vtable slot
end;
var
C: class of TAnimal;
begin
C := TDog;
C.Create(); // dispatches to TDog.Create via vtable
TAnimal.Create(); // static call to TAnimal.Create (no dispatch)
end;
----
The lowering is:
1. `_ClassCreate(C)` — reads totalsize, fieldcleanup, and vtable from the
typeinfo pointer `C`; allocates and zeroes the instance; installs the
vtable; calls `_ClassAddRef`.
2. Load the vtable pointer from the new instance (`[instance+0]`), index
into the constructor's slot, and call indirectly.
Both the `C.Create(args)` syntax and `ClassCreate(C, args)` use this path.
=== Rationale
The xUnit test runner instantiates fixtures through `class of TTestCase`
and must run the derived class's constructor, not the base. Without
virtual dispatch, `ClassCreate` called the base constructor statically —
a derived class with initialisation in its own `Create` would silently
skip it.
Making constructors implicitly virtual only through metaclass-typed
receivers preserves the zero-overhead guarantee for direct
`TFoo.Create` calls (the overwhelming majority) while giving metaclass
dispatch the polymorphic behaviour users expect. Requiring explicit
`virtual`/`override` on constructors was considered and rejected: it
would force boilerplate on every class in the hierarchy, and the mental
model of "metaclass dispatch = polymorphic" is intuitive without it.
The vtable slot is assigned by constructor name (same name = same slot,
new name = new slot), matching the existing virtual-method slot
assignment algorithm.
=== Alternatives rejected
* *Require `virtual`/`override` on constructors.* Imposes annotation
burden on every class for a behaviour that only manifests through
metaclass dispatch. Direct `TFoo.Create` does not benefit from the
annotation. The implicit model is simpler and matches Delphi's
`virtual constructor` behaviour without the keyword noise.
* *Dispatch constructors through a separate RTTI table instead of the
vtable.* Adds a parallel lookup mechanism that must be kept in sync.
Constructors are regular methods with a `Self` parameter; the vtable
is the natural dispatch vector.
* *Keep constructors static even through metaclass dispatch.* This was
the original `ClassCreate` implementation. It fails the test-runner
use case: the derived fixture's constructor never runs, so the test
fixture is uninitialised.
---
== `case` Statement on String Selector
=== Decision
A `case` statement may use a `string`-typed selector. Each branch label
must be a string-typed expression (typically a string literal); the
runtime comparison uses `_StringEquals` against the selector value.
[source,pascal]
----
case S of
'foo': HandleFoo;
'bar': HandleBar;
'baz': HandleBaz;
else
HandleOther;
end;
----
The semantics are equivalent to a chain of `if S = 'foo' then ... else
if S = 'bar' then ...` tests, evaluated in source order.
=== Rationale
The compiler's own source uses small one-letter strings (`'w'`, `'l'`,
`'d'`, `'s'`) as QBE type tags and switches on them in several places.
Forcing those sites into hand-rolled if/else chains for the sake of
self-hosting was rejected as a step backwards — Object Pascal users
have always read `case`-on-string as idiomatic, and Delphi/FPC accept
it. Implementation cost is small (lower to a chained equality test;
no jump tables) and the codegen is local to `EmitCaseStmt`.
The selector is evaluated exactly once. Each branch label is
evaluated when its turn comes; because string literals are immortal,
order has no observable side effect.
=== Alternatives rejected
* *Refactor every internal `case S of` to if/elseif chains.* Hides
intent at the call site, repeats the selector expression, and would
have to be re-applied every time a new tag-switch site appears.
The language feature pays for itself the first time it's reused.
* *Restrict labels to string literals only.* Would simplify the
semantic check, but ruins compositions like `case Tag of EmptyName,
WildcardName: ... end`. Allow any string-typed expression; the
runtime comparison handles whatever value flows in.
* *Build a hash table for large dispatches.* Premature. All current
uses are 26 branches, where chained equality is faster than any
hash overhead. Revisit if a case ever has dozens of labels.
---
== Triple Single-Quote Text Blocks
=== Decision
Blaise supports multi-line string literals delimited by triple single
quotes (`+'''...'''+`). The opening `'''` must be followed immediately by
a newline; the closing `'''` must appear on its own line (possibly preceded
by whitespace). The column position of the closing `'''` sets the
indentation baseline: all content lines are de-dented by that many columns.
Single quotes inside the block require no escaping.
Example:
[source,pascal]
----
const
Src = '''
program Hello;
begin
WriteLn('world');
end.
''';
----
The value of `Src` is the four-line program with no leading whitespace,
because the closing `'''` is at column 5 (four spaces of indent), and
all content lines have at least four leading spaces stripped.
=== Rationale
Pascal is a single-quote language; triple-quoting follows naturally from
the existing `''` escape convention. The design mirrors Java 13+ text
blocks — one of Java's most well-received modern additions.
The primary motivation is the compiler test suite: every `cp.test.*.pas`
file constructs multi-line source strings with `'line' + LineEnding +
'line'` concatenation. Text blocks eliminate this concatenation noise,
making inline source snippets readable and maintaining them trivial.
Margin stripping based on the closing delimiter position keeps text blocks
tidy inside indented method bodies without accumulating spurious leading
whitespace in the string value.
Embedded single quotes (e.g. `'John'`, `#10`) require no escaping, which
is essential for Pascal source literals in the test suite.
=== Disambiguation
When the lexer encounters three consecutive single-quote characters, it
peeks at the next character:
* If the next character is a newline (LF, CR, or end-of-file), it opens
a text block.
* If the next character is anything else — including another `'`, a letter,
a digit, or any printable character — it falls back to the classic string
parse. This means `'''text'''` (a string containing `'text'` via the
standard `''` escape) continues to work unchanged.
This is a one-character lookahead — no backtracking or multi-token
lookahead is required.
=== Alternatives rejected
* *Heredoc syntax* (`<<'SENTINEL' ... SENTINEL`) — alien to Pascal;
requires sentinel matching rather than simple delimiter detection.
* *Backtick raw strings* (`` `...` ``) — cannot embed backtick characters;
backtick has no history in Pascal.
* *Triple curly braces* (`{{{ ... }}}`) — visually heavy; conflicts with
the comment delimiter namespace.
* *Keyword heredoc* (`beginstring SENTINEL ... SENTINEL`) — verbose; adds
a new keyword.
This is a Blaise-only language feature — not FPC/Delphi compatible — and
that is intentional: it is the kind of feature that makes Blaise worth
using rather than just being "FPC with a different name".
---
== `Supports()` — Runtime Interface Membership Test
Blaise provides `Supports()` as a compiler intrinsic with two overloaded forms:
[source,pascal]
----
function Supports(Obj: TObject; IntfType: <type-name>): Boolean;
function Supports(Obj: TObject; IntfType: <type-name>; out OutVar: IntfType): Boolean;
----
The second argument is a *type name*, not a runtime value. This is why `Supports` is
a compiler intrinsic rather than a library function: the parser treats the second
(and optional third) argument positionally rather than as an expression.
=== Decision
`Supports()` is a compiler intrinsic parsed specially in `ParseFactor` when the
identifier `Supports` is immediately followed by `(`. The second argument is consumed
as a bare identifier (interface type name), and the optional third argument is consumed
as a bare identifier (output variable name).
=== Rationale
* *No GUIDs* — Blaise does not have GUID-based interface identity (Delphi `IID`
dispatch). Interface type identity is determined structurally via the `typeinfo_*`
symbols emitted by the compiler. Passing a GUID at runtime would require a
separate GUID declaration infrastructure.
* *Intrinsic vs. library function* — A library `Supports(Obj, IFoo)` would require
`IFoo` to be a runtime value, which it is not; it is a compile-time type name.
Making it an intrinsic keeps the call site idiomatic without requiring special
"type value" syntax.
* *Two forms* — The 2-arg form is a pure Boolean test: it calls the RTL
`_ImplementsInterface(obj, typeinfo_IFoo)`. The 3-arg form additionally obtains the
correct `itab` via `_GetItab` and writes both the obj and itab pointers into the
output variable's fat-pointer slots under full ARC (retain new, release old).
* *Delphi compatibility at the call site* — Delphi's `Supports` is a library function
in `SysUtils` with GUID overloads. Blaise's form is syntactically identical for the
common use-cases (`Supports(X, IFoo)` and `Supports(X, IFoo, Ref)`) even though the
underlying mechanism differs.
=== Alternatives Rejected
* *`is` operator extended to assignment* — Considered `if (Obj is IFoo) := Ref then`
syntax, rejected as non-standard and confusing.
* *Explicit `QueryInterface` method* — Requires the programmer to manage typeinfo
pointers manually; the intrinsic is safer and more ergonomic.
== Generic Interfaces and the Collections Framework
=== Decision
Blaise supports generic interfaces — interface types with type parameters —
using the same `<K, V>` syntax as generic classes:
[source,pascal]
----
type
IMap<K, V> = interface
procedure Add(Key: K; Value: V);
function TryGetValue(Key: K; var Value: V): Boolean;
function ContainsKey(Key: K): Boolean;
procedure Remove(Key: K);
function GetCount: Integer;
end;
----
A generic class implements a generic interface by listing the instantiated
interface name in its parent clause:
[source,pascal]
----
TDictionary<K, V> = class(IMap<K, V>)
...
end;
----
The RTL (`rtl/src/main/pascal/generics.collections.pas`) provides the
canonical collection types. New code — tests, compiler passes, and RTL
units — must use these types rather than writing inline collection logic:
[cols="1,2", options="header"]
|===
| Type | Purpose
| `TList<T>` | Ordered, growable list
| `TStack<T>` | LIFO stack
| `TQueue<T>` | FIFO queue
| `TSet<T>` | Unordered membership set
| `TDictionary<K,V>` | Key→value map (unordered)
| `TOrderedDictionary<K,V>` | Key→value map (insertion-ordered)
| `IMap<K,V>` | Map interface abstracting both dictionary types
|===
=== Rationale
* *Interface abstraction over concrete types* — `IMap<K,V>` lets callers
accept either `TDictionary` or `TOrderedDictionary` without caring which
implementation is behind the variable. This is the primary use-case for
interfaces in a generics-era codebase.
* *No `var` parameter type inference gap* — Interface method signatures
carry var-param flags in `TInterfaceTypeDesc.FParamIsVar` (a per-method
comma-separated `0`/`1` string built during semantic analysis). The
codegen uses these flags to pass var params as addresses (`l`) rather than
values (`w`) at interface dispatch sites, where no concrete `TMethodDecl`
is available.
* *Reuse over reinvention* — Inline collection logic written for one test
or compiler pass is never tested beyond that narrow context. Routing all
collection use through the RTL types means every new feature exercises the
same code paths and bugs surface early.
* *Structural interface identity* — As with plain interfaces, generic
interface identity is based on the `typeinfo_*` symbol emitted at
instantiation time, not on GUIDs. `IMap<Integer,Integer>` and
`IMap<String,Integer>` are distinct types with distinct typeinfo pointers.
=== Implementation notes
When the semantic analyser instantiates a generic class that lists a generic
interface in its parent clause (e.g. `TDictionary<K,V> = class(IMap<K,V>)`),
`InstantiateGeneric` detects that the substituted `ParentName` resolves to
`tyInterface`, moves it to `ImplementsNames`, and calls `AddImplements` on
the concrete `TRecordTypeDesc`. This wires the type-compatibility check that
allows `M: IMap<Integer,Integer> := TDictionary<Integer,Integer>.Create`.
The codegen emits itab and impllist data for generic class instances in
`EmitInterfaceDefs`, parallel to the existing path for non-generic classes.
Both class and interface names in itab symbols are passed through `QBEMangle`
to produce valid QBE identifiers (e.g. `$itab_TDictionary_Integer_Integer_IMap_Integer_Integer`).
A non-generic class may also *inherit* from a generic-class instance — e.g.
`TIntBox = class(TBox<Integer>)`. The first heritage entry is instantiated and
then classified: a `tyInterface` result becomes an implements entry (above),
any other (a class instance) is the parent class. Because the generic instance
is emitted under its `QBEMangle`'d symbol (`typeinfo_TBox_Integer`), the
inheriting class's typeinfo must reference the parent through `QBEMangle` too,
not the unmangled name — otherwise the emitted QBE identifier contains the
illegal `<`/`>` characters.
=== Alternatives Rejected
* *Structural duck-typing without interfaces* — Blaise already uses
structural protocols for `for..in` (GetEnumerator/MoveNext/Current).
Extending that pattern to maps would mean callers cannot name the
abstraction or store it in a typed variable. An explicit interface is
clearer and more composable.
* *Non-generic `IMap` with `TObject` keys and values* — Pre-generics
workaround; loses type safety and requires casts at every use site.
== Dynamic Arrays (`array of T`)
=== Decision
Blaise supports heap-allocated, reference-counted dynamic arrays using the
Delphi/FPC syntax `array of T`. A dynamic array variable holds a pointer to
element 0; the two 4-byte integers immediately before element 0 store the
reference count and the current length (the "header").
Memory layout (contiguous block)::
`[refcount:4][length:4][element 0][element 1]…[element N-1]`
A nil pointer (zero value) represents an unassigned array. The variable slot
is 8 bytes on all platforms (a pointer).
Supported built-ins::
* `SetLength(A, N)` — resize the array to `N` elements; copies existing data.
* `Length(A)` — returns the current element count.
* `A[I]` — zero-based element read/write.
=== Supported element types
All scalar element types are fully supported:
[cols="1,1,1"]
|===
|Type | RawSize | QBE store/load
|`Byte`, `Boolean`
|1 byte
|`storeb` / `loadub`
|`Integer`, `UInt32`, `enum`
|4 bytes
|`storew` / `loadw`
|`Int64`, `string`, `class`, `pointer`
|8 bytes
|`storel` / `loadl`
|===
Record element types (`array of TMyRecord`) are structurally supported at the
offset-computation level (element size = `TotalSize`), but element assignment
currently emits a single `storel` rather than a `memcpy`, so only pointer-sized
records are safe. Full record-element support requires a `memcpy` emit path and
is deferred.
=== RTL contract
Two RTL functions implement resize and length queries:
`_DynArraySetLength(Ptr, NewLen, ElemSize)`::
Allocates `DA_HDR + NewLen × ElemSize` bytes, writes refcount=1 and the new
length into the header, zeroes the element area, copies `min(OldLen, NewLen)`
existing elements, frees the old block, and returns a pointer to element 0.
`_DynArrayLength(Ptr)`::
Reads the 4-byte integer at `Ptr 4`. Returns 0 if `Ptr` is nil.
Both functions are implemented in pure Pascal in `blaise_str.pas` and compiled
into the RTL archive.
=== Rationale
*Heap allocation, not stack*: dynamic arrays grow at runtime, so stack
allocation is not viable. The pointer-to-element-0 convention matches Delphi
and makes zero-based indexing natural: `A[I] = *(Ptr + I × ElemSize)`.
*Header before element 0*: placing the header immediately before the data
avoids a separate allocation and keeps cache locality for length checks
(`Ptr 4`). Delphi uses the same layout.
*`SetLength` allocates fresh on every call*: reference counting on arrays is
deferred to Phase 3 (ARC). The current implementation always allocates a new
block and copies, which is correct and safe, though not optimal for in-place
growth.
=== Grammar
----
DynArrayType = ARRAY OF TypeName ;
----
This rule extends `TypeName`. After consuming the `array` keyword, the parser
checks for a `[` bracket. If present it follows the static-array path
(`ArrayType`); if absent it expects `of` and follows the dynamic-array path
(`DynArrayType`).
=== Alternatives rejected
*Open-array parameters as the general dynamic-array type* — open-array
parameters use a two-register ABI (data pointer + high index) and cannot be
stored in a variable. Dynamic arrays require a single-pointer variable form
that can be resized independently.
*`TList<T>` instead of native dynamic arrays* — `TList<T>` already exists for
ordered, resizable sequences. Native `array of T` is needed for ABI
compatibility (passing slices to C, inter-op with RTL functions, future
vectorised operations) and for conciseness in low-level code.
== Stream I/O
=== Decision
The Blaise RTL provides a stream subsystem in the `Streams` unit
modelled on Go's `io` interfaces and Square's Okio rather than on
Delphi/FPC's single-class `TStream` hierarchy. The shape is:
* Two abstract base classes, *one direction each*: `TInputStream` and
`TOutputStream`. Bidirectional read+write on a single handle is rare
enough (database engines, file-format backpatching) that it does not
justify polluting the common API; a future `TRandomAccessFile` can
cover it.
* Three capability interfaces alongside the abstract classes:
`ICloseable` (every stream supports it), `IInputStream`,
`IOutputStream`. Concrete classes implement both the abstract base
*and* the matching capability interface. Two capabilities orthogonal
to read/write — `ISeekable` (file/memory streams), `IReaderFrom` /
`IWriterTo` (optional zero-copy markers consumed by `CopyStream`) —
round out the surface.
* The wrappers are decorators: `TBufferedInputStream` wraps any
`TInputStream`; `TStreamReader` wraps that for line-oriented reads;
similarly on the write side. Each wrapper owns its inner stream by
default (`OwnsInner: Boolean = True`); closing the outer wrapper
tears the chain down.
* `TBuffer` is an in-memory segmented buffer (Okio-style segment rope,
8 KiB segments from a shared pool) that implements *both*
`IInputStream` and `IOutputStream`. Its `TransferTo` operation
re-links segments between buffers without copying bytes, so
multi-layer pipelines (decode → buffer → forward) drop from O(N)
memcpys per byte to O(1).
* `CopyStream(Src, Dst): Int64` is a free function that runtime-checks
for `IReaderFrom` / `IWriterTo` markers and dispatches to the
optimised path, with a byte-loop fallback. Adding a fast-path
implementation (e.g. `sendfile(2)` between two `TFileOutputStream`s)
later is a non-breaking change — existing callers automatically
benefit.
=== Rationale
The design draws explicitly from a cross-language survey (Java, Go,
Rust, Swift, Kotlin/Okio, C#, Oxygene, Python). The recurring lessons:
* *Java's `java.io` is the textbook bad case of GoF Decorator* — 70+
classes, two parallel byte/char hierarchies, wrapping order silently
load-bearing (forget the buffer → 100× slower). Decorator works
*if the decorated interface is tiny* (Go's `io.Reader` has one
method; Okio's `Source` has three). Blaise's abstract bases follow
that constraint: `TInputStream` has `Read` and `Close`, and that is
the whole protocol.
* *Go's `io.Copy` capability discovery is near-free to provide and
hugely valuable.* Callers write `CopyStream(src, dst)` once; the
framework upgrades to zero-copy when both sides cooperate. The Go
team consider this the single most impactful design choice in the
package.
* *Okio's segment-rope buffer is the modern answer to multi-layer
buffering churn.* Naive layers each `memcpy` between their own byte
arrays; segments are reassigned by pointer, turning the dominant
cost into a no-op. We pay for it with ~300 LOC of pool/segment
bookkeeping, which is reasonable.
* *.NET's `StreamReader`-owns-the-inner-stream behaviour is a
surprising footgun.* Blaise wrappers are explicit about ownership
(`OwnsInner` parameter), defaulting to True for ergonomics but
opting out cleanly when a helper returns a wrapper.
* *Rust's `BufWriter` ignoring drop errors is the cautionary tale on
finaliser-based cleanup.* Blaise's ARC `Destroy` is best-effort: it
flushes but absorbs errors silently. Callers who care about
correctness call `Close` explicitly inside a try-finally, which
raises `EStreamError` on failure.
* *Python's three-layer `io` (raw / buffered / text) is the right
shape for composition* — short reads are a layer-1 concern, charset
is a layer-3 concern, mixing them (as `java.io` did) confuses the
semantics. Blaise mirrors this: byte streams (Layer 1+2), buffered
decorators (Layer 2/3), text wrappers (Layer 4), segmented buffer
(Layer 5), copy framework (Layer 6).
=== Why capability interfaces alongside abstract classes
A defining moment in the design was recognising that `TBuffer` is both
a source and a sink. With Blaise's single inheritance, it cannot
extend both `TInputStream` and `TOutputStream`. Two solutions exist:
* *Pure interfaces, no abstract classes* — would force every concrete
stream to re-implement common machinery (close idempotency, default
flush, etc.).
* *Capability interfaces alongside abstract classes* — abstract
classes serve implementers (default code, less boilerplate); the
interfaces serve consumers (accept "anything readable" without
caring about the inheritance chain). `TBuffer` implements both
interfaces directly without picking a parent.
Blaise chose the latter, matching the existing `IMap<K,V>` +
`TDictionary<K,V>` pattern in the collections.
=== UTF-8 only in v1
`TStreamReader` and `TStreamWriter` are UTF-8 only. Strings in Blaise
are already UTF-8, so the wrappers are effectively pass-through. The
constructors accept an `Encoding` parameter for forward compatibility
(reserved); other encodings are deferred until there is a real
consumer. This avoids Java's parallel byte/char hierarchy mistake —
charset is a parameter, not a type-system axis.
=== Alternatives Rejected
* *Delphi-style single `TStream` root* — rejected. Accumulates too
many methods (`Read`/`Write`/`Seek`/`Size`/`Position`), forces every
implementer to override slots that make no sense for their stream
(pipes have no size; sockets cannot seek). Splits cleanly along
read/write *and* seekability axes, which is what the abstract
bases + capability interfaces achieve.
* *`TFilterStream` decorator root* — rejected. Decoration is
structural (hold an `FInner`), not nominal. A separate class adds
no shared behaviour and forces an inheritance commitment that
excludes other useful parents.
* *Stream-of-bytes only* — rejected for v1. `TBuffer.IndexOf` and the
future `TBuffer.TransferTo` cover the framed-I/O use case (parsers,
protocol handling) that pure byte streams handle awkwardly. The
upgrade path to a full `TPipeReader` (System.IO.Pipelines style)
remains open.
* *Async-ready interfaces from day one* — deferred. Blaise has no
async runtime today. The byte interface is shaped so an async
variant could be added later without a parallel hierarchy (Rust's
`std::io` vs `tokio::io` split is the cautionary tale).
=== TODOs flagged in code
* `TBuffer`'s segment pool is single-threaded. The comment on
`AcquireSegment` / `ReleaseSegment` flags the audit needed once
Blaise gains thread support — likely a per-thread freelist or a
mutex.
* `CopyStream`'s fast paths (`IReaderFrom` on `TFileOutputStream` via
`sendfile(2)`; `IWriterTo` on `TBuffer` via `TransferTo`) are
intentionally not implemented in v1. Adding them is non-breaking;
benchmark first.
* Interface argument expressions other than plain identifiers
(function returns, casts, field accesses) are not yet supported as
parameters by the codegen. Assign to a local interface variable
first.
== 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.
==== `Exit(Value)` function-result shorthand
Inside a function, `Exit(X)` is shorthand for `Result := X; Exit;` — it assigns
`X` to `Result` and returns immediately. This matches Delphi/FPC and makes
early returns with a value concise:
[source,pascal]
----
function Classify(N: Integer): Integer;
begin
if N < 0 then Exit(-1);
if N = 0 then Exit(0);
Result := 1
end;
----
`Exit(X)` is only valid inside a function (a procedure has no `Result`); using
it in a procedure is a semantic error. `X` must be assignment-compatible with
the return type — the same check (and the same widening / ARC handling for
string and class returns) as a direct `Result := X`.
Implementation: the parser attaches the parsed `X` to the `TExitStmt`; the
semantic pass rewrites it into a synthesised `Result := X` assignment (so it
reuses the full assignment-analysis path) which codegen emits immediately
before the normal exit jump. A bare `Exit` is unchanged.
== Typed Constants
=== Decision
Blaise supports typed constant declarations with the form:
[source,pascal]
----
const
Pi: Double = 3.14159265358979323846;
MaxItems: Integer = 100;
Greeting: string = 'hello';
----
The type annotation is optional; untyped constants (`const N = 42`) continue
to work unchanged with inferred types.
=== Supported types
Scalar typed constants: Integer, Int64, Double, Single, Boolean, string.
Array typed constants: `array[EnumType] of ScalarType` — see
_Array-of-enum typed constants_ below.
Record, class, and pointer typed constants are not supported — they require
compound value syntax (record literals, address-of) that adds significant
parser complexity for rare use cases.
=== Rationale
Typed constants give the programmer explicit control over the type of a
constant value. Without them, `const Pi = 3.14159` infers `Double`, which is
correct, but `const Scale = 2` infers `Integer` — there is no way to declare a
`Single` or `Int64` constant without a typed form.
The primary motivator was `Pi: Double` in `math.pas`, where the untyped form
would have ambiguous type inference across platforms and makes the intent
opaque.
=== Alternatives Rejected
*Using type-cast syntax* (`const Pi = Double(3.14159)`) — requires the parser
to distinguish a type-cast expression from a function call inside a const
initialiser, which is more complex than a simple colon annotation and is not
the FPC/Delphi convention.
== Constant Expressions
=== Decision
A constant declaration may take a compile-time *integer expression* on the
right-hand side, not just a single literal:
[source]
----
const
A = 2 * 3; // 6
B = 2 + 3 * 4; // 14 — '*' binds tighter than '+'
C = (2 + 3) * 4; // 20 — parentheses override precedence
D = 100 div 7; // 14
Base = 10;
Derived = Base * 2 + 1; // 21 — references a prior constant
Flags = (1 shl 4) or 3; // 19 — arithmetic and bitwise mixed
----
Supported operators are `+ - * div mod` and the bitwise/shift set
`and or xor shl shr`, together with unary minus and parentheses, using the same
precedence as the executable-expression grammar. Operands are integer
literals and references to previously declared integer constants.
=== Rationale
The right-hand side is parsed with the existing full-precedence expression
parser into a normal expression AST, then folded to a single `Int64` by the
semantic pass. Folding in the semantic pass (rather than at parse time) is
what makes forward references to other constants work: every constant in scope
is registered before any expression is folded, so `Derived = Base * 2 + 1`
resolves `Base` cleanly regardless of declaration order within the block.
Reusing the executable-expression parser means operator precedence,
associativity, and parenthesised grouping are encoded in the AST shape for
free — there is no separate, precedence-unaware constant evaluator to keep in
sync. This supersedes the earlier flat bit-op token-chain representation,
which only handled a single left-to-right precedence level and could not
express `2 + 3 * 4` correctly.
=== Scope
Constant expressions fold to an integer. Floating-point constant arithmetic
(`const X = 3.14 * 2`) and string concatenation beyond the existing literal/
named-constant `+` form are not folded by this path; a multi-token float or
string expression is left for a future extension. Compile-time constants are
not yet accepted as static-array bounds (`array[0..N-1]`), which is a separate
parser limitation in the type grammar, independent of constant-expression
folding.
== Array-of-enum Typed Constants
=== Decision
Blaise supports array-typed constants whose index is an enum type:
[source,pascal]
----
type
TWeather = (wtSunny, wtCloudy, wtRainy);
const
WeatherNames: array[TWeather] of string = ('Sunny', 'Cloudy', 'Rainy');
WeatherCost: array[TWeather] of Integer = (0, 1, 2);
----
The element count in the value list must exactly match the number of enum
members; a mismatch is a semantic error. The index type must be an enum
declared in the same or an enclosing scope.
Supported element types: the same scalar types supported by scalar typed
constants (Integer, Int64, Double, Single, Boolean, string).
=== Implementation
Array-typed constants are emitted as pre-initialised global data objects in
the QBE data section. String elements are stored as immortal string pointer
references (`$__sN + 12`); integer elements as `w`-typed words.
The index type must be resolved before the const can be validated, so array
const analysis is deferred to a second pass (`AnalyseArrayConstDecls`) that
runs after `AnalyseTypeDecls` in every scope. Scalar const analysis remains
in the first pass (before type decls) so that constant values remain usable
inside type declarations.
=== Rationale
The canonical Pascal idiom for mapping enum values to strings is a `case`
statement helper function, which is verbose and must be kept in sync with the
enum manually. An array-of-enum constant is more concise, checked at compile
time (wrong element count is an error), and matches FPC/Delphi syntax exactly.
This was the primary motivator: making the following program work as written,
without any workaround:
[source,pascal]
----
const
WetherNames: array[TWetherType] of string = ('Sunny', 'Cloudy', 'Rainy');
var
t1: TWetherType;
begin
t1 := wtSunny;
WriteLn('Wether: ', WetherNames[t1]);
end.
----
=== Index forms: enum, integer range, and multi-dimensional
Both enum and integer-range indices are supported for const arrays:
[source]
----
const Days: array[0..6] of string = ('Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat');
const Wether: array[TWetherType] of string = ('Sunny', 'Cloudy', 'Rainy');
----
Range-indexed const arrays extend to multiple dimensions, in either the comma
form or the equivalent nested form:
[source]
----
const M: array[0..1, 0..2] of Integer = ((1, 2, 3), (4, 5, 6));
const N: array[0..1] of array[0..1] of Integer = ((10, 20), (30, 40));
----
A multi-dimensional const desugars to a nested static-array type (the comma and
nested forms are interchangeable) and its initialiser values are stored
flattened in row-major order — the same contiguous layout a `var`-declared
multi-dimensional array uses. The compiler validates that the number of
initialiser leaves equals the product of the dimension extents.
Enum indexing is intentionally limited to a single dimension: it ties the
element count to a type whose cardinality the compiler already knows, and
mixing enum and range dimensions buys little over the uniform range form.
== Record Methods
Blaise records may declare methods alongside fields:
[source,pascal]
----
type
TPoint = record
X, Y: Integer;
function Distance(Other: TPoint): Integer;
function ToString: string;
end;
function TPoint.Distance(Other: TPoint): Integer;
begin
Result := Abs(Self.X - Other.X) + Abs(Self.Y - Other.Y)
end;
----
=== Calling Convention
Record methods receive `Self` by pointer (functionally a `var` parameter at
the QBE ABI level). The language preserves value semantics: assignments copy
the record content, parameters pass by value unless `var` is specified. The
pointer-pass for `Self` is an implementation detail — within a method body
`Self.Field := V` mutates the caller's record, the same as in any imperative
record method.
=== Restrictions
Records cannot have constructors, destructors, or virtual methods. There is
no inheritance: a record `type` declaration cannot list a parent. Records do
not participate in the vtable / RTTI system; they have no class identity.
=== Why no constructors
Pascal records have always been initialisable by struct-style assignment
(`P.X := 1; P.Y := 2`) or by `MakePoint(1, 2)`-style factory functions.
Adding constructor syntax would either require introducing a *new*
two-keyword construction form (Delphi's `record constructor`) or overloading
the existing `Create` method name with new semantics — both add complexity
without enabling anything that factory functions don't already cover. The
five-type date/time RTL (`DateUtils`) settled the question in practice: the
`Make*` factory pattern reads cleanly, threads through `with` blocks, and
keeps record literal construction (the common case) free of method-call
overhead.
=== Alternatives Rejected
*Record inheritance* (Delphi extended records with parent) — would require
either a vtable per record (defeating the cost model — records are meant to
be value types, fast to copy, no indirection) or a single-inheritance chain
without polymorphism (no semantic advantage over composition). Classes
already cover the cases where inheritance is wanted.
*Virtual methods on records* — same as above: needs a vtable, conflicts with
copy-by-value, and the design pressure for it (polymorphism) is satisfied by
interfaces over classes.
== Date and Time RTL — Five-Type Model
The `DateUtils` unit replaces FPC/Delphi's `TDateTime`-as-`Double` with five
distinct record types: `TDate`, `TTime`, `TDateTime`, `TInstant`, and
`TDuration`, plus a `TTimeZoneOffset` helper.
=== Why not `TDateTime` as Double
The Delphi/FPC convention encodes `TDateTime` as a `Double`: integer part
is days since 1899-12-30, fractional part is the time of day. This has
three well-known problems:
* *Precision loss.* A `Double` has 53 bits of mantissa. Operations on
dates more than a few centuries from the epoch lose milliseconds and
eventually whole seconds. Arithmetic round-trips do not preserve the
original value.
* *Type confusion.* The same type is used for a calendar date, a local
wall-clock instant, and a UTC point-in-time. There is no way for the
type system to flag a function that adds a local-time value to a
UTC instant — both compile to `Double + Double`.
* *No timezone metadata.* The bare `Double` carries no information about
whether the encoded value is local time, UTC, or naive. Reasoning about
DST transitions or cross-timezone arithmetic requires out-of-band
knowledge.
The five-type model addresses all three: every value uses integer storage
(no precision drift), the types name their semantic role, and timezone is
explicit where it matters.
=== Type semantics
`TDate` — calendar date (Year, Month, Day) without time or timezone.
`TTime` — time of day (Hour, Minute, Second, Nanosecond) without date or
timezone.
`TDateTime` — local date+time without timezone. Use when wall-clock fields
are what matter and the timezone is implied or irrelevant.
`TInstant` — unique point on the UTC timeline, stored as nanoseconds since
the Unix epoch (signed `Int64`). The right type for "when did this happen"
across timezones, log timestamps, scheduling.
`TDuration` — signed nanosecond interval (`Int64`). Closed under addition
and negation; not anchored to any point in time.
=== Timezone scope
`TTimeZoneOffset` represents a fixed UTC offset (`+HH:MM` or `-HH:MM`). Full
IANA tzdata / DST rules are deferred to a future phase — they require a
runtime tzdata reader and DST-aware arithmetic that would significantly
expand the unit's scope. The fixed-offset model covers ISO 8601 timestamp
formatting and a substantial fraction of real-world timezone needs.
=== Why integer storage everywhere
`TInstant` and `TDuration` use signed `Int64` nanoseconds — large enough to
cover ±292 years from epoch with nanosecond precision. `TDate` / `TTime` /
`TDateTime` use `Integer` per sub-field. `Byte`-sized fields now pack at
their natural 1-byte stride, but the existing public API is locked in on
`Integer` for the per-field accessors, so changing the storage type would
break call sites for no observable benefit.
=== Alternatives Rejected
*Single 128-bit instant type* — covers nanosecond precision over the full
ICU calendar range, but requires emulated 128-bit arithmetic for every
operation. `Int64` nanoseconds with a ±292-year range is sufficient for
all practical engineering uses.
*ISO 8601 string as the canonical type* — readable but slow and
allocation-heavy. The wire format is ISO 8601 (`ToString` methods produce
it); the in-memory representation is structured.
== `sar` — Arithmetic Right Shift Operator
=== Decision
Blaise distinguishes logical and arithmetic right shift with two separate
keywords:
* `shr` — logical (zero-fill) right shift. The vacated high bits are
filled with zero regardless of the operand's signedness. This matches
the documented semantics in Delphi and Free Pascal.
* `sar` — arithmetic (sign-preserving) right shift. The vacated high
bits are filled with a copy of the original sign bit. On unsigned
types `sar` produces the same result as `shr`; on signed types with
the sign bit set, `sar` preserves the sign while `shr` discards it.
The compiler maps `shr` to QBE's `shr` instruction and `sar` to QBE's
`sar` instruction. Both are accepted at the same precedence level as
`*`, `/`, `div`, `mod`, `and`, `xor`, and `shl` (term-level).
=== Rationale
A signed right shift by one is the natural way to divide by two with
sign-preservation: `-16 sar 1 = -8`. Using `shr` for the same
operation on a signed value produces a large positive number because
the sign bit is treated as a data bit and shifted right. Both
operations are useful — arithmetic shift for sign-preserving division
and bit-field extraction on signed values; logical shift for unsigned
bit manipulation and packed-field access — and conflating them under
a single keyword forces the programmer to cast through an unsigned
type whenever the other interpretation is required.
Delphi and Free Pascal define `shr` as logical regardless of the
operand type and expose no separate arithmetic-shift operator. C and
C++ leave the choice implementation-defined. Java and Rust expose
both as distinct operators (`>>` arithmetic vs `>>>` logical in Java;
`>>` follows operand signedness in Rust). Blaise follows the
two-operator model so the source code names the intent explicitly.
=== Alternatives Rejected
*Make `shr` sign-preserving on signed types* — diverges from documented
Delphi/FPC semantics, silently changes the meaning of existing source
ported from those compilers, and removes the ability to perform a
logical shift on a signed value without casting.
*Reject `shr` on signed types* — forces an explicit cast through
`UInt64` (or another unsigned type) for every logical shift on a signed
value. Verbose and noisy in bit-manipulation code.
*Use a symbolic operator (e.g. `>>` and `>>>`)* — symbols are concise
but read less naturally next to Pascal's other keyword operators
(`shl`, `shr`, `and`, `or`). Three-letter mnemonic keywords match the
established Pascal style.
== `/` vs `div` — Real Division and Integer Division
=== Decision
Blaise distinguishes real division from integer division with two
separate operators:
* `/` — real division. Always yields a floating-point result, even
when both operands are integers. Integer operands are promoted to
`Double` before the division. The result type is `Single` only when
both operands are `Single`; in every other case the result is
`Double`.
* `div` — integer division. Both operands must be integer types;
passing a `Single` or `Double` operand is a semantic error.
This matches standard Pascal, Delphi, and Free Pascal.
=== Rationale
Conflating `/` and `div` produces silently-wrong code in numerical
expressions. A user writing `Round(Y / X)` where `X` and `Y` are
`Integer` reasonably expects the same result as `Round(Y / X)` written
with floating-point operands — i.e. the rational quotient rounded to
the nearest integer. Treating `/` as integer division when both
operands are integers makes `Round(10 / 3)` evaluate as `Round(3) = 3`
rather than `Round(3.333…) = 3`; the example happens to coincide, but
`Round(7 / 2)` produces `3` instead of `4`. The operator's name also
becomes misleading: `/` reads as the mathematical division symbol.
Keeping `/` and `div` as distinct operators makes the programmer's
intent explicit and matches every other Pascal dialect. `Trunc`,
`Round`, `Ceil`, and `Floor` continue to require a float argument; the
ergonomic case for integer-quotient rounding is now `Round(Y / X)`,
which naturally has a float subexpression and therefore type-checks.
=== Implementation
`uParser.pas` maps the `/` token to `boSlash` and the `div` keyword to
`boDiv`. `uSemantic.pas` (`AnalyseBinaryExpr`) gives `boSlash` a
float result type unconditionally; `boDiv` retains the existing
integer-promotion rules and now rejects float operands explicitly.
`blaise.codegen.qbe.pas` lowers both `boSlash` and `boDiv` to QBE's `div`
instruction; the difference is whether the operands have already been
promoted to a float type (`d`/`s`) on the float arithmetic path.
=== Alternatives Rejected
*Map both operators to a single `boDiv` and infer the kind from operand
types* — this is what the compiler did before this change. The
inference path that says "two integer operands → integer division" is
exactly the bug: Pascal source distinguishes the two operators
deliberately, and the AST should preserve that distinction.
*Make `Trunc`/`Round` accept Integer arguments* — this would mask the
underlying bug rather than fix it. `Round(10 / 3)` would still
evaluate `10 / 3` as integer division to `3` before passing it to
`Round`, so the result would silently be `3` regardless of whether
the rational answer rounds up or down.
== `High` and `Low` on Ordinal Types
=== Decision
`High(T)` and `Low(T)` accept any ordinal type — `Integer`, `Int64`,
`UInt32`, `UInt64`, `SmallInt`, `Word`, `Byte`, `Boolean`, and any
user-declared `enum` — in addition to the array and string forms
documented above. The argument may be either a type name (e.g.
`High(Integer)`) or a value of that type (e.g. `High(SomeVar)`).
The result type is the argument's own type, not `Integer`, so
`High(Int64)` has type `Int64` and round-trips through 64-bit code
paths without truncation.
Floating-point types are explicitly rejected with a targeted error
message that points the user at `MaxDouble`/`MinDouble` and the
`Math.Infinity` constants.
=== Rationale
The idiom `for I := Low(T) to High(T)` is one of the few places in
Pascal where the bounds of a primitive type are expressed without a
magic number. Restricting `High`/`Low` to arrays and strings forced
users to write `2147483647` or `MaxInt` in scan loops and saturation
guards, which is both noisier and easier to get wrong on the
`Int64`/`UInt64` end of the range.
The result type matches the argument deliberately. Returning
`Integer` for every form would make `High(Int64)` lossy and would
also break overload resolution against `Int64`-typed call sites.
Returning the argument type composes naturally with assignment and
comparison.
The bounds are folded at compile time — `High(Integer)` emits a single
QBE `=w copy 2147483647` instruction. No runtime call, no extra
memory.
=== Floating-point types
`High(Double)` is a category error in Pascal: ordinal bounds and
floating-point extremes are different concepts. IEEE 754 has
`+inf`/`-inf`, finite `MaxDouble`/`-MaxDouble`, a smallest positive
normal `MinDouble`, and `NaN` — none of which `High`/`Low` could
disambiguate. The fix is to expose these as named constants in the
`math` unit (`Math.Infinity`, `Math.NegInfinity`, `Math.NaN`,
`MaxDouble`, `MinDouble`). The compile-time error tells the user
exactly that.
=== Alternatives Rejected
*Return `Integer` for every ordinal form* — rejected. `High(Int64)`
would lose its top bit at the call site, silently producing wrong
results in 64-bit arithmetic.
*Generalise to floats as `High(Double) = MaxDouble`* — rejected.
Conflates two distinct concepts (ordinal upper bound vs. largest
finite float) and doesn't generalise to `+inf` or `NaN` semantics.
Named constants on the `math` unit are unambiguous and discoverable.
*Leave the existing array/string-only behaviour and tell users to
write `2147483647`* — rejected. Discoverability is the point: every
Pascal programmer reaches for `High`/`Low` first, and finding a
helpful targeted error message ("use `Math.Infinity`...") only on
floats is a much better DX than a generic "must be an array" error on
every primitive.
== Diamond Operator — Generic Type-Argument Inference
=== Decision
Blaise supports the diamond operator `<>` in constructor assignment expressions.
When a variable is declared with a fully-specified generic type, the constructor
call on the right-hand side may omit the type arguments:
[source,pascal]
----
var S: TStack<string>;
S := TStack<>.Create; // equivalent to TStack<string>.Create
var D: TDictionary<string, Integer>;
D := TDictionary<>.Create; // infers both K and V from the LHS
----
The operator works for any number of type parameters — single-argument generics
(`TStack<T>`, `TList<T>`) and multi-argument generics (`TDictionary<K,V>`,
`TPair<K,V>`) alike.
=== Rationale
Repeating the full type argument list at every constructor call site is redundant
when the declared variable type already carries the complete instantiation.
Java introduced the diamond operator in Java 7 for the same reason; the pattern
is well understood.
The restriction to assignment context keeps the implementation minimal and
unambiguous: the semantic pass has the declared LHS type available at the point
it encounters the RHS constructor call, so inference requires no lookahead or
backtracking. Inference in other contexts (parameter passing, return values)
would require a more general type-propagation pass and is left to a future
decision.
=== Alternatives considered
*Require explicit type arguments everywhere* — rejected. Writing
`TStack<string>.Create` in both the `var` declaration and the constructor call
is mechanical duplication that adds noise without information.
*Infer at all call sites, not just assignments* — deferred. Inference from
parameter and return-type context requires propagating the expected type through
the expression tree before the call is analysed. The assignment case covers the
common pattern at zero additional complexity.
=== Implementation
The lexer folds `<>` into the single token `tkNotEquals`. The expression parser
detects `IDENT tkNotEquals DOT` as the diamond pattern and stores the sentinel
string `TFoo<>` in `TFieldAccessExpr.RecordName`. The semantic pass — in
`AnalyseAssignment`, after resolving the LHS type — calls `ResolveDiamond`,
which replaces the sentinel with the full concrete type name taken from
`ResolvedLhsType.Name`. Subsequent semantic analysis proceeds identically to
an explicit constructor call.
== Generic Records
=== Decision
Blaise supports generic records using the same `<T>` syntax as generic classes
and interfaces:
[source,pascal]
----
type
TMyVal<T> = record
Value: T;
function GetValue: T;
begin
Result := Self.Value
end;
end;
var
V: TMyVal<Integer>;
begin
V.Value := 42;
WriteLn(V.GetValue());
end.
----
Generic records are monomorphized exactly like generic classes: each distinct
instantiation (`TMyVal<Integer>`, `TMyVal<string>`, etc.) creates a new
concrete `TRecordTypeDesc` with its own field layout and method bodies. Type
parameter constraints apply identically.
=== Alternatives considered
* *Classes only* — The initial generics implementation supported only classes
and interfaces. This meant lightweight value-type wrappers (tagged unions,
option types, result types) had to be classes with heap allocation and ARC
overhead. Generic records give zero-overhead value-type generics.
* *Reuse `TGenericTypeDef` / `TGenericInstance` for both classes and records* —
Rejected because `TGenericTypeDef.ClassDef` is specifically a
`TClassTypeDef` and the two AST node types have meaningfully different
fields (records lack `ParentName`, `ImplementsNames`, `Properties`,
`Attributes`). Separate `TGenericRecordDef` and `TGenericRecordInstance`
types keep the AST honest and avoid nil-field ambiguity.
=== Key differences from generic classes
Records are value types, so generic record instances do *not* emit:
* Typeinfo blocks (`$typeinfo_<T>`)
* Vtable data (`$vtable_<T>`)
* Field cleanup functions (`$_FieldCleanup_<T>`)
* Inheritance chains or interface implementation tables
They emit only field layout (offsets computed by the semantic analyser) and
method bodies (one function per method per instantiation). ARC-managed fields
inside generic records are handled by the existing record-cleanup logic at
variable scope exit, not by class-style field cleanup.
== Generic Methods
=== Decision
A class or record method may declare its *own* type parameters, independent of
any type parameters on the enclosing type:
[source,pascal]
----
type
TUtil = class
function Pick<T>(cond: Boolean; a, b: T): T;
begin if cond then Result := a else Result := b end;
end;
...
u.Pick<Integer>(True, 7, 9); // -> 7
u.Pick<string>(False, 'a', 'b'); // -> 'b'
----
The method is *monomorphised* on first use: each distinct set of explicit type
arguments at a call site produces one concrete method body, named
`<Owner>_<Method>_<Args>` (e.g. `TUtil_Pick_Integer`). The implicit `Self` is
preserved, so a generic method may read the receiver's fields and call its
other methods. Type arguments are explicit at the call site
(`u.Pick<Integer>(...)`); they are not inferred from the value arguments.
This is distinct from a method of a generic *class* (`TBox<T>` and its
`function Get: T`), where the type parameter `T` is bound by the class
instantiation, not the method. The two compose: a generic class may have a
method with its own additional type parameters.
=== Rationale
Generic methods reuse the existing generic free-function machinery: the same
on-demand instantiation, the same `<T>` type-parameter scoping during body
analysis, and the same `QBEMangle`/`NativeMangle` name mangling (`<`/`,` ->
`_`, `>` dropped). The only addition over a free function is carrying the
owner type and the implicit `Self` through to codegen, so the instance emits as
an ordinary method. Monomorphisation (rather than type erasure) keeps the
model uniform with generic classes and records and needs no runtime type
descriptors.
Both declaration forms are accepted: the inline body written inside the class,
and the out-of-line implementation `function TUtil.Pick<T>(...)` with the body
separated from the class declaration (the body is transferred onto the in-class
generic-method template before instantiation).
== Platform constants — one name per concept
Delphi and FPC carry multiple aliases for the same platform constant:
* `LineEnding` (FPC), `sLineBreak` (Delphi), and sometimes `LineBreak`
* `DirectorySeparator` (both), `PathDelim` (Delphi SysUtils alias)
* `PathSeparator` (both) — no alias, the one exception
Blaise retains exactly three names, one per concept:
[cols="1,1,1"]
|===
| Constant | POSIX value | Windows value
| `LineEnding`
| `#10`
| `#13#10`
| `DirectorySeparator`
| `/`
| `\`
| `PathSeparator`
| `:`
| `;`
|===
*Dropped:* `sLineBreak`, `PathDelim`. They were Delphi compatibility aliases
that added no meaning — two names for the same value is a maintenance trap, not
backwards compatibility.
All three constants are defined in `system.pas` and pre-seeded as compiler
built-ins in `RegisterBuiltins`. Their values are derived from the compilation
target (`GTarget` in `blaise.codegen.target`), so cross-compilation to a
different OS produces the correct constants without conditional compilation.
== `not` — Boolean and Bitwise NOT
=== Decision
The `not` operator accepts both Boolean and integer operands:
* On a Boolean operand, `not` performs logical negation. The result type
is `Boolean`.
* On an integer operand (`Byte`, `SmallInt`, `Word`, `Integer`, `Int64`,
`UInt64`), `not` performs bitwise complement — every bit is inverted.
The result type is `Integer` for operands narrower than 64 bits, or
`Int64` / `UInt64` for 64-bit operands.
* On a floating-point operand (`Single`, `Double`), `not` is a semantic
error.
This matches Delphi and Free Pascal, where `not` is overloaded on the
operand type.
=== Rationale
Bitwise complement is essential for bitmask patterns such as
`Flags := Flags and (not MASK)` (clear specific bits). Without `not` on
integers, the programmer must use `xor` with a hand-computed all-ones
constant — error-prone and width-dependent.
Narrower-than-32-bit operands widen to `Integer` because the complement
of a `Byte` value is meaningful as a 32-bit mask, not as a byte that
wraps modulo 256. This follows the same integer-promotion rule that
Blaise applies to other arithmetic on narrow types.
=== Alternatives Rejected
*Require a separate `bitnot` keyword* — adds a keyword that no Pascal
dialect uses. The overloaded `not` is unambiguous because the operand
type determines the operation, and both Delphi and FPC programmers
expect this behaviour.
*Return the same type as the operand for narrow types* — a `Byte` result
of `not B` where `B = 3` would be `252`, which is surprising when the
value is used as a bitmask in a wider expression. Promoting to `Integer`
is consistent with how other arithmetic operators handle narrow operands.
== Boolean formatting — `WriteLn` prints `True` / `False`
Both Delphi and FPC print `TRUE` / `FALSE` (case varies by RTL version) when a
Boolean value is passed to `Write` / `WriteLn`. Early Blaise treated booleans as
plain integers, so `WriteLn(True)` printed `1` and `WriteLn(False)` printed `0`.
This was a codegen shortcut, not a deliberate design choice. It surprised users
familiar with any other Pascal dialect and made program output harder to read.
Blaise now emits a dedicated `_SysWriteBool` call for Boolean-typed arguments.
The runtime writes the literal string `True` or `False` — no heap allocation, no
string conversion. `BoolToStr` in `sysutils.pas` is unaffected and continues to
return `'True'` / `'False'` as a string value.
*Alternatives considered:*
* `TRUE` / `FALSE` (all-caps, matching Delphi) — rejected; Blaise identifiers are
`True` / `False` (title-case), and the output should match the language.
* Keeping `1` / `0` and requiring explicit `BoolToStr` — rejected; the implicit
integer rendering was a bug, not a feature.
== `threadvar` — Thread-Local Storage
*Decision:* Blaise supports the `threadvar` keyword for declaring thread-local
storage variables. Syntactically it mirrors `var` but places the variable in
TLS rather than the global data section. Each thread receives its own
zero-initialised copy.
*Scope restriction:* `threadvar` is only allowed at program or unit scope (not
inside procedures or methods). Attempting to use it at local scope produces a
parse error.
*QBE backend:* The QBE IR uses QBE's native `thread` linkage qualifier. Data
declarations emit `export thread data $Name = { ... }`, and all load/store
references use `thread $Name`. QBE generates correct local-exec TLS access
patterns on Linux x86_64 (`%fs:Name@tpoff`).
*Native backend:* The x86_64 native backend emits threadvars in
`.section .tbss,"awT",@nobits` and accesses them via `%fs:Name@tpoff`
(local-exec TLS model).
*Unit interface serialisation:* The `IsThreadVar` flag is serialised through
`.bif` files so that importing units correctly mark symbols as thread-local.
*Alternatives considered:*
* POSIX `pthread_key_t` / `pthread_getspecific` — more portable but slower
(function call per access vs. a single `%fs:` load). The native TLS model
is the correct choice for a compiler targeting Linux x86_64.
* Implicit TLS via runtime per-thread tables — rejected; the explicit keyword
makes the intent clear and matches established Pascal practice (Delphi, FPC).
== `TThread` Lifetime and ARC — No `FreeOnTerminate`
=== Decision
Blaise's `TThread` does not provide the `FreeOnTerminate` property found in
Delphi and FPC. Thread lifetime is managed entirely through ARC: when the
last reference to a `TThread` instance is released, `Destroy` calls `WaitFor`
(which calls `pthread_join`), blocking until the OS thread has fully exited
before the object's memory is freed.
The trampoline (the free procedure passed to `pthread_create`) does *not*
hold an ARC reference to the `TThread` instance. It accesses the object
only while the thread is running; `pthread_join` in `Destroy` guarantees
the trampoline has returned before the object is deallocated, so there is
no use-after-free.
=== Rationale
In Delphi and FPC, object lifetime is manual. `Free` directly calls
`Destroy`, and if the programmer forgets to call it the object leaks.
`FreeOnTerminate` exists to handle the "fire-and-forget" pattern: when a
thread is spawned and the caller discards the reference, the thread must
clean itself up on completion or the `TThread` instance leaks.
In Blaise, ARC eliminates this class of leak. Every relevant scenario is
already handled:
* *Explicit Free* — `Free` lowers to `_ClassRelease`. If this drops the
refcount to zero, `Destroy` runs, which calls `WaitFor`. The caller
blocks until the thread finishes, then the object is freed.
* *Scope exit* — when a local `TThread` variable goes out of scope, ARC
emits `_ClassRelease`. Same chain: `Destroy` → `WaitFor` → join → free.
* *Field in an owning object* — when the owner is destroyed, the field is
released. Same chain.
* *Reassignment* — `T := nil` or `T := SomeOtherThread` releases the old
reference. Same chain.
In every case, ARC ensures the `TThread` is joined and freed exactly once,
without any explicit cleanup code from the programmer.
=== Why the trampoline must not hold an ARC reference
The original implementation had `Start` call `_ClassAddRef` on the
`TThread` instance, and the trampoline call `_ClassRelease` in its
`finally` block. This created a reference-count of 2 (one for the caller,
one for the trampoline). The consequence: when the caller released its
reference (via `Free`, scope exit, or reassignment), the refcount dropped
to 1 — not zero — so `Destroy` did not run, `WaitFor` was never called,
and the main program could exit before the thread finished.
Removing the trampoline's ARC reference restores the correct behaviour:
the caller's reference is the *only* reference, so releasing it triggers
`Destroy` → `WaitFor`. The `pthread_join` call is the synchronisation
barrier that guarantees the trampoline has fully returned before the object
is deallocated.
=== Migration note
Code ported from Delphi or FPC that sets `FreeOnTerminate := True` will
fail to compile. The fix is to simply delete the assignment — ARC handles
the lifetime automatically. The Blaise migration analyser flags this
pattern and explains the removal.
=== Alternatives considered
* *Keep `FreeOnTerminate` for compatibility* — rejected. The property
introduces a second, redundant lifetime model that conflicts with ARC
semantics. Keeping it would require the trampoline to hold an extra ARC
reference (breaking the join-on-free guarantee) or special-case logic in
`Destroy` (complexity for a feature that ARC already covers).
* *Detached threads (no join)* — not yet supported. If a future use-case
requires truly detached threads (where the caller never waits), this
would be a separate API (`TThread.Detach` or a `TDetachedThread`
subclass) with explicit opt-in semantics, not a boolean property on the
base class.
== Mandatory parentheses on zero-argument calls
*Decision*: every function, procedure, method, and constructor call requires
parentheses, even when there are no arguments. `Obj.Free()`, `Create()`,
`WriteLn()`, `Foo()` — never `Obj.Free`, `Create`, or `Foo` without `()`.
This includes `inherited` calls (`inherited Create()`, not `inherited
Create`) and constructor dispatch through a metaclass variable
(`C.Create()`, not `C.Create`).
The `@` (address-of) operator is the sole exception: `@Obj.Method` takes a
method reference and does not invoke the method, so no `()` is written.
In standard Object Pascal (Delphi and FPC), a bare identifier that resolves
to a parameterless function or procedure is ambiguous: it can be either a
call or a reference to the function's `Result` variable (inside the function
body) or a method reference (with `@`). The compiler resolves the ambiguity
by context — statement position means call, expression position means call
unless `@` is present — but this creates several problems:
* *Readability*: `F := Foo;` — is `Foo` a variable, a constant, or a
function call? The reader must consult the declaration to know.
* *Compiler complexity*: the compiler must synthesise invisible call nodes
for zero-argument functions and track `IsNoArgFuncCall` / `NoArgFuncDecl`
flags through the AST to distinguish calls from variable reads. This
introduces a class of latent bugs whenever a new AST consumer (codegen
backend, semantic pass) forgets to check the flag.
* *Grep-ability*: searching for all call sites of `Foo` requires semantic
analysis; a textual search for `Foo(` misses the zero-arg calls.
Requiring `()` eliminates all three problems. The syntax is unambiguous at
the lexical level: `Foo()` is always a call, `Foo` is always a read. The
compiler AST becomes simpler — `TMethodCallExpr` for calls with explicit
parentheses, `TFieldAccessExpr` only for field/property reads.
The rule applies in *statement* position as well as expression position: a
bare `Foo;`, `Obj.Method;`, or `Obj.Free;` used as a statement is a parse
error (issue #148). Earlier the statement parser silently accepted the
bare form — expression position was already enforced, but statement-position
parameterless calls slipped through and built a paren-less `TProcCall` /
`TMethodCallStmt`. Both now raise the same "requires () for a call"
diagnostic the `inherited` and expression-position paths use.
=== Alternatives considered
* *Deprecation warning period* — rejected. Blaise is in alpha; there is no
stable public API to preserve. A deprecation cycle adds complexity for
no practical benefit when the migration is a mechanical find-and-replace.
* *Optional parentheses (status quo)* — rejected. The ambiguity is the
root cause of multiple latent codegen bugs discovered during this change:
`IsBuiltinToString` applied to record methods, `IsVarParam` not set for
record value parameters in `TMethodCallExpr`, and native backend missing
`leaq` for record receivers. All were masked by the old
`TFieldAccessExpr.IsMethodCall` path.
== Implicit Integer→Float Widening — Builtins and Typecasts
=== Decision
Integer-family values (`Integer`, `Int64`, `UInt32`, `UInt64`, `SmallInt`,
`Word`, `Byte`) widen implicitly to `Double` wherever a float is expected:
* Assignment to a `Single` or `Double` variable (`d := i`).
* Arguments to the float compiler builtins: `Sqrt`, `Ceil`, `Floor`,
`Round`, `Trunc`, `Ln`, `Log2`, `Log10`, `Power`, `Sin`, `Cos`, `Tan`,
`ArcTan`, `ArcTan2`, `ArcSin`, `ArcCos`, `Sinh`, `Cosh`, `Tanh`,
`IsNaN`, `IsInfinite`. `Sin(12)` and `Tanh(I)` are valid; the result
type of the value-returning trig builtins is `Double` for integer
arguments (for float arguments it continues to match the argument
type — `Single`→`Single`, `Double`→`Double`).
* Explicit float typecasts: `Double(I)` and `Single(I)` perform a real
numeric conversion (never a bit reinterpretation). Float-to-float
casts likewise convert: `Single(D)` narrows, `Double(S)` widens.
=== Rationale
Both FPC and Delphi accept integer arguments at every float call site via
implicit widening; rejecting them made Blaise's builtins stricter than the
rest of the language for no benefit — plain assignment already widened
implicitly, so `d := i; d := Tanh(d)` compiled while `d := Tanh(i)` did
not. Mixed integer/float arithmetic (`s * 1000`) was likewise already
accepted by the semantic pass. The widening is always to `Double`
(not `Single`): it is the lossless choice for every 32-bit integer and
matches the C math library signatures the builtins lower to.
=== Alternatives Rejected
* *Require explicit float literals/casts* (status quo) — rejected: it
contradicts both Pascal tradition and Blaise's own assignment rule, and
the recommended workaround (`Tanh(Single(I))`) did not work because
float typecasts were lowered as bit copies.
* *Widen to `Single` for small integer types* — rejected: `Double`
represents every `Integer` exactly; `Single` does not (24-bit
mantissa). Precision should not silently depend on the argument's
declared integer width.
== Interface Properties
=== Decision
Interfaces may declare properties. Accessors must be methods of the
interface itself or an inherited parent interface — interfaces have no
fields, so field-backed accessors cannot exist:
[source,pascal]
----
type
IValued = interface
function GetValue(): Integer;
procedure SetValue(AValue: Integer);
property Value: Integer read GetValue write SetValue;
end;
----
`I.Value` lowers to the getter dispatched through the itab; `I.Value := X`
lowers to the setter dispatch with `X` as the single argument. Properties
are pure compile-time sugar: they occupy no itab slots, change no layout,
and have no ARC implications beyond the underlying method calls. Child
interfaces see inherited properties. Read-only (`read` only) and
write-only (`write` only) properties are supported; accessing the missing
direction is a compile-time error.
=== Rationale
Both FPC and Delphi support interface properties with identical syntax —
there is no dialect fork to choose from. Without them, interface
consumers are forced back to raw getter/setter calls even when the
implementing class exposes a property, which defeats the purpose of
publishing the interface as the API surface.
=== Limitations (v1)
* Property access requires a plain interface-typed variable as the
receiver; access through record/class fields holding interfaces or
through chained expressions is not yet wired.
* Indexed and default array properties in interfaces are not yet
supported.
== Collection Ownership — ARC Conventions
=== Decision
The collection types follow three explicit ownership conventions:
* `TObjectList` — ownership is opt-in: `Create(True)` retains on add and
releases (destroying at refcount zero) on remove/clear/destroy;
`Create(False)` stores borrowed references.
* `TList<T>`, `TStack<T>`, `TQueue<T>` and the other generic collections —
managed elements (`string`, class, dynamic array) ARE retained on store
and released when an element slot is overwritten (stores lower to
ARC-aware pointer writes). An object whose only other reference was a
local in an exited routine therefore survives in the collection.
* `TStringList.Objects[]` — NON-OWNING raw pointer slots, by design.
The API type is `Pointer`, and storing integer payloads cast to
pointers (`TObject(PtrUInt(N))`) is an accepted idiom (the compiler
itself uses it); blind retention would corrupt such entries. Callers
storing real objects must hold a strong reference elsewhere for the
entry's lifetime.
=== Rationale
Retention for typed generic elements is safe because `T` is statically
known — only genuinely managed types get ARC operations. `Objects[]`
cannot distinguish a pointer-encoded integer from an object reference at
runtime, so it keeps FPC/Delphi's borrowed-pointer contract.
=== Known limitation (leak backlog)
`Clear`/`Destroy` on the generic collections do not yet release the
retained references of remaining managed elements (releasing requires a
`Default(T)`-style generic zero-store that the language does not provide
yet). Until then, collections of managed elements leak their contents
when destroyed non-empty; element overwrite and `Delete` shifting are
ARC-balanced.
== Module Names Are Reserved Identifiers
*Decision*: the name of the module being compiled (the `program` or `unit`
header name) and the names of all directly used units are reserved
identifiers within that module. No top-level declaration — variable,
constant, type, procedure, or function — may redeclare them. Inner scopes
(routine bodies) may shadow them freely. The module name is not a value:
referencing it in an expression reports an undeclared identifier.
This matches Delphi and FPC, both of which reject the redeclaration with
`Duplicate identifier` (Delphi error E2004 documents exactly this case:
`program Tests; var Tests: Integer;`).
=== Rationale
Before this rule (issue #84), Blaise accepted:
[source,pascal]
----
program blaisefault;
var
blaisefault: Integer; { accepted; rejected by FPC and Delphi }
begin
blaiseFault := 1;
end.
----
The acceptance was an accident of implementation, not a decision: the parser
stored the module name on the AST node without registering it in the symbol
table, so the duplicate-identifier check had nothing to collide with. The
behaviour was benign only because Blaise currently has no module-qualified
identifier syntax (`unitname.identifier`) and emits no linker symbol named
after the module.
Reserving the name now was chosen over documenting the accident as a
feature for three reasons:
* *Design freedom*: module-qualified access is the standard escape hatch
when two used units export the same name. If it is ever added, module
names must be resolvable identifiers; programs relying on the old
behaviour would break or silently change meaning. Reserving the name
early keeps that door open at near-zero cost.
* *Migration expectations*: code arriving from FPC or Delphi already obeys
the rule, and programmers from those compilers expect the error.
* *Consistency*: the rule extends uniformly to directly used unit names,
again matching FPC (`uses sysutils; var sysutils: Integer;` is rejected).
=== Implementation
The semantic pass plants an `skModule` marker symbol for the module's own
name and each directly used unit name in the scope that receives top-level
declarations (the global scope and the program block scope for programs;
the unit scope for units). The ordinary duplicate check on
`TSymbolTable.Define` then rejects redeclarations with the standard
`Duplicate identifier` error. `TSymbolTable.Lookup` treats a marker hit as
unresolvable, so the reserved name is not usable as a value and blocks
same-named symbols in lower resolution layers, while pushed inner scopes
shadow it naturally.
One deliberate divergence: FPC accepts a *procedure* whose name matches the
program name (an apparent accident of its overload machinery). Blaise
rejects all declaration forms uniformly.
The grammar is unchanged — this is a name-resolution rule, not a syntax
rule — so `docs/grammar.ebnf` is unaffected.
== Zero-Initialisation of Variables
=== Decision
Every variable in Blaise is guaranteed to hold its zero value before any
explicit assignment. This is a *language semantic*, not an implementation
detail. The guarantee covers all storage categories and all type kinds:
[cols="2,1", options="header"]
|===
| Type kind | Zero value
| Integer, Int64, Byte, Word, SmallInt, UInt32, UInt64 | `0`
| Single, Double | `0.0` (positive zero — all bits 0)
| Boolean | `False`
| Byte (used as Char) | `#0`
| Enum | ordinal `0` (first declared member)
| Set | empty set (all bits 0)
| Pointer, typed pointer, PChar | `nil`
| Class reference | `nil`
| Interface (both fat-pointer slots) | `nil` (`obj = 0` and `itab = 0`)
| Metaclass (class-of) | `nil`
| Procedural / method pointer | `nil` (both slots for method pointers)
| String | `''` (nil data pointer)
| Dynamic array | `nil` (length 0)
| Static array | every element zero-initialised
| Record | every field recursively zeroed
|===
The guarantee applies to: local variables, global variables, class fields
(at `ClassAlloc`), record fields (at declaration/allocation), static-array
elements, threadvars, and the `Result` variable of value-returning functions.
Open-array parameters are excluded — they are caller-owned and the callee
receives a pointer to the caller's storage.
The grammar is unchanged.
=== Rationale
Zero-initialisation removes an entire category of undefined-behaviour bugs
(reading an uninitialised variable). It also simplifies ARC correctness:
every managed slot starts `nil`, so a `Release(nil)` on the first-write path
is always safe.
Blaise is a systems-capable language but not a C replacement. The audience
expects the safety of a managed language first and the performance escape
hatches of a systems language second. The Principle of Least Surprise
favours predictable zeroes over garbage.
=== Alternatives Considered and Rejected
*Garbage-by-default for performance.* C/C++ leave locals uninitialised. The
performance argument is real for large hot static arrays, but profiling shows
the cost is negligible in practice: the QBE backend's dead-store elimination
removes the zero store whenever the first use is an assignment; the native
backend emits one extra store per scalar local (acceptable). If a future
profiling run ever identifies a hot large array as a bottleneck, the agreed
escape hatch is a `{$noinit}` attribute — *not implemented now*; mention here
serves as the single authoritative record of that decision.
*Definite-assignment analysis instead of zero-initialisation.* A compiler
that rejects programs reading a variable before explicit assignment provides
the same safety with no runtime cost. This is the approach taken by Java,
C#, and Rust. Blaise does not rule it out — a future diagnostic pass could
warn on read-before-explicit-assignment as a lint on top of the
zero-init guarantee — but it is not a substitute for it. Zero-init is a
runtime guarantee; definite-assignment analysis is a static check that
does not cover all cases (pointers, out-params, aliasing).
=== Implementation
* The QBE backend zeroes every local in `EmitVarAllocs` (alloc + store/memset).
QBE's dead-store elimination removes the store when the first use is an
assignment.
* The native x86-64 backend zeroes every local in `EmitFunctionDef` using
`movl $0` / `movq $0` / `xorpd + movsd` / `memset` as appropriate for
the type width. An exhaustive `case` with an `else raise` ensures no
new type kind is silently skipped.
* Global variables are placed in the `.data` section with explicit zero
directives (`.quad 0`, `.long 0`, etc.); threadvars are placed in `.tbss`
(zero-initialised thread-local storage).
* Class instances are zeroed by `_ClassAlloc` via `memset` before the
constructor runs.
=== Future Roadmap (not implemented)
* `{$noinit}` attribute — opt-out for a specific variable when the profiler
identifies the zero-store as a measurable hot-path cost. Agreed as the
escape hatch if ever needed; not to be added speculatively.
* Definite-assignment warning — a lint diagnostic for
read-before-explicit-assignment. Zero-init is the hard guarantee; the
warning is advisory and will be implemented as a separate analysis pass.
== Multi-Dimensional Static Arrays
=== Decision
Static arrays may declare and index multiple dimensions using either a
comma-separated form or nested single-dimension arrays, and the two are
exactly equivalent:
[source,pascal]
----
var
A: array[0..1, 0..2] of Integer; // comma form
B: array[0..1] of array[0..2] of Integer; // nested form
begin
A[i, j] := v; // comma indexing
A[i][j] := v; // chained indexing — identical to the line above
end;
----
The comma form is *syntactic sugar*: the parser desugars
`array[L1..H1, L2..H2] of T` into `array[L1..H1] of array[L2..H2] of T`,
and a comma index list `A[i, j]` into chained subscripts `A[i][j]`.
Dimensions extend to any depth (`array[0..1, 0..1, 0..1] of T`).
=== Rationale
Representing a multi-dimensional array as nested single-dimension arrays
keeps the type system, symbol table, sizing, ARC, and the OPDF debug
emitter free of any special "rank" concept. An inner dimension is simply
the element type of the outer one, so a 2-D array is laid out as a flat,
row-major block of contiguous inline storage (no per-row indirection),
which is the conventional Pascal layout and the most cache-friendly.
The element-address computation already existed for single-dimension
arrays; the only codegen gap was that a static-array *element* was loaded
as a value rather than returned as an address, which broke a further
subscript on it. Both backends now return the inline sub-array's address
(mirroring how record and interface elements are already handled), so a
chained subscript indexes into it correctly.
Because the comma forms desugar to the nested forms before semantic
analysis, both notations are interchangeable in every position: a value
declared with one form can be indexed with the other.
=== Alternatives considered
* *A dedicated multi-dimensional array type descriptor carrying a list of
bounds.* Rejected: it would duplicate the element-address logic, require
a new OPDF record shape, and complicate sizing/ARC for no behavioural
gain over the nested representation.
* *Supporting only the comma form (no bare chained writes).* Rejected as
inconsistent: the desugared target `A[i][j] := v` would then be a form
the user could not write directly, even though the read form `A[i][j]`
was always legal.
=== OPDF debug emission
No new debug record is required. A nested array emits one `recArray`
(`Dimensions = 1`) per dimension, with each dimension's element type
pointing at the next inner `recArray`. The reference debugger (pdr)
follows the element chain and renders the value as a true multi-dimensional
structure, e.g. `array[0..1] of array[0..2] of Integer = [[0,0,0],[0,0,0]]`.
=== Constant initialisers
Multi-dimensional *const* arrays use the same nested desugaring and
row-major layout. Both the comma form (`array[0..1, 0..2] of Integer`)
and the nested form (`array[0..1] of array[0..1] of Integer`) are accepted
in a const type annotation, and the initialiser nests one parenthesised
group per dimension (`((1, 2, 3), (4, 5, 6))`). The values are flattened
to row-major order at parse time, so codegen emits a single contiguous data
blob identical to what the equivalent runtime assignments would produce.
See the "Array-of-enum Typed Constants" section for the value-count
validation rule.
=== Dynamic-array nesting (jagged arrays)
A *dynamic* array of dynamic arrays (`array of array of T`) supports the
same chained subscript forms for both reading and writing:
[source,pascal]
----
var m: array of array of Integer;
begin
SetLength(m, 2);
SetLength(m[0], 3); // each row sized independently — rows may be jagged
m[0][1] := 5; // chained element write
WriteLn(m[0][1]); // chained element read
end;
----
The representation differs from the static case: each level is a separate
heap allocation, so an inner row is reached through a pointer rather than
inline storage. The chained-write codegen therefore evaluates the base
expression (`m[i]`) to the inner array's *data pointer* and indexes into
it, whereas the static path computes an inline offset. This indirection
is what makes jagged arrays possible — distinct rows may have distinct
lengths, set independently with `SetLength(m[i], n)`.
The element type of a chained write may be any managed or unmanaged type
(integers, strings, records, classes); the store path applies the same
ARC retain/release discipline used for a single-dimension dynamic-array
element write.
== Initialised Global Variables
=== Decision
A global variable declaration may carry an initialiser:
[source,pascal]
----
var
G: Integer = 42;
S: string = 'hello';
A: array[0..2] of Integer = (10, 20, 30);
----
The value is folded at compile time and emitted directly into the data
section, so the variable holds its initial value before the program body
runs. This matches FPC and Delphi.
The initialiser uses the same value grammar as a typed constant — a scalar,
string, boolean, real, or a parenthesised aggregate list — and is restricted
as follows:
* *Global scope only.* Initialisers are accepted on program-level and
unit-level variables. A local variable initialiser is rejected: locals are
zero-initialised and then assigned in the body, keeping the data-section
path the single source of truth for static initial values.
* *Single name.* `var A, B: Integer = 1` is a parse error — an initialiser
applies to exactly one variable, avoiding the ambiguity of whether the value
is shared or per-name.
* *Arrays only for aggregates.* Array initialisers reuse the existing
array-constant machinery. Record and inline-set initialisers are not yet
supported (Blaise has no record-constant infrastructure, and a set
initialiser would collide with the const-symbol the set folder defines); both
are rejected with a clear diagnostic rather than silently mis-emitted.
=== Rationale
Reusing the constant pipeline (parse → fold → data-section emit) keeps the
feature small and consistent: an initialised global is, in effect, a constant
value placed in mutable storage. The folded value is emitted as a typed data
slot in both backends — a single field for scalars/strings (a string global
points at an immortal static header, `__sN + 12`), and an inline element list
for arrays (identical layout to an array constant). No runtime initialiser
code is generated; the loader provides the value.
=== Alternatives considered
* *Emit an assignment at program start instead of static data.* Rejected:
it defeats the point (the variable would be zero until the assignment runs)
and bloats the entry path. Static data is what FPC/Delphi emit and what the
debugger expects.
* *Allow local initialisers (assignment at scope entry).* Deferred. It is a
reasonable future extension but mixes two emission models; the global
data-section path is the clean baseline.
* *Support record/set initialisers now.* Deferred until record-constant
infrastructure exists; rejecting them keeps the feature honest.
== Inline Set Types
=== Decision
A set type may be written inline anywhere a type is expected — a variable,
parameter, record field, etc. — not only in a named type declaration. The
element type may be a named enumeration or an anonymous enumeration written in
place:
[source,pascal]
----
var
Days: set of TWeekday; // named enum element
Flags: set of (fA, fB, fC); // anonymous enum element
----
Previously a set type had to be named in the `type` section
(`type TS = set of TWeekday; var S: TS;`). Both inline forms now match FPC
and Delphi.
=== Rationale
The implementation resolves an inline set entirely from its canonical
type-name string, with no special parser state. The parser encodes the type
as `set of <Name>` (named element) or `set of (a,b,c)` (anonymous element);
the semantic type resolver (`FindTypeOrInstantiate`) recognises the `set of `
prefix and constructs the `TSetTypeDesc` on demand, exactly as it already does
for `array of T`, `^T`, and `class of T`. For the anonymous form it first
synthesises an enum type from the encoded member list — registering each
member as an ordinary enum constant — then builds the set over it.
Because set types compare structurally, the synthetic enum's name need only be
unique, and an identical inline enum encountered again reuses the existing one
(keyed on its already-defined member constants). Encoding the enum in the
type string rather than carrying parser-side state keeps the feature
self-contained and makes it work uniformly in every position a type can
appear, with no ordering or ownership concerns.
=== Alternatives considered
* *Synthesise the enum as a side effect in the parser and stage it for
transfer into the enclosing block.* Rejected: it makes `ParseTypeName`
mutate shared state, requires every declaration site to flush the staging
area, and introduces declaration-ordering hazards between the synthesised
enum and any type that references it. The string-encoding approach has none
of these problems.
* *Require named set types (status quo).* Rejected: inline set types are
common in ported FPC/Delphi code and the named-type requirement is friction
with no compensating benefit.
== Loosely-Typed Parameter Passing — `array of const`, untyped params, `varargs`, `Variant`
Four Object Pascal features share the theme of passing a value whose type is
not fully fixed at the call site. They are frequently conflated but are
distinct mechanisms with very different safety profiles. Blaise adopts only
one of them.
[cols="1,1,1,2", options="header"]
|===
| Feature | Runtime type tag | Count known | Purpose
| Untyped `const Data` / `var Data`
| none (raw bytes)
| n/a (single argument)
| Low-level memory primitives (`Move`, `FillChar`)
| `array of const`
| yes — per element (`TVarRec.VType`)
| yes (it is an array)
| Heterogeneous argument lists (`Format`-style APIs)
| `varargs`
| none
| no
| C ABI interop (`printf`)
| `Variant`
| yes (`TVarData.VType`)
| n/a (a storeable value)
| Dynamic / late-bound values (historically COM/OLE)
|===
=== Decision
Blaise implements *only* `array of const`. Untyped parameters, `varargs`, and
`Variant` are deliberately omitted.
=== Untyped parameters (`const Data`, `var Data`) — omitted
An untyped parameter discards the argument's type entirely; the compiler
implicitly takes the variable's address, and the callee operates on raw bytes
(`PByte(@Data)`). It exists for call-site ergonomics on raw-memory routines:
`Move(Source, Dest, Count)` reads more cleanly than `Move(@Source, @Dest,
Count)`.
This is rejected on footgun grounds, which is the consistent thread through
Blaise's type-system decisions. `DoSomething(Rec)` *looks* like it passes a
value, but silently passes an address and erases the type — the caller gets no
signal that raw, uncheckable memory access is happening. The same job is done
by an ordinary typed-pointer parameter with no new language machinery:
[source,pascal]
----
procedure DoSomething(const Data: PByte); // or ^TMyRecord
...
DoSomething(@Rec); // explicit, honest
----
The explicit `@` at the call site signals "raw memory, be careful," and a
*typed* pointer (`PByte`, `^TMyRecord`) keeps type information — it permits
indexing and field access without a cast, which is strictly more useful than a
fully untyped blob. The only thing lost is the `@`-free call syntax on a
handful of RTL primitives; that is an ergonomics cost, arguably a clarity gain,
and not a capability loss — a typed pointer can express everything an untyped
parameter can. Blaise's RTL writes these primitives against `Pointer` /
`PByte` instead.
=== `varargs` — omitted
`varargs` is the untyped, C-ABI variadic form (FPC's `cdecl; varargs`). It
carries no type tags and no argument count; the callee must recover both from
an external convention (e.g. a `printf` format string). It exists almost
exclusively to call C variadic functions. Blaise has no need for it today, and
it is the least safe option of the four — omitted unless direct C-variadic
interop becomes a requirement.
=== `Variant` — omitted
`Variant` is a storeable, self-describing value type with runtime conversion
rules, late binding, and (historically) COM/OLE Automation dispatch. It is the
weakest fit for Blaise: its coercions can fail at runtime, and its primary
historical motivation (COM) is irrelevant to Blaise's targets. Blaise prefers
the statically-checkable alternatives — method overloads for the fixed-arity
cases, generics for parametric code, and explicit discriminated unions
(`case`-based records) for genuinely dynamic data. A clean tagged union
expresses "one of a known set of types" with compile-time checking; `Variant`
trades that checking for runtime flexibility Blaise does not want to encourage.
=== `array of const` — adopted
`array of const` is the one mechanism that overloads and generics cannot
cleanly replace, and it is therefore worth its cost. Its purpose is a single
call accepting an *arbitrary-length, mixed-type* argument list:
[source,pascal]
----
WriteLn(Format('%s is %d years old', [Name, Age]));
Log(['count=', N, ' ratio=', R, ' ok=', Flag]);
----
No finite set of overloads can express "any number of arguments, each of any
type" — the arity and type combinations are unbounded. This is the feature
that makes `Format`-style APIs feel native, which is the expected Pascal idiom.
Unlike `Variant`, `array of const` is a compiler-generated *call construct*,
not a free-floating dynamic value type. The compiler builds an array of tagged
records (`TVarRec`: a `VType` discriminator plus a pointer-sized `VValue` slot)
at the call site from the bracket literal, and the callee dispatches on
`VType`. The tag set is fixed and known at compile time, the array length is
known, and the construct cannot leak into arbitrary storage the way a `Variant`
value can — so it admits the heterogeneous-list ergonomics without importing
`Variant`'s runtime-coercion surface.
==== Implementation
A parameter declared `array of const` is an open array whose element type is
the intrinsic record `TVarRec`. `TVarRec` is registered as a compiler builtin
(like `TObject` and `TMethod`), so it and the `vt*` discriminant constants are
available with no `uses` clause — matching Delphi, where `array of const` lives
in the auto-available System unit. Its layout is a 16-byte record: a `VType`
byte at offset 0 and a pointer-sized `VValue` at offset 8.
Because Blaise has no record variant parts (a deliberate omission), `TVarRec`
does not expose Delphi's overlapping union fields (`VInteger`, `VAnsiString`,
…). Instead the callee reads each element by reinterpret-casting the single
`VValue` slot, switching on `VType`:
[source,pascal]
----
procedure Log(args: array of const);
var i: Integer;
begin
for i := 0 to High(args) do
case args[i].VType of
vtInteger: WriteLn(Integer(args[i].VValue));
vtBoolean: WriteLn(Boolean(args[i].VValue));
vtAnsiString: WriteLn(string(PChar(args[i].VValue)));
vtExtended: WriteLn(PDouble(args[i].VValue)^);
vtObject: ...TObject(args[i].VValue)...;
end;
end;
----
The supported element kinds and their tags are `vtInteger` (also boolean as
`vtBoolean`, enum ordinals as `vtEnum`), `vtInt64`, `vtExtended` (`Double`),
`vtAnsiString` (the UTF-8 string), `vtObject`, and `vtPointer`.
Two representation choices follow from the pointer-sized value slot:
* *Doubles are heap-boxed.* A `Double` does not fit a reinterpret-cast through
the pointer slot (it is a different register class, and `Double(ptr)` is not a
bit-reinterpretation). So a `vtExtended` element stores a pointer to a
heap-allocated `Double`; the callee dereferences it (`PDouble(VValue)^`).
This mirrors FPC's `vtExtended: PExtended`.
* *Strings and objects are borrowed, not retained.* The boxed value is the
string's data pointer / the object pointer, stored without an `AddRef`
(FPC semantics). The `array of const` array borrows its elements for the
duration of the call; the caller's source values outlive the call, and the
callee must not retain a reference beyond it. Borrowing avoids the ARC
traffic and lifetime entanglement that owning each element would impose on a
transient stack array.
A heterogeneous bracket literal is accepted only in `array of const` position:
the array-literal analyser, on finding mixed element types, types the literal
as `array of TVarRec` instead of rejecting it, and overload resolution binds it
to an `array of const` formal (or fails with "no matching overload"). A
homogeneous literal (`[1, 2, 3]`) and the empty literal (`[]`) bind too.
=== Alternatives considered
* *Adopt untyped parameters for RTL ergonomics.* Rejected — typed pointers do
the same job explicitly and keep type information; see above.
* *Adopt `Variant` for dynamic values.* Rejected in favour of overloads,
generics, and explicit tagged unions, which keep checking at compile time.
* *Reject `array of const` too, and require callers to build an explicit
array/record.* Rejected — it would make `Format`-style APIs verbose and
un-idiomatic, and the construct is compiler-bounded and tagged, so it does
not carry the open-ended risks of the omitted three.
== Variable Names May Not Shadow Type Names
=== Decision
A variable declaration may not share its identifier with any type name that is
visible at the point of declaration — whether the type is built-in
(`Integer`, `string`), declared in the same block, declared in an enclosing
scope, or imported from another unit. Pascal is case-insensitive, so the
shadowing is detected regardless of letter case:
[source,pascal]
----
type
Iface = interface
procedure Test;
end;
var
iface: Iface; // ERROR: 'iface' and the type 'Iface' are the same identifier
----
The diagnostic is `Duplicate identifier 'X' — a type with this name is already
visible`, reported at the variable's declaration site.
This rule is stricter than FPC `mode objfpc`, which reports a duplicate only
when the variable clashes with a type declared in the *same* scope, and
silently permits a variable to shadow a built-in or outer-scope type
(`var Integer: Int64` compiles in FPC).
The same rule applies to *enum members*: a variable may not share its name with
a visible enum constant.
[source,pascal]
----
type TC = (A, B, C, D);
var c: TC; // ERROR: 'c' and the enum member 'C' are the same identifier
----
Without this rule the variable shadows the member, so a set literal that names
the member — `s := [A, C, D]` — silently resolves `C` to the variable (not the
constant). That is not a compile-time constant, so the QBE backend rejects it
with a cryptic message while the native backend produced a *wrong bitmap with
no error at all* (the member's bit was simply dropped). Rejecting the shadow
at the declaration, with `Duplicate identifier 'X' — an enum member with this
name is already visible`, removes both failure modes (FPC/Delphi allow the
shadow; Blaise does not, consistent with the type-name rule above).
=== Rationale
A variable that bears the same name as a type is almost always a mistake, and
when it is not, it is a readability trap: every subsequent use of the
identifier in that scope means the variable, so the type becomes unreferenceable
by its own name and later declarations like `x: Iface` resolve in surprising
ways. Issue #102 reported exactly this confusion — a program declaring both an
`Iface` interface and an `iface` variable compiled without complaint on both
backends.
Blaise already rejects variable-vs-variable, variable-vs-constant, and
type-vs-type clashes in the same scope. The variable-vs-type case slipped
through only because of an implementation detail: type declarations are
registered in the block's enclosing scope so they remain visible after the
block scope is popped, while variable declarations are registered one scope
deeper. The duplicate check at definition time therefore never saw the type.
Closing the gap with an explicit type-name lookup restores the symmetry users
expect: no two declarations in overlapping scopes may claim the same
identifier.
=== Alternatives considered
* *Match FPC exactly (same-scope clash only; built-in/outer shadowing
allowed).* Rejected. It fixes the reported case but keeps the underlying
footgun: `var Integer: Int64` would still compile and silently rebind the
type name for the rest of the scope. Blaise favours a clean rule over FPC
bug-for-bug compatibility (cf. the deliberate omission of `Char`, the
uniform 0-based indexing, and the required `overload` keyword).
* *Warn instead of error.* Rejected. The construct has no legitimate use that
a rename would not serve more clearly, and a warning that is routinely
ignored would leave the confusion in place.
* *Restrict the rule to reference types (classes, interfaces) only.* Rejected
as arbitrary — the confusion is identical for records, enums, aliases, and
built-ins, and a name-based rule is simpler to explain than a kind-based one.
== Set Literal Ranges
=== Decision
A set literal element may be a range `lo..hi`, expanding to every member from
`lo` to `hi` inclusive:
[source,pascal]
----
type
TColor = (Red, Green, Blue, Yellow, Cyan);
TColorSet = set of TColor;
var
e: TColorSet;
begin
e := [Red..Blue]; // {Red, Green, Blue}
e := [Red, Blue..Cyan]; // {Red, Blue, Yellow, Cyan} — ranges mix with singles
end;
----
Both bounds must be compile-time constants of the set's base type. A reversed
range (`[Blue..Red]`, low ordinal greater than high) is a compile-time error,
not a silently empty set. Variable bounds (`[lo..hi]` where `lo`/`hi` are
variables) are rejected with "set range bound must be a constant".
This matches the range form FPC and Delphi accept in set constructors, with two
deliberate restrictions (see below).
=== Rationale
`[1..10]` (or `[Red..Cyan]`) is far clearer than spelling out every member, and
the construct is standard Pascal — the reporter of issue #105 reasonably
expected it to work. The implementation expands each range into individual
member nodes during semantic analysis, so the rest of the pipeline — overload
resolution, the bitmask folder, the jumbo-set path, and both code generators —
sees an ordinary member list and needs no range-specific logic. A transient
`TSetRangeExpr` AST node carries the unexpanded range from the parser to that
expansion point; it is also wired into the `.bif` serialiser and the AST cloner
because a generic template body may contain an unexpanded range that is
serialised and re-instantiated later.
=== Alternatives considered
* *Treat a reversed constant range as an empty set (FPC behaviour).* Rejected.
FPC accepts `[5..3]` as empty (with a warning); when both bounds are
compile-time constants there is no legitimate reason to write a reversed
range — it is a typo, and `[]` already expresses the empty set
unambiguously. Erroring matches Blaise's preference for rejecting
confusing-but-legal constructs (cf. variable/type name clashes).
* *Support variable range bounds `[lo..hi]`.* Deferred. Runtime-variable
bounds require emitting a bit-setting loop in both backends across the
small-set and jumbo-set paths; the constant form covers the reported case and
the overwhelmingly common usage. The restriction is enforced with a clear
diagnostic rather than silently mis-compiling.
* *Support ranges over an ordinal base type (`set of byte`, `[1..3]`).* Out of
scope: Blaise set base types are enumerations today. Ordinal-based set types
are tracked separately (see `docs/future-improvements.adoc`); because range
expansion is base-type-agnostic, `[1..3]` will work automatically once they
land.
== Floating-Point Constant Expressions
=== Decision
Constant declarations accept compile-time floating-point expressions, not only
bare float literals. The following forms are supported:
[source,pascal]
----
const
Pi = 3.14159; { bare literal — already supported }
Tau = Pi * 2.0; { named const × literal }
Half = 10 / 4; { integer / integer → float (2.5) }
Mixed = 2 * 1.5 + 0.5; { integer × float + float → float }
Neg = -1.5 * 2.0; { unary minus propagates through float }
----
The `/` operator (real division) always produces a float result, even when both
operands are integers. This matches Delphi and FPC semantics and matches the
existing runtime behaviour of the `/` operator on variables. Integer division
is expressed via `div`.
=== Rationale
Before this change, any expression involving a float literal or the `/` operator
in a constant declaration was rejected — the constant folding path only handled
integer arithmetic. Users had to inline magic numbers or break them into
separate `var` initialisations, losing the self-documenting benefit of named
constants.
The semantic pass detects a float expression (at least one float literal, a
named float constant, or use of `/`) and dispatches to a float-specific
constant folder. The folder performs arithmetic using the host's IEEE 754
`Double` type and stores the result as a string in the constant declaration,
exactly as bare float literals are stored.
=== Alternatives considered
* *Defer to runtime evaluation.* Rejected. Constants must be folded at compile
time; emitting runtime arithmetic for `const X = 3.14 * 2.0` would violate
the constant contract and prevent their use in contexts requiring true constants
(e.g. array bounds, case labels).
* *Support only the four arithmetic operators.* Adopted. Bitwise operators
(`and`, `or`, `xor`, `shl`, `shr`) have no meaningful float semantics. The
`mod` operator could theoretically map to `fmod`, but there is no demonstrated
need. Integer-only operators remain available in integer constant expressions.
== Named Constants as Static-Array Bounds
=== Decision
Static-array bounds accept named constants and compile-time integer expressions,
not only integer literals. The following forms are all valid:
[source,pascal]
----
const N = 10;
const Lo = 1;
const Hi = 100;
type
TBuf = array[0..N-1] of Byte; { expression as bound }
TArr = array[Lo..Hi] of Integer; { named constants as both bounds }
var
A: array[0..N] of Integer; { named constant as upper bound }
const
Days: array[0..N-1] of string = (...); { const-array declarations too }
----
=== Rationale
Before this change, all array bounds had to be integer literals. This forced
users to duplicate magic numbers and prevented writing self-sizing array types
based on named constants — a fundamental use case in systems programming.
The parser now reads a constant expression (integer literals, named identifiers,
and the arithmetic operators `+`, `-`, `*`, `div`, `mod`, `shl`, `shr` with
parentheses) as each array bound, embedding the text in the type-name string.
The semantic pass resolves each bound to a concrete integer: plain integers are
converted directly, named constants are looked up in the symbol table, and
expressions are parsed and folded via the existing `EvalConstIntExpr` machinery.
=== Alternatives considered
* *Accept only bare named constants, not expressions.* Rejected. `array[0..N-1]`
is the most common idiom; supporting `array[0..N]` but not `array[0..N-1]`
would be a constant source of frustration.
* *Resolve bounds at parse time.* Rejected. The parser runs before the semantic
pass, so named constants are not yet resolved. Deferring to the semantic pass
is the natural point where all constant values are available.
== Enum-Indexed Static Arrays in Var and Type Declarations
=== Decision
Static-array declarations in `var` and `type` sections accept an enumeration
type as the index, matching the form already supported for const-array
declarations:
[source,pascal]
----
type
TColor = (Red, Green, Blue);
TColorNames = array[TColor] of string; { type declaration }
var
Costs: array[TColor] of Integer; { var declaration }
----
The array is sized `0..High(TColor)` (i.e. `0..Members.Count-1`), and
subscript access uses the enum ordinal value directly.
=== Rationale
Enum-indexed const arrays (`const Names: array[TColor] of string = (...)`) were
already supported; extending the same syntax to `var` and `type` declarations
completes the feature and eliminates the asymmetry. The codegen required no
changes — enum values are integer ordinals and the subscript arithmetic already
handles the zero-based static-array case correctly.
The parser detects the `array[Ident]` pattern (identifier followed by `]` with
no `..`) and encodes it as a special marker in the type-name string. The
semantic pass resolves the enum type, reads its member count, and creates a
standard `TStaticArrayTypeDesc` with bounds `0..N-1`.
=== Alternatives considered
* *Support non-enum ordinal index types (`array[Byte]`, `array[Boolean]`).*
Deferred. These require a different size-computation path (256 elements for
`Byte`, 2 for `Boolean`) and the enum form covers the primary use case.
* *Store the enum type in `TStaticArrayTypeDesc`.* Rejected. The type
descriptor erases the index origin after semantic analysis, storing only
integer bounds. This simplifies the codegen and is consistent with how
const-array enum indexing already works.
== Native Backend and Internal Toolchain as Default
=== Decision
As of v0.12.0, the *native* x86-64 backend is the default code generator, and
the in-process *internal assembler* (`blaise.assembler.x86_64`) and *internal
linker* (`blaise.linker.elf`) are the default toolchain. A complete
source-to-executable build invokes no external tools — only the host's C
runtime startup objects (`crt1.o`, `crti.o`, `crtn.o` from the distribution's
libc development package) are read from disk.
The QBE backend (`--backend qbe`) is retained but *deprecated*. It continues to
serve as a differential oracle for the native backend in the e2e test suite
(every `AssertRunsOnAll` program is compiled and run under both backends, and
their output is asserted equal), and the QBE self-hosting fixpoint
(`scripts/fixpoint.sh`) remains a guard. QBE is scheduled for removal in a
later release once the native backend has accumulated sufficient field exposure.
=== Rationale
* *Toolchain independence.* The original pipeline shelled out to `qbe`, then
`cc` to assemble, then `cc`/`ld` to link. Each is an external dependency a
user must install and keep version-compatible. The native backend plus
internal assembler and linker reduce the hard dependency to the C runtime
startup objects alone, which simplifies installation and makes the compiler's
output reproducible independent of the host toolchain version.
* *Exact debug information.* Only the native backend records precise codegen
facts (real frame-pointer offsets, per-statement line labels) consumed by the
OPDF debugger. The QBE backend emits approximate AST-walk debug records.
Making native the default aligns the default build with the debuggable build.
* *Self-hosting closure.* The native pipeline reaches a clean self-hosting
fixpoint (`scripts/fixpoint-native.sh` — stage-2 and stage-3 native assembly
are byte-identical), so the compiler can rebuild itself end to end without QBE.
=== Alternatives considered
* *Remove QBE immediately.* Rejected for v0.12.0. QBE's value as an
independent second implementation — the parity oracle that surfaces native
codegen bugs the single-backend tests cannot — is highest precisely while the
native backend is still maturing. Removal is deferred to a later release.
* *Keep QBE as the default and ship native opt-in.* Rejected. This was the
prior state; it left the default build path different from the debuggable
build path and kept the external-tool dependency on the common path.
=== Escape hatches
The internal toolchain can be bypassed per invocation when diagnosing a
suspected internal-assembler or internal-linker defect:
* `--assembler external` — assemble the native `.s` with the host `cc` instead
of the internal assembler.
* `--linker external` — link with the host `cc`/`ld` instead of the internal
linker.
* `--backend qbe` — fall back to the QBE pipeline entirely (deprecated).
The default native build emits a single whole-program object; `--incremental`
opts in to per-unit object emission with a warm cache.
== Decimal Arithmetic — One `TDecimal`, No `Currency` Primitive
=== Decision
Exact decimal arithmetic is provided by a single StdLib type, `TDecimal`
(`Numerics.Decimal`), not by a compiler primitive and not by a family of types.
There is deliberately no fixed-point `Currency`/`Comp` type and no separate
`BigDecimal`; the one `TDecimal` serves money, billing, tax and every other
exact-decimal need. Scientific and engineering computation is explicitly out of
scope for `TDecimal` and remains the domain of `Double` and the `Math` unit.
`TDecimal` is a value-type record holding an unsigned magnitude, a signed scale,
and a sign, with a compact `Int64` fast path that inflates to an arbitrary-
precision decimal-digit magnitude only when a value exceeds `Int64` range.
Equality is value-based: `2.0` and `2.00` are equal and hash identically.
=== Rationale
The string-consolidation precedent applies directly. Just as Blaise replaced
the `string`/`AnsiString`/`WideString`/`ShortString` family with one `string`,
it replaces the `Currency`/`Comp`/`Extended`/`Real` numeric proliferation — which
came from hardware limitations and legacy concerns that no longer apply — with
one exact-decimal type plus the two float primitives (`Double`, `Single`).
The single-type choice matches the dominant industry pattern. .NET (`decimal`),
Swift (`Decimal`), Rust (`rust_decimal`) and Python (`decimal.Decimal`) each ship
exactly one decimal type and treat "money" as decimal + a currency tag in a
separate library layer. Java is the outlier in having no built-in money type at
all (`java.util.Currency` is only an ISO-4217 descriptor); `BigDecimal` is its
de-facto money type, and even Java has no second fixed type.
A value-type record gives immutable value semantics without Java `BigDecimal`'s
per-operation heap garbage: the common money case lives entirely in the inline
`Int64` and allocates nothing, while still presenting a pure, immutable API.
The design deliberately fixes the well-known `BigDecimal` pitfalls: value-based
equality (so the type is safe in hash containers), a safe-by-default float
constructor (`DecFromFloat` takes the shortest decimal; the binary-exact path is
the explicitly-named `DecFromFloatExact`), mandatory explicit rounding on every
division (no throw-on-`1/3` and no silent truncation), and plain-string
formatting that never emits scientific notation.
Rounding is injected through a layered design — a `TRoundingMode` enum for the
eight standard modes (banker's rounding the default) over an `IRoundingStrategy`
interface for custom algorithms — so jurisdiction-specific rules (e.g. cash
rounding) need no change to the library.
=== Alternatives considered
* *A fixed-point `Currency` type (Int64 scaled by 10000), as in Delphi/FPC.*
Rejected as a separate type. Hard-coding four decimal places is a footgun
(JPY has zero, crypto/FX want eight), and a hidden `*10000` representation
invites scale confusion. Its one virtue — speed — is captured instead by the
compact `Int64` fast path inside the single `TDecimal`, with no second type
and no cross-type conversion friction.
* *A separate arbitrary-precision `BigDecimal` alongside `Currency`.* Rejected.
Two types double the API surface and force conversions at their boundary for
no functional gain; one type with an internal fast path covers both roles.
* *Making `TDecimal` cover scientific use too.* Rejected. Scientific work wants
hardware floating point and transcendental functions over irrational results,
where exact decimal representation is impossible and pointless. `Double` +
`Math` already serve this; `TDecimal` ships no such functions.
* *Baking currency (ISO-4217) into the numeric core.* Deferred to the
`Numerics.Money` wrapper (see the next section). Currency is a policy layer
(which currencies, how to format, per-jurisdiction rounding) that should not be
frozen into the numeric type — matching Moneta / money-gem / rusty-money, which
all wrap a decimal.
== Currency Amounts — `TMoney` Wraps `TDecimal`, Currency Is a String Tag
=== Decision
Currency-aware monetary amounts are provided by `TMoney` (`Numerics.Money`), a
thin value-type wrapper that pairs an exact `TDecimal` amount with an ISO-4217
currency tag. The numeric core (`TDecimal`) stays currency- and locale-agnostic;
currency policy lives entirely in this wrapper. Three rules define `TMoney`:
* The currency tag is an ISO-4217 alphabetic *string* code ('USD', 'JPY',
'KWD'), upper-cased on construction. The set of currencies is open: a code the
built-in registry does not list is still accepted and uses the fallback minor-
unit scale of two.
* Each currency has a default minor-unit scale (JPY 0, USD 2, KWD 3, fallback 2).
Every `TMoney` is normalised to its currency's scale on construction and after
every arithmetic operation. The default rounding is banker's (`rmHalfEven`),
but it is not hard-wired: each constructor and rounding-sensitive operation has
overloads taking an explicit `TRoundingMode` or a custom `IRoundingStrategy`
(the same eight modes and extension point as `TDecimal`), so the rounding
policy is swappable per call — e.g. `rmHalfUp` for a jurisdiction that rounds
halves up, or an `IRoundingStrategy` implementing Swedish/Swiss 0.05 cash
rounding.
* Arithmetic across different currencies (`Add`, `Subtract`, `Compare`) raises
`EMoneyMismatch`. There is no implicit conversion — exchange rates are a
higher policy layer, not a property of the type. (`Equals` is the one
exception: it returns `False` for different currencies rather than raising, so
it is total and safe in containers.)
`TMoney` has immutable value semantics: every operation returns a fresh value and
never mutates `Self`, exactly as `TDecimal` does.
=== Rationale
This is the universal industry pattern. Moneta (Java), the money gem (Ruby) and
rusty-money (Rust) each wrap a decimal with a currency tag rather than baking
currency into the numeric type. Keeping currency out of `TDecimal` preserves the
"one exact-decimal type" decision (see the previous section) and keeps locale and
jurisdiction policy — formatting, which currencies exist, per-currency rounding —
in a layer that can evolve without touching the numeric core.
A *string* currency code (rather than a closed `enum`) keeps the type open: new
or non-standard codes (crypto, historical, regional) work without a library
edit, matching money gem and rusty-money. The small loss of compile-time
checking is the right trade for an inventory that changes outside the compiler's
release cycle; an unknown code degrades gracefully to the fallback scale instead
of failing to compile.
Per-currency scale normalisation with banker's rounding on every operation makes
`TMoney` behave like real money: a JPY amount never carries a fraction, a USD
amount is always two places, and ties round without the upward bias of
round-half-up. Doing it on *every* operation (not just on display) keeps stored
values canonical, so equality and hashing are well-defined.
=== Alternatives considered
* *A `TCurrency` enum instead of a string code.* Rejected. It is type-safe but
closes the currency set: every new or non-standard code would require editing
and re-releasing the library. The open string code matches the dominant money
libraries and lets unknown codes degrade to the fallback scale.
* *Preserving full `TDecimal` scale and rounding only on display.* Rejected as
the default. Lazy rounding lets non-canonical values (e.g. an un-rounded
division result) leak into stored amounts and breaks value equality between
amounts that should be equal. Normalising eagerly keeps every `TMoney` at its
currency scale.
* *Baking exchange-rate conversion into `TMoney`.* Rejected. Rates are
time-varying external data, not a property of an amount; mixing them in would
make arithmetic depend on hidden state. Cross-currency arithmetic raises
instead, leaving conversion to an explicit higher layer.
* *A fixed-point money type (Int64 scaled).* Already rejected for `TDecimal`
(see the previous section); `TMoney` inherits `TDecimal`'s compact `Int64`
fast path for the common case, so it needs no separate fixed-point design.
== Conditional Compilation — Symbol-Presence `{$IFDEF}`, Predefined `BLAISE`
=== Decision
Blaise supports symbol-presence conditional compilation in the lexer:
`{$DEFINE sym}`, `{$UNDEF sym}`, `{$IFDEF sym}`/`{$IFNDEF sym}` with an optional
`{$ELSE}` and a closing `{$ENDIF}`, and a `-d`/`--define` command-line flag.
Symbols are case-insensitive. The compiler always predefines `BLAISE` and the
target's CPU/OS symbols (`CPUX86_64`, `CPUAMD64`, `LINUX`, `UNIX` on
Linux/x86-64). There is deliberately no expression form (`{$IF expr}`) — only
whether a symbol is defined.
=== Rationale
The feature was held back on purpose (the project builds itself without it,
delegating build-time configuration to PasBuild), but it is invaluable for
*end-user* source that must compile under FPC, Delphi and Blaise from one file:
`{$IFDEF BLAISE} ... {$ELSE} ... {$ENDIF}`. That cross-compiler use case is the
whole point of predefining `BLAISE`, and the CPU/OS predefines mirror the FPC
family so existing portable source compiles unchanged. This is the same
"every mainstream native compiler has it" argument the language applies to
parenthesised calls — conditional compilation is a near-universal expectation
for native Pascal, even though it is absent from managed languages like Java.
Implementing it in the lexer (not the parser) keeps excluded source from ever
reaching the parser or semantic pass, matching FPC/Delphi and avoiding any
interaction with type checking.
=== Alternatives considered
* *No conditional compilation (status quo, rely on PasBuild).* Rejected for
end-user code: writing a whole project with custom build paths to toggle one
line is disproportionate, and it does not help a single source file shared
across compilers.
* *An expression form `{$IF declared(X) and (Y > 2)}`.* Deferred. It needs a
constant-expression evaluator in the lexer and a `declared()` intrinsic;
symbol-presence `{$IFDEF}` covers the cross-compiler and feature-flag cases
that motivated the request. Can be added later without breaking `{$IFDEF}`.
* *A version macro predefine (e.g. `BLAISE_0_12_0`).* Not added yet — there is
no concrete use for it until the language stabilises; `BLAISE` alone serves
the cross-compiler case. CPU/OS symbols are added because portable source
routinely keys on them.
== Qualified Type Names — `UnitName.TypeName`
=== Decision
A type reference may be written with a unit qualifier, e.g.
`SysUtils.TFormatSettings`, and the qualifier may itself be dotted, e.g.
`System.SysUtils.TFormatSettings`. The parser reads the full dotted path;
a type is always named by its *final* component, and the leading components
name a unit already brought in by the `uses` clause. The qualifier is
informational — it does not select between units at resolution time; the
final identifier is resolved against the symbol table as usual.
=== Rationale
Object Pascal source in the wild routinely qualifies type names with their
declaring unit, both for documentation and to disambiguate when two used
units export the same name. Rejecting `SysUtils.TFormatSettings` as a parse
error blocked porting otherwise-portable FPC/Delphi code for no language
benefit. Accepting the qualifier and resolving by the final component matches
how FPC and Delphi treat a unit-qualified type in the common (non-ambiguous)
case, while keeping resolution simple.
=== Alternatives considered
* *Full unit-scoped resolution (the qualifier selects the unit).* Deferred.
It requires per-unit symbol scoping at the resolution site and only matters
when two used units export the same type name — rare, and the duplicate
case can be resolved today by ordering the `uses` clause. Resolving by the
final identifier covers the motivating real-world code without that
machinery, and can be tightened later without changing the syntax.
== Chained L-value Assignment — `Self.F[i].Sub := value`
=== Decision
The left-hand side of an assignment may be a selector chain: after the first
member, any sequence of `.Field`, `[Index]` (indexed-property or array-field
element), and `.Method(...)` selectors, terminated by `:=`. For example
`Self.FStack[0].Count := value`, `r.A[0] := 10`, and `obj.Items[i].Flag := True`.
The bare implicit-`Self` field form (`A[0] := v` inside a method) follows the
same rule.
=== Rationale
The earlier parser accepted a single `.Field` (optionally one subscript)
before `:=` but rejected a *chain* — `Self.FStack[0].Count := value` failed
because the `Ident.Ident[Index]` path unconditionally expected `]:=` after the
subscript. That is a routine and idiomatic Object Pascal l-value; rejecting it
forced awkward temporaries and surprised anyone porting normal code. The chain
loop after the subscript makes the read and write l-value grammars symmetric:
anything that can be read as a designator can be assigned to.
=== Alternatives considered
* *Require an intermediate variable (status quo).* Rejected — it is a
gratuitous restriction with no semantic justification; the read side already
supported the full chain, so write-side parity is the consistent choice.
== Inline Assembler — `asm … end` Routine Bodies
=== Decision
A routine body may be written as an inline-assembler block instead of a Pascal
`begin … end` block:
[source,pascal]
----
function GetSelf: Pointer; assembler; nostackframe;
asm
movq %rdi, %rax
ret
end;
----
The block content is verbatim GNU/AT&T assembly. The front end (lexer, parser,
semantic pass) never interprets it — the lexer captures the whole `asm … end`
as one opaque token, and the native backend emits the text verbatim into the
assembly stream, where the existing internal (or external) assembler parses it.
The `nostackframe` directive suppresses the compiler prologue/epilogue so the
asm body owns the entire frame (it provides its own `ret`). Inline asm requires
the native backend; the QBE backend rejects it with a diagnostic. `asm`
routines mix freely with ordinary Pascal routines in a standard `.pas` unit
(no `.inc` include mechanism is involved).
=== Rationale
The motivation is toolchain independence. The runtime's startup (`_start`),
`setjmp`/`longjmp`, and atomic primitives are hand-written assembly. Shipping
them as standalone `.s` files assembled by `cc -c` reintroduces a build-time gcc
dependency, defeating the internal assembler/linker. Expressing them as `asm`
bodies in Pascal units folds that assembly into Blaise's own pipeline — the same
approach FPC takes (`rtl/linux/x86_64/si_c.inc`).
The block is modelled as opaque text rather than a parsed instruction AST: the
internal assembler already consumes GNU/AT&T text and the native backend already
emits text, so the block needs only to reach the backend's stream verbatim.
Parsing instructions into the AST would duplicate the assembler and couple the
front end to one architecture's instruction set for no benefit. This keeps the
architecture-specific knowledge at the backend/assembler edge (a ports-and-
adapters boundary), so the portable core stays ignorant of x86-64 encoding. See
docs/inline-asm-design.adoc for the full design and the design patterns applied.
=== Alternatives considered
* *A standalone `blaise --assemble file.s -o file.o` mode (Route A).* Reaches
"no gcc in the RTL build" too and is smaller, but leaves assembly outside the
language and keeps a parallel build path. Inline `asm` blocks fold the
assembly into ordinary units, matching FPC and removing the special-case
build directory. Route A remains a valid stepping stone but was not pursued.
* *Parse instructions into a structured AST.* Rejected — duplicates the
assembler, couples the front end to x86-64, and provides nothing for the
text-to-text path.
* *Reserve `asm` only contextually (keep it usable as an identifier).* Rejected
— `asm` is a reserved word in FPC/Delphi and in the grammar; the codebase had
a single local variable named `Asm` (renamed), and contextual keywording would
need parser state the lexer does not have.
== Implementation-section Declarations are Unit-private
=== Decision
A type, class, constant, or routine declared in a unit's `implementation`
section is visible only inside that unit. It is never visible to a unit or
program that `uses` the declaring unit, even when both sit on the same unit
path. Only `interface`-section declarations are exported.
Cross-unit visibility otherwise follows the standard Pascal transitive rule:
a unit A sees a name from unit C when A `uses` B and B's *interface* section
`uses` C. Implementation-section `uses` of B are private to B.
=== Rationale
This is the long-standing Pascal/Delphi/FPC contract — the `implementation`
section is a unit's private body. Honouring it keeps a unit's internal helper
types from becoming an accidental part of its public surface, so an unrelated
unit cannot come to depend on them and the author stays free to rename or
remove them.
Mechanically, the compiler interns every unit's symbols into one shared symbol
table. An implementation-section symbol is tagged unit-private (`IsImplPrivate`)
at registration; `TSymbolTable.Lookup` suppresses such a symbol whenever the
unit currently being analysed is not its owner. The flat-table fallback that
previously returned any globally-interned symbol — and so leaked
implementation-section declarations into unrelated units — no longer applies to
unit-private symbols.
A class declared in the implementation section keeps its symbol reachable at
code generation (its type metadata — typeinfo, vtable, `_FieldCleanup` — is
emitted under the owning unit's mangling prefix, identical to an
interface-section class) by establishing the emitted unit as the symbol-table
viewing context during code generation. Without that, the suppression would
also hide the class from its own backend and the unit-qualified symbol names at
the reference and definition sites would disagree.
=== Alternatives considered
* *Keep the flat-table leak (status quo before this change).* Rejected — it
made every interned declaration, including private implementation-section
ones, silently visible to every other unit on the path. That is neither the
Pascal contract nor sound: it let a class declared in one unit's
implementation section resolve inside an unrelated unit that never used it.
* *Suppress all cross-unit symbols not reachable through `uses` (interface
symbols included), enforcing strict Delphi visibility everywhere.* A larger
correctness improvement, but it surfaces every pre-existing missing-`uses`
in the tree at once (each its own fix). Deferred; only the
implementation-section leak — the concrete defect — is closed here.
* *Give code generation a dedicated per-unit symbol table instead of a viewing
context.* Heavier plumbing for the same effect; the analyser already owns one
shared table that carries every unit's symbols, so scoping the *view* to the
emitted unit is the smaller, sufficient change.
== `static` Class Members — One Keyword, No `class`-Keyword Overload
=== Decision
Type-associated (class-level) members — fields, constants, methods, and
properties that belong to the type itself rather than to an instance — are
declared with the `static` keyword:
[source,pascal]
----
type
TRegistry = class
private static var
FInstance: TRegistry; { one shared slot, not per-instance }
private static const
MaxEntries = 256;
private
FItems: TList<TEntry>; { ordinary instance field }
static function GetInstance: TRegistry; { no Self; type-associated }
function GetItem(I: Integer): TEntry; { ordinary instance method }
public
static property Instance: TRegistry read GetInstance;
property Items[I: Integer]: TEntry read GetItem;
end;
static function TRegistry.GetInstance: TRegistry; { out-of-line impl }
begin
if FInstance = nil then
FInstance := TRegistry.Create(); { lazy singleton }
Result := FInstance;
end;
----
`static` may appear in two positions:
* as a *section qualifier* — `private static var`, `public static const`,
`protected static` — applying to every member declared until the next
visibility or section keyword; and
* as a *member prefix* — `static function`, `static procedure`,
`static property` — applying to that one member.
A `static` member receives no implicit `Self`. A `static var` / `static const`
occupies a single shared storage slot for the whole type, addressed as a
global; it does not advance the instance layout and is not copied per object.
A `static function` / `static procedure` is an ordinary routine that happens to
be name-qualified by its owning type and may read the type's other `static`
members. A `static property` is pure sugar over a `static` getter/setter.
Inside a record, a method declared `static` is the record's factory /
namespaced-function form — it takes no hidden `Self` pointer and is called as
`RecType.Method(...)`. (An ordinary record method still receives `Self` by
pointer; see <<_record_methods>>.) Records may also carry `static var` and
`static const` members.
Blaise has *no* `class` keyword in member position. The word `class` appears
only where it introduces a reference type (`T = class`), never as a member
qualifier. There is also no `static constructor` / `static destructor` — see
_No type-initialiser construct_ below.
=== Rationale
Delphi and FPC overload the single word `class` into three unrelated meanings:
the reference-type introducer (`T = class`), the member-association qualifier
(`class var`, `class function`, `class property`), and the type-initialiser
form (`class constructor`, `class destructor`). The three concepts have nothing
in common; sharing one keyword forces the reader to disambiguate by position
every time. Blaise has no existing source to keep compatible, so it splits the
concerns: `class` keeps only its structural meaning, and `static` carries the
member-association meaning it already carries in Java, C#, Swift, Rust, Go, and
Kotlin (`companion`/`static`). One keyword, one meaning.
The member-association concept is genuinely needed: the canonical singleton /
registry pattern (a private shared instance reached through a type-level
accessor) cannot be expressed with instance members alone. `static`
delivers it with a single, universally-understood keyword.
==== No type-initialiser construct (`static`/`class constructor`)
Blaise deliberately omits Delphi's `class constructor` / `class destructor`
(the run-once-at-startup type initialiser and its teardown counterpart). The
construct is unnecessary in Blaise because the two jobs it does are already
covered:
* *Zeroing a `static var`.* Blaise guarantees every variable — including
globals — holds its zero value before any code runs (see
<<_zero_initialisation_of_variables>>). A `static var FInstance: TRegistry`
is therefore `nil` at startup with no initialiser. Delphi needs the type
constructor here only because it makes no such guarantee.
* *Non-trivial one-time setup or teardown.* A lazy singleton initialises itself
on first access inside its `static` getter (the `if FInstance = nil` pattern
above); eager setup or teardown that genuinely must run once belongs in the
unit's existing `initialization` / `finalization` sections, which live in the
implementation section where code is permitted and run once in dependency
order. This is exactly Go's package-level `init()` model.
Re-using the spelling `static constructor Create` was rejected for the same
reason `class` was: it overloads `constructor` for something that is not a
callable constructor (it takes no arguments, is never invoked by name, and
cannot be overloaded). A nameless `static begin … end` initialiser block (the
Java/Kotlin shape) was also rejected: Pascal forbids code in the interface
section, where the class declaration lives, so an inline initialiser block
there would be alien to the language. The unit `initialization` section is the
idiomatic Pascal home and needs no new syntax.
=== Cross-language comparison
[cols="1,1,1,1,2", options="header"]
|===
| Language | Static field | Static method | Static property | Type initialiser
| *Blaise*
| `static var`
| `static function`
| `static property`
| none — zero-init + unit `initialization`
| Java
| `static`
| `static`
| (getter)
| `static { }` block (lazy)
| C#
| `static`
| `static`
| `static P { }`
| `static TFoo()` static constructor (lazy)
| Swift
| `static let/var`
| `static func`
| `static var { }`
| none — `static let` lazily initialised per-field
| Rust
| `static` / assoc `const`
| assoc `fn` (no `self`)
| (none)
| none — `static` is const-expr; lazy is library-level
| Go
| package `var`
| package func
| (none)
| `func init()` (package, once at startup)
| Kotlin
| `companion object`
| companion `fun`
| companion `val/var`
| companion `init { }`
| Delphi / FPC / Oxygene
| `class var`
| `class function`
| `class property`
| `class constructor` (lazy)
|===
The languages divide on the type-initialiser question. Java, C#, Kotlin, and
the Delphi family provide an explicit type-constructor block because a static
field can be left null/indeterminate and the programmer must run code to
populate it. Swift, Rust, and Go barely need one — either every static is
zero/const-initialised by the language, or lazy init is a per-field runtime
feature. Blaise sits with the second group precisely because of its zero-init
guarantee, so it follows Swift/Rust/Go and omits the type-constructor entirely
rather than following the Java/C#/Delphi machinery.
=== Visibility
`static` is orthogonal to visibility. It composes with `private`,
`protected`, and `public` (and, once visibility enforcement lands, with
`strict private`). `static` governs *storage and dispatch* — where the slot
lives and whether `Self` is passed — not *who may see the member*. The two
are independent axes and are implemented as independent features (see
<<_member_visibility_enforcement_private_protected_strict>>).
=== Alternatives rejected
* *Keep Delphi's `class var` / `class function` / `class property`.* Rejected.
It preserves the `class`-keyword overload that is the whole confusion this
decision removes, for no compatibility benefit Blaise can collect.
* *Infer static-ness of a record method from whether its body touches `Self`.*
Rejected. The hidden-`Self` ABI would then flip silently when an edit adds or
removes a `Self` reference. `static` makes the calling convention explicit at
the declaration. (See <<_record_methods>>.)
* *Adopt a `static constructor` or `static begin … end` type initialiser.*
Rejected — see _No type-initialiser construct_ above.
== Member Visibility Enforcement — `private` / `protected` / `strict`
=== Status
_Decided; implemented as the second half of the static-members work. This entry
records the decision; the spelling and enforcement rules below are the
committed design._
=== Decision
Visibility modifiers on class and record members are *enforced*, not merely
parsed. A member declared `private` is visible only within the unit that
declares the owning type; a `protected` member is additionally visible within
descendant types; a `public` (or `published`) member is visible everywhere the
type is. Blaise additionally supports `strict private` and `strict protected`,
which narrow visibility to the declaring *type* itself (and, for
`strict protected`, its descendants) rather than the whole declaring unit.
`strict` composes with `static`: `strict private static var FInstance: TFoo;`
is permitted, exactly as in Delphi. The `strict`-ness (who may see the member)
is independent of the `static`-ness (where the slot lives and whether `Self`
is passed).
A non-`strict` `private` member remains visible to the unit's
`initialization` / `finalization` sections and to other code in the same unit,
because those sections are part of the declaring unit. A `strict private`
member is *not* visible to them — it is reachable only from the methods of the
declaring type. This is the one interaction point between visibility and the
static-members feature: a `static var` that the unit `initialization` section
must populate eagerly must therefore not be `strict private`.
=== Rationale
Before this change, Blaise parsed `private` / `protected` / `published` but
enforced none of them, and had no `strict` keyword at all. `private` was
effectively a comment. Enforcing visibility closes a real gap — a unit's
internal class members were silently part of its usable surface — and makes the
`static` singleton pattern honest: a `private static var FInstance` can now be
genuinely unreachable from outside while the `public static property Instance`
is the sole entry point.
`strict` is included because the unit-scoped `private` of classic Pascal is
looser than most programmers coming from Java, C#, Swift, or Rust expect; those
languages scope `private` to the *type*. Offering both the Pascal unit-scoped
`private` and the type-scoped `strict private` lets the author choose, matching
Delphi and FPC.
=== Implementation order
Visibility enforcement lands *after* the `static`-members feature, as a
separate commit. Static members are on the critical path (they unblock real
code and exercise the codegen/OPDF stack); visibility enforcement is a pure
rejection pass with no codegen impact, and is cleaner to write once against the
complete, final set of member kinds (instance and static fields, methods, and
properties) than to write first and revisit when `static` arrives.
=== Alternatives rejected
* *Leave visibility unenforced (status quo).* Rejected — `private` that
protects nothing is a latent footgun and undermines the `static` singleton
pattern's encapsulation.
* *Enforce only the unit-scoped `private` (no `strict`).* Rejected — the
type-scoped `strict private` is the form most non-Pascal programmers expect,
and omitting it would force every genuinely type-private member to leak to
the whole unit.
* *Bundle visibility enforcement into the static-members commit.* Rejected —
the two are independent axes; sequencing them keeps each commit focused and
lets the codegen-touching feature accumulate field exposure first.