feat(const): compile-time integer constant expressions (#96)

A const declaration may now take a compile-time integer expression on the
right-hand side, not just a single literal:

  const A = 2 * 3;            // 6
        B = 2 + 3 * 4;        // 14 (precedence)
        C = (2 + 3) * 4;      // 20 (parentheses)
        D = 100 div 7;        // 14
        E = Base * 2 + 1;     // references a prior const

Previously only single literals and a flat, single-precedence bit-op chain
(a or b or c) were accepted; 2 * 3 failed with "Expected ';' but got '*'"
and (2 * 3) with "Expected numeric or string constant".

Parser: when the const RHS starts an integer expression (a leading '(', an
integer literal or leading-minus literal followed by a binary operator, or an
ident followed by a numeric operator), parse it with the existing
full-precedence ParseExpr into a normal expression AST stored on
TConstDecl.IntValueExpr. A bare literal keeps the fast IntVal path; '+' on an
ident is left to the string-concat path for backward compatibility.

Semantic: EvalConstIntExpr recursively folds the AST to an Int64, resolving
named-constant references against the symbol table. Folding in the semantic
pass (after all consts are registered) is what makes forward references work
regardless of declaration order. Wired into both the const-block and the
initialised-variable (var G = expr) fold sites. Operator precedence and
grouping come from the AST shape, superseding the precedence-unaware bit-op
token chain.

Scope: folds to integer. Float constant arithmetic and using a const as a
static-array bound remain separate, pre-existing limitations (noted in the
rationale).

Tests: eight IR/fold cases in cp.test.constants (multiply, parens, precedence,
div/mod, named-ref, unary minus, mixed arith+bitwise); one e2e case on both
backends in cp.test.e2e.misc. Docs: grammar.ebnf gains the ConstIntExpr rules;
language-rationale gains a Constant Expressions section.
This commit is contained in:
Graeme Geldenhuys 2026-06-13 19:14:14 +01:00
parent 9a511a22e2
commit 2708a6209e
7 changed files with 377 additions and 2 deletions

View file

@ -563,6 +563,15 @@ type
operator names (one of 'or'/'and'/'xor'/'shl'/'shr'). Semantic
resolves idents and folds to CD.IntVal. }
IntExprTokens: TStringList;
{ Integer constant expression in the value position — non-nil when the RHS
is a compile-time arithmetic/bitwise expression with operator precedence
and/or parentheses (e.g. '2 + 3 * 4', '(1 shl 8) or 7', 'BASE * 2').
Stored as a normal expression AST so precedence and grouping are encoded
structurally; the semantic pass folds it to CD.IntVal via EvalConstIntExpr
(resolving any named-constant references after all consts are declared).
Supersedes the flat IntExprTokens form for newly-parsed expressions; the
token form is retained only for the legacy single-precedence bit-op path. }
IntValueExpr: TASTExpr;
{ Parallel to ArrayElements — each non-nil entry is an IntExprTokens-
shaped TStringList describing the bit-op expression for that
element. Semantic folds it and overwrites ArrayElements[i] with
@ -2153,6 +2162,8 @@ begin
Result.IntExprTokens.AddObject(
ASrc.IntExprTokens.Strings[I], ASrc.IntExprTokens.Objects[I]);
end;
if ASrc.IntValueExpr <> nil then
Result.IntValueExpr := CloneExpr(ASrc.IntValueExpr);
end;
function CloneMethodParam(ASrc: TMethodParam): TMethodParam;

View file

@ -73,6 +73,8 @@ type
procedure ParseConstArrayGroup(CD: TConstDecl);
procedure ParseConstValue(CD: TConstDecl);
function TryParseConstIntTypecast(out AValue: Int64): Boolean;
function IsConstExprOp(AKind: TTokenKind): Boolean;
function ConstRhsStartsIntExpr: Boolean;
function CurrentIsConstBitOp: Boolean;
function ConsumeConstBitOpName: string;
function CollectConstBitOpExpr(const AFirstStr: string;
@ -761,6 +763,42 @@ end;
const initialiser: 'or', 'and', 'xor', 'shl', 'shr'. Used to detect
the start of a deferred bit-op expression chain after the first
operand has been parsed. }
{ True when the const RHS is a multi-token integer expression that must be
parsed with full operator precedence (via ParseExpr) rather than the
single-literal / single-precedence bit-op fast paths. Recognises:
- a leading '(' (always an arithmetic grouping in const position),
- an integer literal followed by any binary operator,
- a leading minus + integer literal followed by any binary operator.
A lone integer literal (no trailing operator) is deliberately NOT matched,
so the simple 'const X = 5;' fast path is preserved. }
function TParser.IsConstExprOp(AKind: TTokenKind): Boolean;
begin
Result := (AKind = tkPlus) or (AKind = tkMinus) or (AKind = tkStar)
or (AKind = tkDiv) or (AKind = tkMod)
or (AKind = tkOr) or (AKind = tkAnd) or (AKind = tkXor)
or (AKind = tkShl) or (AKind = tkShr);
end;
function TParser.ConstRhsStartsIntExpr: Boolean;
begin
Result := False;
if Check(tkLParen) then
Result := True
else if Check(tkIntLit) and Self.IsConstExprOp(PeekKind()) then
Result := True
else if Check(tkMinus) and (PeekKind() = tkIntLit)
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
Result := True;
end;
function TParser.CurrentIsConstBitOp: Boolean;
begin
Result := Check(tkOr) or Check(tkAnd) or Check(tkXor)
@ -1099,6 +1137,17 @@ begin
Self.ParseConstArrayGroup(CD);
Exit;
end;
{ Compile-time integer expression with precedence / parentheses
(e.g. 2 * 3, 2 + 3 * 4, (1 shl 8) or 7). Parsed into a normal
expression AST and folded by the semantic pass (EvalConstIntExpr),
which also resolves any named-constant references. A bare literal
without a trailing operator skips this and uses the fast path below. }
if Self.ConstRhsStartsIntExpr() then
begin
CD.IsString := False;
CD.IntValueExpr := Self.ParseExpr();
Exit;
end;
if Check(tkMinus) then
begin
Advance();

View file

@ -146,6 +146,12 @@ type
function NewArrayConstLabel(const AName: string): string;
function FoldConstBitOpExpr(ATokens: TStringList;
ALine, ACol: Integer): Int64;
{ Fold a compile-time integer constant expression AST (literals, named
constants, and the integer binary/unary operators with the precedence
already encoded in the tree shape) to its Int64 value. Named-constant
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;
procedure AnalyseTypeDecls(ABlock: TBlock);
procedure LinkClassMethodImpls(ABlock: TBlock);
procedure LinkGenericClassMethodImpls(ABlock: TBlock);
@ -3048,6 +3054,81 @@ begin
end;
end;
function TSemanticAnalyser.EvalConstIntExpr(AExpr: TASTExpr;
ALine, ACol: Integer): Int64;
var
Bin: TBinaryExpr;
IdSym: TSymbol;
L, R: Int64;
begin
Result := 0;
if AExpr = nil then Exit;
if AExpr is TIntLiteral then
Exit(TIntLiteral(AExpr).Value);
if AExpr is TIdentExpr then
begin
{ Named-constant reference; also resolves True/False to 1/0. }
if SameText(TIdentExpr(AExpr).Name, 'True') then Exit(1);
if SameText(TIdentExpr(AExpr).Name, 'False') then Exit(0);
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;
Exit(IdSym.ConstValue);
end;
if AExpr is TNotExpr then
Exit(not EvalConstIntExpr(TNotExpr(AExpr).Expr, ALine, ACol));
if AExpr is TBinaryExpr then
begin
Bin := TBinaryExpr(AExpr);
L := EvalConstIntExpr(Bin.Left, ALine, ACol);
R := EvalConstIntExpr(Bin.Right, ALine, ACol);
case Bin.Op of
boAdd: Exit(L + R);
boSub: Exit(L - R);
boMul: Exit(L * R);
boDiv:
begin
if R = 0 then
begin
SemanticError('Division by zero in constant expression', ALine, ACol);
Exit;
end;
Exit(L div R);
end;
boMod:
begin
if R = 0 then
begin
SemanticError('Division by zero in constant expression', ALine, ACol);
Exit;
end;
Exit(L mod R);
end;
boAnd: Exit(L and R);
boOr: Exit(L or R);
boXor: Exit(L xor R);
boShl: Exit(L shl R);
boShr, boSar: Exit(L shr R);
else
SemanticError(
'Unsupported operator in integer constant expression', ALine, ACol);
Exit;
end;
end;
SemanticError(
'Constant expression is not a compile-time integer', ALine, ACol);
end;
procedure TSemanticAnalyser.AnalyseSetConstDecl(ACD: TConstDecl);
var
I: Integer;
@ -3185,6 +3266,10 @@ 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
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
(AnalyseArrayConstDecls), like array consts. }
@ -5175,6 +5260,8 @@ begin
CD.Line, CD.Col);
if CD.IntExprTokens <> nil then
CD.IntVal := FoldConstBitOpExpr(CD.IntExprTokens, CD.Line, CD.Col);
if CD.IntValueExpr <> nil then
CD.IntVal := EvalConstIntExpr(CD.IntValueExpr, CD.Line, CD.Col);
{ Type compatibility check between the folded value kind and the declared
type — catches 'var N: Integer = ''text''' and similar. }

