📖 Документация Qumir

← Вернуться в Playground

Core Language

Core lang is the internal surface syntax for the Qumir AST. It is used by tests, golden files, debugging output, and the playground "core syntax" mode. The syntax is intentionally close to S-expressions: most AST nodes are written as parenthesized forms, while composite types use angle brackets.

The motivation is practical: the user-facing Kumir syntax is small and conservative, which makes it awkward to introduce and debug new language constructs directly in .kum first. Core lang lets us start from the AST shape: add or inspect a node, write it explicitly, run it through semantic passes and lowering, and only later decide whether and how it should be exposed in the Kumir frontend syntax.

The implementation lives in qumir/parser/core/lexer.cpp, qumir/parser/core/parser.cpp, and qumir/parser/core/printer.cpp.

Lexical Syntax

Whitespace separates tokens and is otherwise ignored.

Delimiters and structural operators:

Token Meaning
( ) expression forms and nested lists
< > composite type forms
[ ] array bounds, index vectors, slices, and generic parameter lists
: type annotation form head

Core source is UTF-8 text. Simple identifiers start with a letter, _, $, or a non-ASCII byte. They may continue with letters, digits, _, $, :, or non-ASCII bytes:

foo
my_var
$tmp
module:name
имя

Symbolic identifiers are made from operator characters:

+ - * / = ! & ^
<= >= << >> || |

