feat(const): fold floating-point constant expressions at compile time (#108)

Constant declarations now accept arithmetic expressions involving float
literals, named float constants, and the '/' operator.  The semantic pass
detects float-containing expressions and folds them to a single Double
value at compile time, storing the result as a string — the same format
used for bare float literals.

The '/' operator always yields a float result even with integer operands,
matching Delphi/FPC semantics (e.g. const X = 10 / 4 produces 2.5).

Parser: extended ConstRhsStartsIntExpr to detect float-led and
slash-containing expressions; added tkSlash to IsConstExprOp.

Semantic: added IsFloatConstExpr (detects float leaves or '/' usage) and
EvalConstFloatExpr (folds via libc strtod/snprintf to avoid a known
self-hosting ABI issue with the Blaise StrToDouble built-in).

Tests: 8 IR unit tests + 4 E2E tests (both backends).
Grammar and language rationale updated.
This commit is contained in:
Graeme Geldenhuys 2026-06-17 18:48:04 +01:00
parent f6d488bca4
commit 38c77c8a93
6 changed files with 394 additions and 22 deletions

View file

@ -777,7 +777,7 @@ end;
function TParser.IsConstExprOp(AKind: TTokenKind): Boolean;
begin
Result := (AKind = tkPlus) or (AKind = tkMinus) or (AKind = tkStar)
or (AKind = tkDiv) or (AKind = tkMod)
or (AKind = tkSlash) or (AKind = tkDiv) or (AKind = tkMod)
or (AKind = tkOr) or (AKind = tkAnd) or (AKind = tkXor)
or (AKind = tkShl) or (AKind = tkShr);
end;
@ -789,16 +789,22 @@ begin
Result := True
else if Check(tkIntLit) and Self.IsConstExprOp(PeekKind()) then
Result := True
else if Check(tkFloatLit) and Self.IsConstExprOp(PeekKind()) then
Result := True
else if Check(tkMinus) and (PeekKind() = tkIntLit)
and Self.IsConstExprOp(PeekKind2()) then
Result := True
else if Check(tkMinus) and (PeekKind() = tkFloatLit)
and Self.IsConstExprOp(PeekKind2()) then
Result := True
{ Ident-led integer expression (e.g. BASE * 2). Only the unambiguously
numeric operators trigger this — '+' is left to the string path so that
string-concat consts ('a' + b) keep their existing behaviour, and the
legacy ident-bitop chain handles or/and/xor/shl/shr starting on an ident. }
else if Check(tkIdent) and
((PeekKind() = tkStar) or (PeekKind() = tkDiv) or
(PeekKind() = tkMod) or (PeekKind() = tkMinus)) then
((PeekKind() = tkStar) or (PeekKind() = tkSlash) or
(PeekKind() = tkDiv) or (PeekKind() = tkMod) or
(PeekKind() = tkMinus)) then
Result := True;
end;

View file

@ -163,6 +163,8 @@ type
references are resolved against the symbol table, so this must run after
the referenced consts are declared. }
function EvalConstIntExpr(AExpr: TASTExpr; ALine, ACol: Integer): Int64;
function EvalConstFloatExpr(AExpr: TASTExpr; ALine, ACol: Integer): string;
function IsFloatConstExpr(AExpr: TASTExpr): Boolean;
procedure AnalyseTypeDecls(ABlock: TBlock);
procedure LinkClassMethodImpls(ABlock: TBlock);
procedure LinkGenericClassMethodImpls(ABlock: TBlock);
@ -455,6 +457,22 @@ type
implementation
function _strtod(S: PChar; EndPtr: Pointer): Double; external name 'strtod';
function _snprintf(Buf: PChar; Size: Int64; Fmt: PChar; V: Double): Integer; external name 'snprintf';
function RawDoubleToStr(V: Double): string;
var
Buf: array[0..63] of Byte;
begin
_snprintf(PChar(@Buf[0]), 64, PChar('%.15g'), V);
Result := string(PChar(@Buf[0]));
end;
function RawStrToDouble(const S: string): Double;
begin
Result := _strtod(PChar(S), nil);
end;
function TSemanticAnalyser.GetSymbolTable: TSymbolTable;
begin
Result := FTable;
@ -3664,6 +3682,99 @@ begin
'Constant expression is not a compile-time integer', ALine, ACol);
end;
function TSemanticAnalyser.IsFloatConstExpr(AExpr: TASTExpr): Boolean;
var
Bin: TBinaryExpr;
Sym: TSymbol;
begin
Result := False;
if AExpr = nil then Exit;
if AExpr is TFloatLiteral then
Exit(True);
if AExpr is TIdentExpr then
begin
Sym := FTable.Lookup(TIdentExpr(AExpr).Name);
if (Sym <> nil) and (Sym.Kind = skConstant) and
(Sym.TypeDesc <> nil) and Sym.TypeDesc.IsFloat() then
Exit(True);
Exit;
end;
if AExpr is TBinaryExpr then
begin
Bin := TBinaryExpr(AExpr);
if Bin.Op = boSlash then
Exit(True);
if IsFloatConstExpr(Bin.Left) or IsFloatConstExpr(Bin.Right) then
Exit(True);
end;
end;
function TSemanticAnalyser.EvalConstFloatExpr(AExpr: TASTExpr;
ALine, ACol: Integer): string;
var
Bin: TBinaryExpr;
IdSym: TSymbol;
L, R: Double;
LStr: string;
RStr: string;
begin
Result := '0';
if AExpr = nil then Exit;
if AExpr is TFloatLiteral then
Exit(TFloatLiteral(AExpr).Value);
if AExpr is TIntLiteral then
Exit(IntToStr(TIntLiteral(AExpr).Value));
if AExpr is TIdentExpr then
begin
IdSym := FTable.Lookup(TIdentExpr(AExpr).Name);
if (IdSym = nil) or (IdSym.Kind <> skConstant) then
begin
SemanticError(Format(
'Constant expression references ''%s'', which is not a constant',
[TIdentExpr(AExpr).Name]), ALine, ACol);
Exit;
end;
if (IdSym.TypeDesc <> nil) and IdSym.TypeDesc.IsFloat() then
Exit(IdSym.ConstString)
else
Exit(IntToStr(IdSym.ConstValue));
end;
if AExpr is TBinaryExpr then
begin
Bin := TBinaryExpr(AExpr);
LStr := EvalConstFloatExpr(Bin.Left, ALine, ACol);
RStr := EvalConstFloatExpr(Bin.Right, ALine, ACol);
L := RawStrToDouble(LStr);
R := RawStrToDouble(RStr);
case Bin.Op of
boAdd: L := L + R;
boSub: L := L - R;
boMul: L := L * R;
boSlash, boDiv:
begin
if R = 0.0 then
begin
SemanticError('Division by zero in constant expression', ALine, ACol);
Exit;
end;
L := L / R;
end;
else
SemanticError(
'Unsupported operator in floating-point constant expression', ALine, ACol);
Exit;
end;
Exit(RawDoubleToStr(L));
end;
SemanticError(
'Constant expression is not a compile-time float', ALine, ACol);
end;
procedure TSemanticAnalyser.AnalyseSetConstDecl(ACD: TConstDecl);
var
I: Integer;
@ -3837,9 +3948,13 @@ begin
named-constant values in scope. }
if CD.IntExprTokens <> nil then
CD.IntVal := FoldConstBitOpExpr(CD.IntExprTokens, CD.Line, CD.Col);
{ Compile-time integer expression (arithmetic/bitwise with precedence):
fold the AST now that prior consts in scope are resolvable. }
if CD.IntValueExpr <> nil then
if (CD.IntValueExpr <> nil) and IsFloatConstExpr(CD.IntValueExpr) then
begin
CD.StrVal := EvalConstFloatExpr(CD.IntValueExpr, CD.Line, CD.Col);
CD.IsFloat := True;
CD.IsString := False;
end
else if CD.IntValueExpr <> nil then
CD.IntVal := EvalConstIntExpr(CD.IntValueExpr, CD.Line, CD.Col);
{ Set-valued constants reference enum members, which are not registered
until AnalyseTypeDecls runs — so they are resolved in the second pass

View file

@ -132,6 +132,16 @@ type
procedure TestConstExpr_UnaryMinus_Folds;
procedure TestConstExpr_MixedArithAndBitwise_Folds;
procedure TestArrayConst_OrChainElement_FoldsToFinalInteger;
{ Compile-time floating-point constant expressions (issue #108) }
procedure TestConstExpr_FloatMultiply_Folds;
procedure TestConstExpr_FloatDivide_Folds;
procedure TestConstExpr_FloatAddSub_Folds;
procedure TestConstExpr_FloatParens_Folds;
procedure TestConstExpr_FloatMixedIntFloat_Folds;
procedure TestConstExpr_FloatNamedConst_Folds;
procedure TestConstExpr_NegativeFloat_Folds;
procedure TestConstExpr_IntSlash_ProducesFloat;
end;
implementation
@ -1540,6 +1550,124 @@ begin
AssertTrue('99 also in $T data item', IRContains(IR, 'w 99'));
end;
{ ------------------------------------------------------------------ }
{ Compile-time floating-point constant expressions (issue #108) }
{ ------------------------------------------------------------------ }
procedure TConstTests.TestConstExpr_FloatMultiply_Folds;
var IR: string;
begin
IR := GenIR(
'''
program P;
const X = 3.0 * 2.0;
var V: Double;
begin V := X end.
''');
AssertTrue('IR non-empty', IR <> '');
AssertTrue('folded to 6', IRContains(IR, 'd_6'));
end;
procedure TConstTests.TestConstExpr_FloatDivide_Folds;
var IR: string;
begin
IR := GenIR(
'''
program P;
const X = 10.0 / 4.0;
var V: Double;
begin V := X end.
''');
AssertTrue('IR non-empty', IR <> '');
AssertTrue('folded to 2.5', IRContains(IR, 'd_2.5'));
end;
procedure TConstTests.TestConstExpr_FloatAddSub_Folds;
var IR: string;
begin
IR := GenIR(
'''
program P;
const X = 1.5 + 2.5;
var V: Double;
begin V := X end.
''');
AssertTrue('IR non-empty', IR <> '');
AssertTrue('folded to 4', IRContains(IR, 'd_4'));
end;
procedure TConstTests.TestConstExpr_FloatParens_Folds;
var IR: string;
begin
IR := GenIR(
'''
program P;
const X = (1.0 + 2.0) * 3.0;
var V: Double;
begin V := X end.
''');
AssertTrue('IR non-empty', IR <> '');
AssertTrue('folded to 9', IRContains(IR, 'd_9'));
end;
procedure TConstTests.TestConstExpr_FloatMixedIntFloat_Folds;
var IR: string;
begin
IR := GenIR(
'''
program P;
const X = 2 * 1.5;
var V: Double;
begin V := X end.
''');
AssertTrue('IR non-empty', IR <> '');
AssertTrue('folded to 3', IRContains(IR, 'd_3'));
end;
procedure TConstTests.TestConstExpr_FloatNamedConst_Folds;
var IR: string;
begin
IR := GenIR(
'''
program P;
const
PI = 3.14;
TAU = PI * 2.0;
var V: Double;
begin V := TAU end.
''');
AssertTrue('IR non-empty', IR <> '');
AssertTrue('folded to 6.28', IRContains(IR, 'd_6.28'));
end;
procedure TConstTests.TestConstExpr_NegativeFloat_Folds;
var IR: string;
begin
IR := GenIR(
'''
program P;
const X = -1.5 * 2.0;
var V: Double;
begin V := X end.
''');
AssertTrue('IR non-empty', IR <> '');
AssertTrue('folded to -3', IRContains(IR, 'd_-3'));
end;
procedure TConstTests.TestConstExpr_IntSlash_ProducesFloat;
var IR: string;
begin
IR := GenIR(
'''
program P;
const X = 10 / 4;
var V: Double;
begin V := X end.
''');
AssertTrue('IR non-empty', IR <> '');
AssertTrue('folded to 2.5', IRContains(IR, 'd_2.5'));
end;
initialization
RegisterTest(TConstTests);

View file

@ -155,6 +155,12 @@ type
{ Integer arguments to float builtins + float typecasts }
procedure TestRun_Trig_IntegerArgs;
procedure TestRun_FloatCast_FromInteger;
{ Float constant expressions (issue #108) }
procedure TestRun_FloatConstExpr_MulDiv;
procedure TestRun_FloatConstExpr_MixedIntFloat;
procedure TestRun_FloatConstExpr_NamedRef;
procedure TestRun_FloatConstExpr_IntSlash;
end;
implementation
@ -1535,6 +1541,63 @@ begin
''', '32' + LE + '64' + LE + '1000' + LE, 0);
end;
{ ------------------------------------------------------------------ }
{ Float constant expressions (issue #108) }
{ ------------------------------------------------------------------ }
procedure TE2EMathTests.TestRun_FloatConstExpr_MulDiv;
begin
if not ToolchainAvailable() then begin Ignore('toolchain unavailable'); Exit; end;
AssertRunsOnAll('''
program P;
const
X = 3.0 * 2.0;
Y = 10.0 / 4.0;
begin
WriteLn(Round(X));
WriteLn(Y)
end.
''', '6' + LE + '2.5' + LE, 0);
end;
procedure TE2EMathTests.TestRun_FloatConstExpr_MixedIntFloat;
begin
if not ToolchainAvailable() then begin Ignore('toolchain unavailable'); Exit; end;
AssertRunsOnAll('''
program P;
const X = 2 * 1.5;
begin
WriteLn(X)
end.
''', '3' + LE, 0);
end;
procedure TE2EMathTests.TestRun_FloatConstExpr_NamedRef;
begin
if not ToolchainAvailable() then begin Ignore('toolchain unavailable'); Exit; end;
AssertRunsOnAll('''
program P;
const
PI = 3.14;
TAU = PI * 2.0;
begin
WriteLn(TAU)
end.
''', '6.28' + LE, 0);
end;
procedure TE2EMathTests.TestRun_FloatConstExpr_IntSlash;
begin
if not ToolchainAvailable() then begin Ignore('toolchain unavailable'); Exit; end;
AssertRunsOnAll('''
program P;
const X = 10 / 4;
begin
WriteLn(X)
end.
''', '2.5' + LE, 0);
end;
initialization
RegisterTest(TE2EMathTests);

View file

@ -515,32 +515,45 @@ ConstArrayValue
{ COMMA ( ConstArrayValue | ConstExpr ) } RPAREN
;
(* A constant value position. A scalar const may be a string/float literal or
* a compile-time INTEGER expression: integer literals, named-constant
* references, the binary operators ( + - * div mod and or xor shl shr ), unary
* minus, and parentheses, with the usual operator precedence. The integer
* expression is folded to a single value by the semantic pass (which resolves
* named-constant references), so it may appear anywhere a constant is required. *)
(* A constant value position. A scalar const may be a string/float literal, a
* compile-time integer expression, or a compile-time float expression.
*
* INTEGER expressions: integer literals, named integer-constant references,
* binary operators ( + - * div mod and or xor shl shr ), unary minus, and
* parentheses, with the usual operator precedence.
*
* FLOAT expressions: any expression that contains at least one float literal,
* a named float constant, or uses the '/' operator (which always yields a
* float result, even with integer operands — matching Delphi/FPC semantics).
* Supports the arithmetic operators + - * / div, unary minus, named-constant
* references, and parentheses.
*
* Both integer and float expressions are folded to a single value by the
* semantic pass, which resolves named-constant references. *)
ConstExpr
= ConstIntExpr
| [ MINUS ] FloatLit
= ConstArithExpr
| StrLit { PLUS StrLit } (* string constant, with concatenation *)
| IDENT (* named constant reference, e.g. True / False *)
;
ConstIntExpr
= ConstIntTerm { ( PLUS | MINUS | OR | XOR ) ConstIntTerm }
(* Arithmetic constant expression — covers both integer and float. The
* semantic pass inspects the AST: if any leaf is a float literal, a named
* float constant, or the '/' operator is used, the expression is folded as
* a float; otherwise it is folded as an integer. *)
ConstArithExpr
= ConstArithTerm { ( PLUS | MINUS | OR | XOR ) ConstArithTerm }
;
ConstIntTerm
= ConstIntFactor { ( STAR | DIV | MOD | AND | SHL | SHR ) ConstIntFactor }
ConstArithTerm
= ConstArithFactor { ( STAR | SLASH | DIV | MOD | AND | SHL | SHR ) ConstArithFactor }
;
ConstIntFactor
ConstArithFactor
= IntLit
| IDENT (* named integer constant *)
| MINUS ConstIntFactor (* unary minus *)
| LPAREN ConstIntExpr RPAREN
| FloatLit
| IDENT (* named integer or float constant *)
| MINUS ConstArithFactor (* unary minus *)
| LPAREN ConstArithExpr RPAREN
;
(* ConstDecl supports untyped, scalar-typed, array-typed, and set-typed forms:

View file

@ -4676,3 +4676,50 @@ serialised and re-instantiated later.
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.