View file

@ -115,6 +115,17 @@ type
procedure TestTypedConst_OrChain_MixedLiteralAndNamed_Folds;
procedure TestTypedConst_AndMaskOfHexLiteral_Folds;
procedure TestTypedConst_ShiftLeftLiteral_Folds;
{ Compile-time integer constant expressions (issue #96) — arithmetic
operators, precedence, parentheses, and forward const references }
procedure TestConstExpr_Multiply_Folds;
procedure TestConstExpr_Parenthesised_Folds;
procedure TestConstExpr_PrecedenceMulOverAdd_Folds;
procedure TestConstExpr_ParensOverridePrecedence_Folds;
procedure TestConstExpr_DivAndMod_Folds;
procedure TestConstExpr_NamedConstReference_Folds;
procedure TestConstExpr_UnaryMinus_Folds;
procedure TestConstExpr_MixedArithAndBitwise_Folds;
procedure TestArrayConst_OrChainElement_FoldsToFinalInteger;
end;
@ -1324,6 +1335,123 @@ begin
AssertTrue('1 shl 8 = 256 in IR', IRContains(IR, '256'));
end;
procedure TConstTests.TestConstExpr_Multiply_Folds;
var IR: string;
begin
IR := GenIR(
'''
program P;
const X = 2 * 3;
var v: Integer;
begin v := X end.
''');
AssertTrue('IR non-empty', IR <> '');
AssertTrue('2 * 3 = 6 in IR', IRContains(IR, '6'));
end;
procedure TConstTests.TestConstExpr_Parenthesised_Folds;
var IR: string;
begin
IR := GenIR(
'''
program P;
const X = (2 * 3);
var v: Integer;
begin v := X end.
''');
AssertTrue('IR non-empty', IR <> '');
AssertTrue('(2 * 3) = 6 in IR', IRContains(IR, '6'));
end;
procedure TConstTests.TestConstExpr_PrecedenceMulOverAdd_Folds;
var IR: string;
begin
IR := GenIR(
'''
program P;
const X = 2 + 3 * 4;
var v: Integer;
begin v := X end.
''');
AssertTrue('IR non-empty', IR <> '');
{ 2 + (3 * 4) = 14, not (2 + 3) * 4 = 20 }
AssertTrue('2 + 3 * 4 = 14 in IR', IRContains(IR, '14'));
end;
procedure TConstTests.TestConstExpr_ParensOverridePrecedence_Folds;
var IR: string;
begin
IR := GenIR(
'''
program P;
const X = (2 + 3) * 4;
var v: Integer;
begin v := X end.
''');
AssertTrue('IR non-empty', IR <> '');
AssertTrue('(2 + 3) * 4 = 20 in IR', IRContains(IR, '20'));
end;
procedure TConstTests.TestConstExpr_DivAndMod_Folds;
var IR: string;
begin
IR := GenIR(
'''
program P;
const Q = 100 div 7;
R = 100 mod 7;
var a, b: Integer;
begin a := Q; b := R end.
''');
AssertTrue('IR non-empty', IR <> '');
AssertTrue('100 div 7 = 14 in IR', IRContains(IR, '14'));
AssertTrue('100 mod 7 = 2 in IR', IRContains(IR, '2'));
end;
procedure TConstTests.TestConstExpr_NamedConstReference_Folds;
var IR: string;
begin
IR := GenIR(
'''
program P;
const Base = 10;
Derived = Base * 2 + 1;
var v: Integer;
begin v := Derived end.
''');
AssertTrue('IR non-empty', IR <> '');
AssertTrue('Base * 2 + 1 = 21 in IR', IRContains(IR, '21'));
end;
procedure TConstTests.TestConstExpr_UnaryMinus_Folds;
var IR: string;
begin
IR := GenIR(
'''
program P;
const X = -5 * 3;
var v: Integer;
begin v := X end.
''');
AssertTrue('IR non-empty', IR <> '');
AssertTrue('-5 * 3 = -15 in IR', IRContains(IR, '-15'));
end;
procedure TConstTests.TestConstExpr_MixedArithAndBitwise_Folds;
var IR: string;
begin
IR := GenIR(
'''
program P;
const X = (1 shl 4) or 3;
var v: Integer;
begin v := X end.
''');
AssertTrue('IR non-empty', IR <> '');
{ (1 shl 4) or 3 = 16 or 3 = 19 }
AssertTrue('(1 shl 4) or 3 = 19 in IR', IRContains(IR, '19'));
end;
procedure TConstTests.TestArrayConst_OrChainElement_FoldsToFinalInteger;
var IR: string;
begin

View file

@ -36,6 +36,7 @@ type
procedure TestRun_Const_IntegerConst;
procedure TestRun_Const_StringConst;
procedure TestRun_Const_NegativeConst;
procedure TestRun_Const_CompileTimeExpression;
procedure TestRun_Const_LocalArrayInFunction;
{ Procedural types }
@ -224,6 +225,26 @@ const
end.
''';
{ issue #96 — const declared with a compile-time formula (precedence,
parentheses, division, and a forward reference to a prior const). }
SrcConstExpr = '''
program Prg;
const
A = 2 * 3;
B = 2 + 3 * 4;
C = (2 + 3) * 4;
D = 100 div 7;
Base = 10;
E = Base * 2 + 1;
begin
WriteLn(A);
WriteLn(B);
WriteLn(C);
WriteLn(D);
WriteLn(E)
end.
''';
{ Regression: typed array constant declared inside a function body was
referenced as $Name but never emitted as a data item, producing a
link error. Exercises the full toolchain (codegen + QBE + ld). }
@ -754,6 +775,14 @@ begin
AssertRunsOnAll(SrcConstNeg, '-20' + LE, 0);
end;
procedure TE2EMiscTests.TestRun_Const_CompileTimeExpression;
begin
if not ToolchainAvailable() then begin Ignore('toolchain unavailable'); Exit; end;
{ A=6, B=14 (precedence), C=20 (parens), D=14 (div), E=21 (named-ref). }
AssertRunsOnAll(SrcConstExpr,
'6' + LE + '14' + LE + '20' + LE + '14' + LE + '21' + LE, 0);
end;
procedure TE2EMiscTests.TestRun_ProcType_CallViaVariable;
var Output: string; RCode: Integer;
begin

View file

@ -509,13 +509,34 @@ 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. *)
ConstExpr
= [ MINUS ] IntLit
= ConstIntExpr
| [ MINUS ] FloatLit
| StrLit
| StrLit { PLUS StrLit } (* string constant, with concatenation *)
| IDENT (* named constant reference, e.g. True / False *)
;
ConstIntExpr
= ConstIntTerm { ( PLUS | MINUS | OR | XOR ) ConstIntTerm }
;
ConstIntTerm
= ConstIntFactor { ( STAR | DIV | MOD | AND | SHL | SHR ) ConstIntFactor }
;
ConstIntFactor
= IntLit
| IDENT (* named integer constant *)
| MINUS ConstIntFactor (* unary minus *)
| LPAREN ConstIntExpr RPAREN
;
(* ConstDecl supports untyped, scalar-typed, array-typed, and set-typed forms:
* Untyped: const MaxItems = 100;
* Scalar-typed: const Pi: Double = 3.14159;

View file

@ -2731,6 +2731,56 @@ 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