<, >, [, ], (, ), and : are delimiter tokens by default. The lexer has one special case for operator heads: after an opening (, two-byte <<, <=, >>, and >= are read as identifiers so forms like (>> x 1) are valid operator calls.

Bar identifiers allow spaces:

|foo bar|

String and character literals support the common escapes \n, \t, \\, \", and \':

"hello\nworld"
'\n'

Numeric and boolean literals:

123
-42
1.25
.5
3e-2
#t
#f

nil is a reserved identifier used as the empty expression/type value in places where the AST allows a missing child.

Expressions

An atom expression is one of:

Syntax AST node
name TIdentExpr, except reserved break/continue
integer, float, boolean TNumberExpr
character TNumberExpr with char type
string TStringLiteralExpr
break TBreakStmt
continue TContinueStmt
nil null TExprPtr

Parenthesized forms use the first item as a form name. Unknown two-argument forms are parsed as binary operator expressions, and unknown one-argument forms are parsed as unary operator expressions.

Core Forms

Assignment:

(= name value)
(= name [index1 ... indexN] value)

The first form creates TAssignExpr; the indexed form creates TArrayAssignExpr.

Unary and binary operators:

(op operand)
(op left right)

Associative operators (+, -, *, /, &, |, ^, &&, ||, xor) also accept more than two arguments and fold left into a chain of binary nodes — (&& a b c) is exactly (&& (&& a b) c), so &&/|| keep their short-circuit semantics. The other operators (comparisons, shifts, %) stay strictly binary.

Blocks:

(block stmt1 stmt2 ... stmtN)

block introduces a nested lexical scope. Its value is the value of its last statement (or void if the block is empty or the last statement does not produce a value) — this is how function bodies return a value implicitly, see "Functions" below.

The outermost block (depth 1) enforces a strict statement order:

(pragma ...)* → (use ...)* → (type ...)* → other statements

Pragmas are virtual nodes — not represented in the AST — collected into TParser::Pragmas for the caller to apply after Parse() returns.

(block
  (pragma language overloads)
  stmt1
  stmt2 ...)

A pragma has the form (pragma group value1 value2 ...). Pragmas must appear before use and type declarations.

use imports a module, making its functions and types available in scope. use must appear after pragmas and before type declarations.

(block
  (use "Цвета")
  ...)

Core-lang does not define an I/O runtime and does not import anything implicitly: a pure core program imports only what it spells with use. The runtime modules (such as System) are provided by the host. The qumiri and qumirc core modes import System as a host prelude so that forms like output resolve; module aliases like the legacy Kumir Файлы exist only in the Kumir frontend.

type declares a named type alias. It must appear after use statements. Declaring a type whose name is already imported from a module is an error.

Currently defined pragmas:

Group Value Effect
language overloads Enable user-defined function overloading (off by default)

Conditionals:

(if condition then)
(if condition then else)

if creates TIfExpr, used both as a statement and as a value-producing expression — its value is the value of the taken branch. The else branch is optional. The older (cond ...) spelling is rejected with an error pointing at (if ...).

Loops:

(while condition body)
(repeat body condition)
(for name from to step body)
(times count body)
break
continue

for always has a step expression in core syntax. Use nil if a missing step must be represented.

Variables and variable blocks:

(var name type)
(var name type [from1 to1] ... [fromN toN])
(var name = value)
(vars var1 var2 ... varN)

Array bounds are declaration metadata on TVarStmt, not part of the TArrayType identity. The (var name = value) form omits the type entirely — it is inferred from value's type during type annotation.

Functions:

(fun name (param1 ... paramN) body)
(fun name (param1 ... paramN) -> return_type body)
(fun name (param1 ... paramN) -> return_type (attrs (expect_after expr) (expect_before expr) ...) body)
(fun name [generic_param1 ... generic_paramN] (param1 ... paramN) -> return_type body)

Each parameter is a (var ...) form; (param1 ... paramN) is required even when empty (()). The body must be a block. The return type defaults to void when -> return_type is omitted.

[generic_param1 ...] is an optional generic parameter list placed immediately after the function name. A bare generic parameter name declares a type parameter, for example [T] or [K V]. Value generic parameters may be parsed as (const Name type) (or (Name type), which prints canonically with const). Only integer value parameters are semantically supported today.

(attrs ...) is an optional attribute list, placed after the return type (if any) and before the body. Recognized attributes:

(expect_after expr)
(expect_before expr)
(extern native_symbol)
(extern "native_symbol")
(operator "OP")
extern
print
used

The parser stores expect_after on TFunDecl::LastAssert. expect_before is parsed for forward compatibility. Bare-identifier attributes other than the recognized ones above (e.g. inline) are accepted and silently ignored for forward compatibility.

External function attributes

extern marks a function declaration as implemented by a native symbol instead of a core-lang body. The function still has a syntactic body, usually an empty (block), but semantic analysis and lowering ignore it.

; native symbol name is the same as the core function name: llabs
(fun llabs ((var x i64)) -> i64 (attrs extern)
  (block))

; core function name is my_abs, native symbol name is llabs
(fun my_abs ((var x i64)) -> i64 (attrs (extern llabs))
  (block))

; explicit symbol may also be written as a string
(fun my_abs2 ((var x i64)) -> i64 (attrs (extern "llabs"))
  (block))

The selected native symbol is stored in TFunDecl::MangledName. The core printer emits external functions canonically as (extern native_symbol), even when the source used bare extern.

Operator, cast and print attributes

A function may implement an operator, a cast or a type's printer instead of being called by name. This lets a source module (or program) provide the same overloaded-by-type behavior that built-in runtime modules do — without enabling language overloads.

; binary operator: a + b for tuple dispatches here
(fun add ((var a tuple) (var b tuple)) -> tuple (attrs (operator "+"))
  (block ...))

; unary operator (e.g. "neg" for unary minus)
(fun negate ((var a tuple)) -> tuple (attrs (operator "neg"))
  (block ...))

; cast: implicit coercion from i64 to tuple (the magic name is "cast")
(fun from_int ((var n i64)) -> tuple (attrs (operator "cast"))
  (block ...))

; printer: outputting a tuple value via (output ...) dispatches here
(fun show ((var p tuple)) (attrs print)
  (block (output "(" (field p a) ";" (field p b) ")")))

These functions are resolved by operand/result types, in the resolver's dispatch tables (ImportedBinaryOps / ImportedUnaryOps / ImportedCasts) — not by their declared name. The declared name stays an ordinary symbol: e.g. naming the printer print does not clash with the operator key "print", since the latter lives in a separate table and is invoked through a synthetic name. Explicit casts (cast x T) are not routed through (operator "cast"); only implicit coercions (assignment, arguments) are.

<main> is the program's entry point: (fun <main> () body). It is always void.

Return

A non-void function returns its result either via an explicit (return expr) or implicitly: if execution falls off the end of the body without hitting a return, the value of the body block's last statement becomes the return value. Falling off the end without producing a value is a compile error ("Тело функции должно заканчиваться оператором return или выражением, возвращающим значение.").

(return)
(return expr)

(return) is only valid in void functions, (return expr) only in non-void ones. Both work for every return type, including structs and named types.

(fun square ((var x i64)) -> i64
  (block
    (* x x)))            ; implicit return: value of the last statement

(fun cube ((var x i64)) -> i64
  (block
    (var sq i64)
    (= sq (* x x))
    (return (* sq x))))  ; explicit return

break/continue/return release any in-scope locals with pending destructors (string/array) before transferring control: break/continue release locals declared since the start of the nearest enclosing loop body; return releases locals declared since the start of the function body.

Type declarations:

(type name underlying_type)
(type name [generic_param1 ... generic_paramN] underlying_type)

Declares a named type alias. name becomes available as a bare type identifier in the surrounding block scope. Named types are transparent to the IR — the IR type is the same as the underlying type.

[generic_param1 ...] is an optional generic parameter list placed immediately after the type name. It uses the same syntax as function generic parameters: a bare name declares a type parameter, while (const Name type) declares a compile-time value parameter. Only integer value parameters are supported today. Generic parameters are introduced into the type declaration's type scope, so type parameters can be used anywhere a type is expected inside the underlying type, and value parameters can be used as value arguments to nested named types:

(block
  (type имя <struct (val i64)>)
  (type Box [T] <struct (Value T)>)
  (type Nullable [T] <struct (Value T) (Valid bool)>)
  (type Decimal [(const Scale i32) (const Precision i32)] <struct (Value i64)>)
  (fun make_name ((var v i64)) -> <named имя>
    (block
      (return (: (struct ((val v))) <named имя>))))
  (fun unwrap_box ((var b <named Box [i64]>)) -> i64
    (block
      (return (field b Value))))
  (fun <main> ()
    (block
      (var n <named имя>)
      (= n (call make_name 42)))))

In type position the long form is <named name> or <named name [type_arg1 ... type_argN]>. An optional inline underlying type may still be written after the name/arguments as <named name underlying_type> or <named name [type_arg1 ...] underlying_type>, but normal source should prefer declaring the alias once with (type ...) and referring to it by name.

Type arguments are written as types. Integer value arguments are written as integer literals, or as bare value-parameter names while resolving another generic type/function. For example, <named Decimal [2 10]> is a concrete decimal type, while <named Decimal [Scale Precision]> is valid inside a scope that declares (const Scale i32) and (const Precision i32).

Calls and I/O:

(call callee arg1 ... argN)
(input arg1 ... argN)
(output arg1 ... argN)
(output (fmt expr width) (fmt expr width precision))

fmt is the output argument wrapper for width and optional precision.

Overloaded functions:

(block
  (fun pick ((var x i64)) -> i64
    (block
      (+ x (: 10 i64))))

  (fun pick ((var x f64)) -> f64
    (block
      (+ x (: 0.5 f64))))

  (fun pick ((var x string)) -> string
    (block
      x))

  (fun <main> ()
    (block
      (output (call pick (: 7 i32)) "\n")
      (output (call pick (: 2.5 f64)) "\n")
      (output (call pick "text") "\n"))))

Multiple fun forms with the same source name form an overload set when their parameter type lists differ. Return type alone is not enough to distinguish overloads.

Overload resolution runs after argument type annotation. Exact matches are preferred over implicit conversions. Integer widening is preferred over integer-to-float conversion, so (call pick (: 7 i32)) selects the i64 overload in the example above. If two viable overloads have the same best cost, the call is rejected as ambiguous.

Generic functions:

(block
  (pragma language overloads)

  (fun identity [K] ((var x K)) -> K
    (block
      (return x)))

  (fun pairFirst [K V] ((var a K) (var b V)) -> K
    (block
      (return a))))

Generic functions use a function-level parameter list after the function name. Bare names in that list are type parameters. They are introduced into the function's type scope, so they can be used anywhere a type is expected inside the signature and body:

(fun first [T] ((var values <array T 1>)) -> T
  (block
    (return (index values 0))))

Generic functions are enabled by the same (pragma language overloads) pragma as overload sets. At each call site the concrete types bound to type parameters are inferred from argument types by unification, and a monomorphized clone of the function is generated for that binding. The clone is registered under a signature-based synthetic name __generic_<name>

Загрузка...
lt;ReturnType>__
Загрузка...
lt;ArgType1>...
(__generic_identity$i64__$i64, __generic_identity$string__$string, ...) and substituted for the call. Repeat calls with the same concrete signature reuse the cached clone — exactly one definition exists per concrete signature, regardless of how many call sites use it.

Type parameters may appear nested inside composite types (<array K 1>, <ref K>, <fun K (K)>, ...), and a function may have several independent type parameter names. If argument types do not bind every type parameter, the annotator may also infer the remaining parameters from the function's declared return type and return expressions. This is intentionally narrow: it is used after the argument-based pass, and it follows return, cast, if, and struct-constructor fields where the target return type still contains unbound generic parameters. A call is a typing error when a type parameter's binding cannot be inferred from the arguments or return body, or when the same type parameter name would have to bind to two structurally different concrete types.

This makes generic operator overloads usable when the result type is determined by an operation inside the overload body rather than by a single operand type:

(block
  (pragma language overloads)
  (type Nullable [T] <struct (Value T) (Valid bool)>)

  (fun nullable_add_rhs [T U R] ((var a <named Nullable [T]>) (var b U))
      -> <named Nullable [R]> (attrs (operator "+"))
    (block
      (return (cast
        (if (field a Valid)
          (struct ((Value (+ (field a Value) b)) (Valid #t)))
          (struct ((Valid #f))))
        <named Nullable [R]>)))))

  (fun <main> ()
    (block
      (var a <named Nullable [i64]>)
      (var c <named Nullable [f64]>)
      (= a (cast (struct ((Value (: 4 i64)) (Valid #t))) <named Nullable [i64]>))
      (= c (+ a (: 0.5 f64))))))

Here T is inferred as i64 from a, U is inferred as f64 from the right-hand operand, and R is inferred as f64 from the returned struct's Value field expression (+ (field a Value) b).

When an overload set mixes concrete and generic fun forms for the same name, overload resolution prefers a concrete match over instantiating the generic fallback — see (fun f ((var x i64)) -> i64 ...) vs. (fun f [K] ((var x K)) -> i64 ...).

Generic functions must be implemented in core-lang; external generic functions are not supported. Function-level value generic parameters use (const Name type) in the [...] list and are currently limited to integer types. Their values are inferred from parametric named types in the function parameters:

(fun decimal_add [(const Scale i32) (const Precision i32)]
    ((var a <named Decimal [Scale Precision]>)
     (var b <named Decimal [Scale Precision]>))
    -> <named Decimal [Scale Precision]> (attrs (operator "+"))
  (block ...))

Casts, indexing, and slicing:

(cast operand type)
(bitcast operand type)
(index collection index)
(index collection [index1 ... indexN])
(slice collection [start])
(slice collection [start end])

bitcast preserves the operand's representation and requires integer, float, symbol, or pointer source and target types with the same byte size. Numeric literal bitcasts are folded during the transformation pipeline.

Single-index access creates TIndexExpr; vector index access creates TMultiIndexExpr. (slice collection [start]) with a single bound is a single-element slice, equivalent to (slice collection [start start]).

Pointers

<ptr T> is a raw machine pointer to T. Core lang does not attach ownership, lifetime, nullability, alignment, or bounds metadata to pointer values. It is mainly used by runtime/source modules and QumirDB kernels to describe ABI objects and manually managed buffers:

(fun qdb_alloc ((var size i64)) -> <ptr i8> (attrs extern) (block))
(fun qdb_free ((var ptr <ptr i8>)) -> void (attrs extern) (block))

The usual low-level idioms are explicit casts:

; null pointer
(cast (: 0 i64) <ptr i8>)

; null check
(== (cast ptr i64) (: 0 i64))

; reinterpret byte storage as typed storage
(var words = (cast bytes <ptr i64>))

; pointer arithmetic by bytes
(var tail = (cast (+ (cast bytes i64) byte_offset) <ptr u8>))

Explicit cast currently supports pointer-to-pointer, pointer-to-integer, and integer-to-pointer conversions. T* -> void* may be implicit; void* -> T* requires an explicit cast. These casts are representation-level operations and do not validate the pointed-to storage.

(index ptr i) reads one element from a pointer. If ptr has type <ptr T>, the expression has type T and lowers as a load from:

ptr + i * sizeof(T)

Pointer indices are zero-based and have no array lower-bound adjustment. The index must be an integer or implicitly castable to i64. Pointer indexing has only the single-index form; multi-indexing is for arrays. Slices are only for strings, not pointers. A pointer to a single object is dereferenced as (index ptr 0).

Indexed assignment stores through a pointer:

(= words [i] (: 42 i64))
(= bytes [b] (index other_bytes b))

This lowers as a store to ptr + i * sizeof(T). For struct element types, assignment copies the whole struct value.

Current lowering has an important restriction: pointer indexing and indexed assignment expect the collection to be a named local/global symbol. If the pointer is produced by a field access, cast, or arithmetic expression, bind it to a variable before indexing:

(var data = (cast (field col Data) <ptr u8>))
(var byte = (index data row))

(var pair_work = (cast (+ (cast pairs i64) (* n (: 16 i64))) <ptr u64>))
(= pair_work [i] (index pairs i))

One-dimensional arrays can be explicitly cast to a pointer to their element type. This is the current way to pass core array storage to lower-level helper functions:

(var counts <array u32 1> [0 255])
(call sort_count_pass (cast counts <ptr u32>))

The resulting pointer is a zero-based view of the array storage; the array's declared lower bound is not carried with the pointer.

There is no first-class address-of expression for arbitrary values. When code needs the address of an existing object rather than a raw pointer value, spell the callee parameter as <ref T> and pass an addressable lvalue. During lowering the call passes the lvalue address:

(fun bump ((var x <ref i64>))
  (block
    (= x (+ x (: 1 i64)))))

(var value i64)
(call bump value)

(var a <array i64 1> [0 2])
(call bump (index a 1))

Addressable reference arguments include identifiers, field accesses, and array element accesses. This is distinct from <ptr T>: <ref T> is a source-level by-reference parameter convention, while <ptr T> is a first-class raw pointer value.

During IR lowering the indexed expression is converted to the element address, not loaded as a value.

Modules and assertions:

(use module_name)
(use "module name")
(assert expr)

use accepts either an identifier-like name or a string token.

Source modules (.oz)

A use name that does not name a registered runtime module but resolves to a <name>.oz file is loaded as a source module: it is parsed, validated and inlined into the compilation unit, so its top-level functions, types and globals become part of the program (one combined AST, one set of constructors). Module name = file stem.

Resolution order for <name>.oz: explicitly registered modules (--module), then the directory of the main source file, then --module-path directories in registration order; the first match wins. (Programmatically the runners take ModuleSearchPaths / ModuleFiles.)

A source module's use declarations are its transitive dependencies; the loader detects cycles and reports the chain. All top-level declarations are exported into the shared namespace (no explicit export/private yet). A source module may not declare <main> or executable top-level statements; it may declare functions, types and globals. Globals are initialized in dependency order (dependencies first, the main program last) and destroyed in reverse.

Operator/cast/print functions (see the attributes section) carried by a source module are registered into the operator/cast dispatch tables on import, so a + b, implicit casts and (output value) work for the module's types.

The same mechanism is available to the Kumir frontend: использовать <name> loads <name>.oz; its exported type names are registered so the Kumir lexer/parser recognizes them at parse time.

Struct operations:

(field field_name object)
(struct ((name1 value1) (name2 value2) ... (nameN valueN)))
(field_assign object field_name value)

The struct expression creates a TStructConstructExpr. Field types are usually supplied later through type annotation.

Type annotations:

(: expr type)

This sets TExpr::Type on any AST node. The printer emits annotations in All mode, and in required places such as named types and struct constructors in the default mode.

Types

Primitive types:

Core AST type
i64 TIntegerType
f64 TFloatType
bool TBoolType
string TStringType
char TSymbolType
void TVoidType

Composite types:

<fun return_type (param_type1 ... param_typeN)>
<array element_type arity>
<ptr pointee_type>
<ref referenced_type>
<named name underlying_type>
<named name [type_arg1 ... type_argN]>
<named name [type_arg1 ... type_argN] underlying_type>
<struct (field_name1 field_type1) ... (field_nameN field_typeN)>
<future result_type>

<future result_type> is TFutureType, the type of an awaitable produced by a coroutine; see arch/coroutine.md.

Examples:

i64
<array i64 1>
<array <ref f64> 2>
<ptr <struct (x f64) (y f64)>>
<ref string>
<named color i64>
<named Nullable [i64]>
<ptr <named Nullable [f64]>>
<struct (x i64) (values <array f64 1>)>
<fun bool (i64 f64)>
<future i64>

Unknown bare type identifiers are parsed as short named types:

color

which is equivalent to a TNamedType without an underlying type attached. Name resolution later decides whether the name is a generic type parameter currently in scope or a declared/imported named type.

Named type arguments use the same [...] delimiter as declarations, but in the canonical order <named Name [Args]>: the keyword named must immediately follow <, then the type name, then the optional argument list. Bare scalar types and generic type parameters may be used directly as type arguments; integer literals and generic value parameters may be used as value arguments:

<named Box [i64]>
<named Pair [string T]>
<named Decimal [2 10]>
<named Decimal [Scale Precision]>
<array <named Nullable [f64]> 1>

Type Attributes

Types carry Readable and Mutable access flags. By default a type is both readable and mutable. Core syntax can spell non-default access modes by wrapping a scalar or composite type in angle brackets:

<i64 (readonly)>
<ref string (writeonly)>
<named buffer <array i64 1> (readonly)>

The access modifiers mutable, readonly, and writeonly are mutually exclusive. mutable explicitly selects the default readable and mutable state, which the printer omits in canonical output. The fully immutable, unreadable state cannot be spelled — there is no attribute for it.

Printer Conventions

The core printer is the canonical form used by tests and AST goldens.

Example

(fun sum
  ((var n i64)
   (var a <array i64 1> [0 (- n 1)]))
  -> i64
  (block
    (vars
      (var i i64)
      (var s i64))
    (= s 0)
    (for i 0 (- n 1) 1
      (block
        (= s (+ s (index a i)))))
    s))