Skip to content

v3: constraints on type parameters, editor answers in generic code, faster checks for editors - #29254

Merged
medvednikov merged 200 commits into
vlang:masterfrom
MARCROCK22:experiment
Oct 2, 2026
Merged

medvednikov merged 200 commits into
vlang:masterfrom
MARCROCK22:experiment

Conversation

@MARCROCK22

@MARCROCK22 MARCROCK22 commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

This draft is the experiment branch of my fork: everything built on top of #28973 (VLS support in V3), #28794 and
#28854 since those were opened. Its main part is new in V: constraints on type parameters, fn longest[T Named],
checked at the calls and in the bodies, with the editor answers for them. It also has the speedups of the
diagnostics server that VLS uses (prepared, shared, warm and incremental checks), checks of generic instances in V
instead of in the C compiler, and fixes found on the way. The VLS side is vlang/vls#525 (draft).

It is a draft to show the whole work in one place, to run the CI on the platforms I cannot test (macOS, Windows,
the BSDs), and above all to ask whether the design of the constraints fits V before splitting this into PRs that
can be reviewed one by one (a split is proposed at the end).

About the commit list: master comes into the branch with merges, never a rebase, and the branch still holds the
original commits of #28973, #28794 and #28854, which master has as squashes, so the Commits tab lists them too.
The Files tab shows what is new: 84 files, +21,981 −829: code 38 files (+12,369 −653), tests and fixtures 45 files (+9,572 −176), docs 1 file
(+40). Every file is listed at the end with what it is for.


1. Constraints on type parameters (new in V)

What a constraint is

A type parameter can name, after it, what its type arguments must be. There is no new keyword: the constraint is an
ordinary type.

Constraint Takes Example
an interface the types that implement it fn longest[T Named](a T, b T) T
a sum type, or an alias of one its variants (a variant that is a sum type gives its own variants) type Number = int | i64 | f64, fn half[T Number](x T) T
a struct that struct and the structs that embed it, at any depth fn greet[T User](x T) string
an interface among the variants of a sum type the types that implement it type Value = Named | int
a generic interface the types that implement it for the bound type arguments [T Comparable[T]], [T Comparable[int]]
a type that names another type parameter what it says once the other is bound [C Container[T], T Named]
any other type itself [T int]

The same sum type stays a sum type where it is the type of a value. A constraint can be written on the type
parameters of functions, methods (also a method's own, fn (s Shelf[T]) pair[U Named]()), structs, interfaces,
sum types, aliases and function types: struct Box[T Named], interface Shelf[T Named],
type Picker[T Named] = fn (T) T, type Tree[T Named] = Leaf[T] | Twig[T].

A type parameter without a constraint works as before. The sweeps that check written types and calls of constrained
generics run only in a program that declares a constraint: a program without any is checked as before (the checks
of instances in section 2 are the exception, as they apply to every generic).

Before and after

interface Named {
	name string
}

struct User {
	name string
	age  int
}

// longest returns the value whose name is longer.
fn longest[T Named](a T, b T) T {
	if a.name.len >= b.name.len {
		return a
	}
	return b
}

fn age_of[T Named](x T) int {
	return x.age
}

fn main() {
	println(longest(User{ name: 'Ana' }, User{ name: 'Bob' }).name)
	println(age_of(User{ name: 'Eve', age: 30 }))
	println(longest(1, 2))
}

master: V3's parser skipped what followed the name of a type parameter up to the next , or ], so the constraint
was dropped: fn longest[T Named] was fn longest[T] (and fn id[T Foo + 42] compiled). v -check says nothing,
v run fails in the C compiler, and then the V1 fallback rejects the syntax:

src.c: In function 'longest_T_v_int':
src.c:6519:14: error: request for member 'name' in something not a structure or union
V compilation failed (c_compilation_error); retrying with `.../v1-fallback/0.5.2/v`.
main.v:13:14: error: unexpected name `Named`, expecting `,`

and v fmt writes the function back as fn longest[T](a T, b T) T.

This branch:

main.v:21:11: error: type `T` has no field named `age`: its constraint `Named` does not declare it
main.v:27:18: error: `int` doesn't implement field `name` of interface `Named`

age_of is reported although User has an age: the body may use only what the constraint declares, so that any
type the constraint admits works. A set of types:

type Number = int | i64 | f64

fn half[T Number](x T) T {
	return x / 2
}

fn describe[T Number](x T) string {
	$if T is f64 {
		return '${x:.2f}'
	} $else {
		return x.str()
	}
}

fn as_int[T Number](x T) int {
	return x
}

fn main() {
	println(half(10)) // 5
	println(half(2.5)) // 1.25
	println(describe(1.5)) // 1.50
	println(as_int(7))
	println(half('a'))
}

master: v -check says nothing; v run fails in the C compiler (invalid operands to binary / (have 'string' and 'int') in half_T_string), then V1 rejects [T Number]. This branch:

main.v:18:9: error: cannot use `f64` as type `int` in return argument: when `T` is `f64`, in its constraint `Number`
main.v:26:15: error: cannot use `string` as `T`: it is not in its constraint `Number`

At the call

  • A type argument that the constraint does not take is reported at the argument that decided it, with the message
    V gives for that kind of type: `int` doesn't implement field `name` of interface `Named`,
    cannot use `string` as `T`: it is not in its constraint `Number`,
    cannot use `int` as `T`: it is not `User` and does not embed it.
  • Explicit type arguments are checked too, longest[int](1, 2), at the type argument as written.
  • A generic type is checked wherever it is written, at any depth: declarations, fields, parameters, return types,
    struct literals with explicit or inferred type arguments (Box{ item: 1 }), map[string]Box[int]{},
    sizeof(Box[int]), typeof, isreftype, casts (&Box[int](p), ?Box[int](none)), chan Box[int]{}, the
    signature of a function literal, the fields of an anonymous struct, map[string][]Box[Box[User]]; and the same
    for generic interfaces, sum types, aliases and function types.
  • A generic function taken as a value is checked, f := longest[int].
  • As in Go and Rust, a type parameter given to a constrained generic needs a constraint that satisfies that one:
    fn wrap[T](x T) Box[T] with struct Box[T Named] is reported, and told which one to take ("give it one,
    [T Named]"). An interface satisfies another that it implements (by embedding it, for instance); a set satisfies
    an interface when all its types implement it, and another set when all its types are in it; a struct family
    satisfies a constraint that takes its whole family. $if T is int around the use counts, and the receiver of a
    method keeps the constraints of its struct.
  • A generic interface constraint is bound to the type arguments before it is checked: Odd, whose less takes an
    int, is not a Comparable[Odd]. One generic interface constraint satisfies another when both name the same
    bound interface.
  • A call that the constraint rejects is reported once: the instance it asks for is not checked, so its body does
    not repeat the error (in the full checks of instances, as the checker fixtures run them).
  • Inference: a type parameter that no argument decides is bound from the constraint of one that names it:
    unwrap_name(Box[User]{...}) with fn unwrap_name[C Container[T], T Named](c C) binds T to User from the
    get() T of Container[T] (before, the call made no instance, passed -check, and failed in the C compiler).
    Methods infer the same way. A type passed for a generic interface binds its type parameters from its methods
    and fields: take_now(shelf) with fn take_now[T Named](s Shelf[T]) T and a UserShelf whose get() returns a
    User builds and runs (it failed in the C compiler, also without constraints).

In the body

  • A value of T has what the constraint provides: the members of the interface, or of the struct; for a set, the
    members every type of it has, builtin ones included (x.len of []int | string, x.str() of numbers). A field
    of a constrained type keeps it: b.item.name in a method of Box[T Named].
  • A member the constraint does not provide is reported where it is used:
    type `T` has no field named `age`: its constraint `Named` does not declare it, or for a set
    type `T` has no method `greet`: `Pet`, in its constraint `Animal`, does not have it.
  • Operators: binary ones on either side, += and the other assignments, -x, !x, ~x, x++, x-- and x[i].
    An interface declares no operators, so its T has ==, != and in; a struct, only == and !=; with a set,
    an operator has to work for every type of the set against what is on its other side (another T, a literal, a
    value of a builtin type): operator `%` is not defined on type `T`: `f64`, in its constraint `Number`, does not have it. The operators a type declares count (fn (a Vec) + (b Vec) Vec), as do those an alias inherits, and
    < brings >, <= and >=. A test holds these rules to the checker: for 43 types, a T constrained to one type
    is reported on exactly the lines where a value of that type is, 30,745 operations in all.
  • Locals that get their value from a T are Ts: x := a, x, y := a, b, the value of an if, a match, (a),
    &a or an or {}, an index of a []T or a map[K]T, the variables of a for ... in, the guard of
    if x := opt. A generic call there is typed with the caller's type parameters bound from its type arguments, its
    receiver and its arguments.
  • Function literals, short lambdas and the it, a and b of map, filter, any, all, count, sort and
    sorted are checked: xs.map(it.age), xs.sort(a.age < b.age), xs.map(|x| x.age), and a lambda passed to
    another generic takes the function type the callee declares with the type parameters the call binds.
  • A run-time is on a value of T is reported when a type of the constraint is neither a sum type nor an
    interface, with the way to say it: `is` can only be used with sum type or interface values, not `T`: `int`, in its constraint `Number`, is neither; test `T` with `$if x is f64`. A match by type on a value of T is
    reported once, as an is, and narrows its branches; a match on values narrows nothing.
  • $if T is f64 { narrows T in its branch, and so does a test on a value, $if x is f64 {, with !is, in,
    !in, $int and the other groups, alone or joined with || and &&. The $else has the rest of the set; with
    an interface constraint it keeps the interface. A branch that no type of the set reaches is not checked.
  • The walks of constrained bodies use loops, not closures: a compiler built with -prealloc and no GC failed every
    editor query with "invalid memory access" once a program had enough $if T is chains (eight functions of three
    branches). The editor tests build their V3 as ./v self does, so they would see it.

The whole body, when every type parameter has a constraint

When all the type parameters of a function or a method have a constraint (its own and the ones the type of its
receiver declares), its body is checked as the body of any other function, with each type parameter as each type
its constraint admits: the interface itself, which stands for any type that implements it, or each type of a set.
An error says for which: cannot use `f64` as type `int` in return argument: when `T` is `f64`, in its constraint `Number`. A generic with a type parameter without a constraint leaves its body to its instances, as before.

  • Every combination of the types of the constraints is checked up to 256. Past that, the combinations where every two
    type parameters meet with every two of their types (pairwise testing: 187 checks for three type parameters of 13
    types each instead of 2,197), plus, for each way the $ifs of the body can go, the combinations of the groups of
    types its conditions tell apart (up to 1,024 checks for all the ways, then one type at a time). A check costs what
    checking a function with that body does (0.46 ms for one of 25 lines).
  • With an interface constraint, a $if that tests the type parameter against a type that implements it ($if T is User, $if x is User, $if y is User for a local y := x, $if x in [User, Admin]) adds a check of the body
    with that type: when `T` is `User`, which implements `Named`.
  • [T Comparable[T]] keeps T open inside its own type; a constraint that names another type parameter,
    [C Container[T], T Named], is checked with the other one put in, C as Container[Named]; a cycle,
    [C Container[T], T Wrapper[C]], keeps the repeated one open.
  • $if x in [...] and !in are decided in those checks as is is, one type of the list at a time.
  • An error names only the type parameters that decide it (`A` is `f32` or `f64`, `A` is not `i8`), and
    a constraint that names itself is written as the type it unfolds, T.
  • A statement that the checks against the constraints already reported gets no other error there.
  • Builds (v run, v build) check these bodies as well: their errors came from the C compiler.

Methods behave as functions

The tests of constraints check every program a second time in its method form: each generic function becomes a
method, with the same type parameters, of a struct without type parameters (vlib/v/compiler_tests/method_form),
which has to give the same errors at the same places, and the same editor answers. The differences it found are
fixed: a method checked with the types of its constraints took the U of Comparable[U] for a type left to infer;
h.first[int] was parsed as an index (it is now the method of T = int bound to h, as first[int] is the
function, with its type arguments checked as those of a call and its instance made); f := h.longest[User] left
f without a type; and a method did not infer a type parameter from the constraint that names it. Methods of
generic structs are checked as functions with all their type parameters: Shelf[T Named].pair[U Named] has its
body checked as a normal function's, Query[T].map[U Named] its calls and what its constraint declares.

Formatting and documentation

v fmt keeps the constraints of functions, methods (with their own type parameters, on any receiver) and type
declarations. doc/docs.md has a section Constraints after the generic functions (its example builds and runs;
v check-md passes), and the checker has fixtures for the errors at calls and in bodies, for functions and for
methods (generic_constraint_{call,body,method_call,method_body}_err.vv).

2. Generic instances checked in V, in -check and in builds

This applies to every generic, with or without constraints. A generic body without constraints is not checked with
its type parameters open; nothing checked its concrete instances either, so show(1) with return x.name in
fn show[T](x T) string passed v -check and failed in the C compiler (request for member 'name' in something not a structure or union). V1 reported "int has no property name".

  • v -check monomorphizes from the program's own functions and checks each concrete clone of its generics, keeping
    what the concrete types decide: a field, a method or an operator that such a type lacks (`int` has no property `name`). The clones of library generics are not checked; in a check they are made without their bodies unless
    a type argument reaches the program through an interface, a generic type or a method with type parameters of its
    own (seven cases, each with a test; V_CHECK_LIBRARY_BODIES=1 keeps every body).
  • A build runs the same pass in a forked child beside the transform and waits for it before generating C, at no
    measurable cost (663-714 ms against 658-700 ms on a 660-line program); V_CHECK_INSTANCES_IN_BUILD=0 turns it
    off. On Windows (no fork) a build leaves them to the C compiler, as before; -check reports them there too.
  • An instance of a generic whose type argument is an alias keeps the alias's methods: el.val.pack() of an
    Elm[Octet] with type Octet = string and fn (o Octet) pack() is valid (generic_static_call_test.v).

3. Editor answers in generic code (the query engine VLS uses)

The checker does not type a generic body with its type parameters open, so hover, definition and completion had
little to answer there. Now (each with tests in vls_line_info_test.v):

  • A value of a constrained type parameter has the members of its constraint: a. completes name and greet,
    hover on a.name shows name string, go-to-definition leads to the interface; for a set, what every type has.
  • The hover of a value says what the type parameter is where the cursor is: x f64 in the branch of $if T is f64 {
    (or of $if x is f64, or of a $if on a local that holds it), a main.User in $if a is User {; elsewhere
    x T with what it can be on a line of its own: T: int | i64 | f64, T: implements main.Named,
    T: main.User or a struct that embeds it. The $ifs on the way are read whole (tests, &&, ||, !,
    parentheses) and taken together, in and !in included; a test it cannot follow may go either way.
  • The type parameter itself has a hover, [T Number] and that line; constraint names have a hover and a definition;
    go-to-definition (and so a rename) of a type parameter leads to its list, [T Named], never to a type of the
    module with the same name.
  • A function's signature writes its type parameters, fn longest[T Named](a T, b T) T, and a parameter is what
    is or a branch of match makes it there, s main.Circle, as a local was.
  • it, a, b, err and the parameters of short lambdas have types (it T), and a lambda's parameter has a
    definition.
  • A local of a generic body has the type of its value: lengths []int for lengths := xs.map(it.name.len),
    first T, the values of a call that returns several, a loop's index, key and element, T(0), []T{},
    map[string]T{}, operations (total := a + b + c, comparisons are bool), an if or a match used as a value,
    array, map and generic struct literals and their elements and fields (pair Pair[N], left N), or {}, dump(),
    &x, a.str(), values.map(it * 2). What no type parameter decides (separator := match sep {...}) is typed by
    checking the body once more with its type parameters open; a generic call is bound as the checks bind it
    (same A for identity(a), not the callee's T), also with explicit type arguments (first_of[A](values)) and
    a struct literal receiver (Host{}.first_of(values)). Box[A] shows what A can be.
  • Calls in generic bodies and through values find their functions: xs.map() over xs []T, a method of
    Box[T], a local that holds a function literal or a method value (double fn (int) int), a generic function
    called with type arguments, a method named without a call (label_of := b.label), the method of the interface
    of a constraint (a.less(b) with [T Comparable[T]]) or of a generic interface (s.get() with Shelf[User]:
    fn get() main.User), and a method value of an alias (the alias's own method first, as the call does).
  • The numbers, bools and runes of an array literal have their default types, so [1, 2].map(it * 2) finds map.
  • A value written in the condition of a $if, which has no node of its own (c in $if c is $float {), is answered
    as that value there, with what the $ifs around it leave: c C with C: i8 | ... | f64.
  • A question in a branch that the parse left out ($if js, $if windows, $if flag ?) parses the file again with
    every branch, as v fmt does, and answers there, once per process; diagnostics never see those nodes.

4. Diagnostics server for editors

#28973 added the diagnostics server (V_DIAGNOSTICS_SERVER): builtin parsed once, each check in a forked child.
This builds on it; every mode is opt-in through the environment and VLS turns them on.

  • Prepared modules (V_DIAGNOSTICS_PREPARE): the server also parses strconv, strings and math.bits (which builtin
    imports) and collects all the declarations of builtin and those modules once, before its first check; each check
    continues from there. The order-dependent results stay those of a one-shot check (the files are checked in its
    order; a program that declares a key a prepared module also declares, 1.3 % of the corpus, collects anew; one that
    would resolve a prepared module otherwise runs as a one-shot check). It also prepares once: the scan for unused
    declarations, the C includes, builtin's field index, the scan for implicit imports (replayed when the program wrote
    none of the keys it read), each file's imports, whether each prepared struct implements IError, and room in the
    node caches for the user's nodes.
  • Shared children (V_DIAGNOSTICS_SHARED): the child that checked a version of the program answers the check and
    the questions (hover, highlights, inlay hints) about it, whichever comes first: one check per edit instead of two.
    The diagnostics come from a grandchild that finishes the check into a file in memory, as the end of a check
    rewrites the tree that answers come from. A grandchild outlives no child.
  • Warm children (V_DIAGNOSTICS_WARM_CHILDREN, 3 by default): the children of the last versions of a program stay,
    each request is offered to all of them, and the one whose version is on disk answers: undo and redo answer at once.
  • Partial answers (V_DIAGNOSTICS_PARTIAL): when the end of a check (unused declarations, instances of generics)
    takes more than 3 ms, the child prints the errors it already has, ended by v-diagnostics-server: partial <code> <token>, and the whole answer when it ends.
  • Incremental checks (on with shared children; V_DIAGNOSTICS_INCREMENTAL=0 turns them off): each check records
    what every function body reported, relative to its region (from its line to the next declaration), in a file in
    memory that every child sees; the next check of the program, with the same files and declarations, checks only the
    bodies whose region changed and puts back what the others reported. The rest of the check reads from the record
    what it needs of the bodies left out (functions used as values, calls of selective imports, unhandled Results). A
    body that names its own place (@LINE), reads another file ($tmpl) or reported outside its region is checked
    anew, and a program under 2,048 nodes (V_DIAGNOSTICS_INCREMENTAL_MIN_NODES) is checked whole. The instances of
    generics are checked again only when a body that changed touches something generic; otherwise their errors are put
    back with their regions. What waits for the bodies left out (an editor question, the instances) has them checked on
    the worker pool. V_DIAGNOSTICS_INCREMENTAL_VERIFY has the child compare what it put back with a full check.
  • A server's check splits even small programs among the worker pool, bounded by the work (one job per 256 of cost),
    takes up to eight jobs, and a question checks on the pool too, with the per-node types promoted from the workers.
  • A child sums what it parses with a 64-bit wyhash instead of SHA-256, reads its files again after its answer instead
    of before, and watches only what it parsed itself.
  • V_DIAGNOSTICS_TRACE tells what the server did and why: a check run one-shot instead, a prepared scan redone,
    how many library instances were made without bodies, a difference found by the incremental verification.
  • Fixes: a question that a prepared server answers as a one-shot run is still answered (with -line-info); a failed
    check replayed by the launcher never becomes a diagnostics server itself (it hung); the server's flag is
    shares_children, as shared is V1's keyword and broke the V1 fallback of a failed build; the Linux-only calls
    (memfd_create, prctl) are behind $if linux, so vc/v.c still builds on macOS (as v.diagserver: guard prctl so vc/v.c builds on macOS again #29176 did for the
    prctl of serve), with a test of the portable C. The server imports v.token under a name of its own (a
    -check of the module reported the requests' token locals as shadowing it), and a verbose (-v) check keeps the
    preparation: verbosity is no longer part of the key that matches a check with the prepared checker.

Measured through VLS by LSP, from a change to its diagnostics, on programs of 100 / 1,850 / 18,600 lines: 80 / 134 /
257 ms before these changes, 17 / 35 / ~150 ms with them; going back to a text checked before (undo): 1.5 / 2 / 6 ms.
On the code of VLS (45,700 lines, with generics), the complete answer after editing a body without generics: about
1,150 ms -> 330 ms. These were measured on my machine (Linux, 16 threads) when each change was made; the commit
messages have the details. The output was compared with the one-shot check on a corpus of 1,645 programs after each
change.

5. Faster checks and builds for everyone

  • The search for C and JS generic declarations looked for the source line of every function of the program,
    libraries included (walking a method's file back byte by byte): ~115 ms of every v -check and build, now 0.5 ms.
  • A check clones library instances without their bodies (see section 2): a one-shot v -check of VLS 2,224 ms ->
    1,184 ms.
  • The walk that asks whether an expression is in an unsafe block stops at its function (it scanned the whole arena
    for the parent of each specialized function); a generic call is told from an index by its brackets first; a check
    lowers no library body late; generic declarations are found by node kind without copying every node.
  • A serial check is split in batches of at least 2,048 nodes, not 64 tiny ones; the export checks are skipped for a
    program that exports nothing; a check prepares no native C inputs; the implicit-import scan neither copies every
    type text nor makes maps for each function, and import resolution continues it instead of starting over; the
    user's file nodes are found once; a source path is normalized only for a cached module header; only a line that
    starts with import can hold several imports; a check of selected files leaves the values of the other files
    unchecked; only a declaration that can be reported is an unused candidate.

6. Other fixes found on the way (the same in master)

  • A method value of a generic interface or of an embedded struct, g := s.get with s Shelf[User] and
    d := p.describe with an embedded Base, was has no field named: it is typed as its call (fn () User), also
    through several embeddings, an alias or a generic struct, and across modules; the C wrapper of an interface method
    value returns the type argument; two embedded structs that declare the method make it ambiguous; a &Base receiver
    on a stack struct asks for @[heap], and a mut receiver on the stack cannot escape.
  • A method value of a struct alias, d := a.describe with fn (a Alias) describe(), called the struct's method or
    failed in C: it calls the alias's.
  • A method named without a call in a generic body, getter := b.get with b Box[T], failed in C: each instance asks
    for the method of its receiver.
  • A generic struct literal without type arguments, Box{ item: a }, kept the bare Box in an instance, in an array
    ([Box{ item: 3 }]) and before a selector (Box{ item: 2 }.item): it takes the type arguments its fields give.
  • IntHolder{ Holder: Holder[int]{ item: 5 } } lost the embedded generic struct silently (h.item was 0): the
    literal matches its fields as a field access does.
  • A method with its own type parameter called through an embedded struct, h.own() with fn (h Holder[T]) own[T]() T,
    infers it from the embedded struct.
  • A type argument that the receiver fixes otherwise, b.own[string]() on a Box[int], is an error of V:
    `own` takes `T` from its receiver `Box[int]`: `T` is `int`, not `string` (it failed in C, or was dropped).
  • xs.map(|x| x.name.to_upper()) after a local fn (x T) int in a generic body: map took the lambda's function
    type for its element type; it takes the type of the lambda's body.
  • v fmt wrote $if a in [f32, f64] && b as [f32, f64]&& b, !(a is f64) as !( a is f64 ), sizeof(A) as
    sizeof ( A ), $pkgconfig('x') as $pkgconfig ( 'x' ) and []&&int as []&& int; the condition text the
    checker reads keeps its spacing.

7. Environment variables

Variable Effect
V_DIAGNOSTICS_PREPARE a diagnostics server prepares builtin and its imports once
V_DIAGNOSTICS_SHARED its checks and questions share their children (enables incremental checks)
V_DIAGNOSTICS_WARM_CHILDREN how many versions keep their child (3)
V_DIAGNOSTICS_PARTIAL a slow check sends the errors it found first
V_DIAGNOSTICS_INCREMENTAL=0 turns incremental checks off
V_DIAGNOSTICS_INCREMENTAL_MIN_NODES below this many nodes a program is checked whole (2,048)
V_DIAGNOSTICS_INCREMENTAL_VERIFY compare what an incremental check put back with a full check
V_DIAGNOSTICS_TRACE say what the server did and why
V_CHECK_LIBRARY_BODIES=1 a check keeps the bodies of library instances
V_CHECK_INSTANCES_IN_BUILD=0 a build leaves the instances of generics to the C compiler

8. Tests

New: generic_constraints_test.v (calls, written types, bodies, sets, families, generic interfaces, inference,
another module, programs that build and run, #28797's format_thousands with [T Number], method forms),
generic_constraint_operators_test.v, generic_body_check_test.v, generic_body_combinations_test.v,
check_generic_instances_test.v, explicit_receiver_type_args_test.v, method_form/method_form_test.v, the
checker fixtures above, incremental_check_test.v, parallel_check_min_items_test.v, scoped_check_batches_test.v,
module_import_lines_test.v, vls_comptime_ways_test.v, digest_test.v, job_limit_test.v, and run tests in
vlib/v/tests/{generics,fns,aliases,structs} for each fix of section 6; vls_line_info_test.v grew with the editor
answers, gen_test.v with the formatting, cross_output_codegen_test.v with the Linux guards, and the review
regression tests with the method values. Each test was seen failing before its change.

Verification on Linux (gcc 16), each time master came in (the last one 6f23f7a6f4):

  • the compiler builds itself after each merge;
  • after merging c2b96c9b33: 640 test files (those master changed since the previous merge, ours, every test of the
    directories whose files both sides changed and all of vlib/v/tests/generics): 624 pass, 2 are skipped, and the 14
    that fail fail the same way on master alone (same functions and cases): review_checker_regressions_test (14
    cases), review_transform_regressions_test, orm_alias_method_visibility_test, copy_ownership_codegen_test,
    mut_address_arg_local_storage_codegen_test, owned_array_borrow_storage_codegen_test,
    escape_binding_storage_test, fixed_array_mut_arg_escape_test, fn_field_address_c_arg_test,
    pointer_alias_len_test, tcc_libatomic_linux_test, two serial builds (native_inputs_serial_build_test and
    parallel_transform_serial_build_test: fn_parallel_d_v3_no_parallel.v declares collect_gen_info_fn_preps with
    two parameters and cleanc.v passes three) and cross_output_codegen_test;
  • after merging 6f23f7a6f4: 544 test files (the same choice of tests): 539 pass, and the 5 that fail fail the same
    way on master alone: review_checker_regressions_test, review_transform_regressions_test,
    native_inputs_serial_build_test, fixed_array_mut_arg_escape_test and cross_output_codegen_test;
  • in cross_output_codegen_test.v, master's test_cross_output_lets_the_target_libc_pick_the_poll_header and this
    branch's test of the Linux guards both fail because master no longer generates the portable C of a program that
    uses os.get_raw_line (os.c.v:628: infix expr: cannot use int (right expression) as voidptr, in its Windows
    branch); the test of the guards passed when it was added, on master 3a9a816dbf;
  • the checker fixtures that master changed or this branch adds pass;
  • V3 answers, one shared check per edit, renames that check the new name, and generic constraints in the editor vls#525, with this V, passes all its tests.

9. Known limits and open questions

  • On Windows a build does not check the instances of generics (it needs fork); -check does.
  • The checks of a body split types only by is and in conditions: $if T.name == 'f64' is not evaluated by V3.
  • With shared children, a second answer to check for the same version loses the warnings that come from parsing
    (match is always true of a $match): vlib/v/tests/comptime/comptime_match_assign_test.v.
  • -check does not check the instances that a $for in a function without type parameters asks for (a build does
    not report their members either; the V1 fallback does).
  • Design questions for the maintainers: the syntax [T Named]; a sum type as the constraint (and still a sum type
    as a value type); a struct standing for its family; the rule that only fully constrained bodies are checked as
    normal functions; and the budget of combinations (256, then pairwise).

10. How this could be split

  1. Parser and v fmt: keep the constraints (no checks yet).
  2. Constraints at the call, written types, docs and fixtures.
  3. Constraints in bodies: members, operators, narrowing.
  4. Whole-body checks of fully constrained generics.
  5. Instances of generics checked in -check and builds.
  6. Editor answers in generic code.
  7. Diagnostics server: prepared, shared, warm, partial, incremental.
  8. Faster checks for everyone (section 5).
  9. The fixes of section 6, each on its own.

Files

File + − What
vlib/v/compiler_tests/vls_line_info_test.v 2891 165 editor answers in generic and constrained code, prepared and shared servers, method forms
vlib/v/types/checker_generic_constraints.v new 2804 0 constraints: calls, written types, bodies (members, operators, narrowing, locals, closures), inference
vlib/v/compiler_tests/generic_constraints_test.v new 2283 0 tests of constraints, with their method forms
vlib/v/types/incremental_check.v new 1647 0 incremental checks of function bodies
vlib/v/driver/driver.v 1354 221 constraints kept, instance checks, prepared server (imports, collect, scans), shared/partial/incremental flows, jobs, faster scans
vlib/v/types/checker_generic_bodies.v new 1139 0 whole-body checks of fully constrained generics (combinations, $if ways, messages)
vlib/v/types/vls_hover.v 753 89 hover and definition in generic code
vlib/v/compiler_tests/generic_body_check_test.v new 748 0 tests of the whole-body checks
vlib/v/types/checker.v 461 125 constraint checks in the passes, generic bodies, prepared collection, export and C/JS checks, unused candidates
vlib/v/diagserver/diagserver_linux.c.v 512 66 shared, warm and partial children, incremental record, quick sums, Linux guards
vlib/v/types/checker_prepared.v new 433 0 prepared collection and scans of a diagnostics server
vlib/v/types/vls_comptime_ways.v new 409 0 hover follows every $if on the way
vlib/v/types/vls_generic_locals.v new 393 0 types of the locals of generic bodies for the editor
vlib/v/compiler_tests/generic_constraint_operators_test.v new 385 0 operators on a constrained T against the checker, 43 types
vlib/v/compiler_tests/check_generic_instances_test.v new 375 0 instances checked in -check and builds; library instances without bodies
vlib/v/transform/monomorphize.v 310 11 instances: rejected ones, inference from constraints, generic interfaces, struct literals, receiver type args, aliases, method values
vlib/v/transform/library_bodies.v new 320 0 which library instances keep their bodies in a check
vlib/v/types/incremental_check_test.v new 311 0 incremental checks
vlib/v/compiler_tests/method_form/method_form.v new 305 0 the method form of a program, for the tests
vlib/v/types/checker_parallel.v 274 26 parallel check: batch size, jobs by work, incremental capture, waited bodies, instances
vlib/v/types/checker_tail_stmt.v 291 9 constraint hooks, struct literal inference, method values (generic, embedded, alias, in instances)
vlib/v/compiler_tests/testdata/format_thousands_28797.v new 272 0 the code of #28797 with [T Number]
vlib/v/types/checker_tail.v 225 30 inference from constraints, method values, receiver type args, import lines
vlib/v/compiler_tests/explicit_receiver_type_args_test.v new 221 0 type arguments that the receiver fixes; method values with type arguments
vlib/v/tests/fns/method_value_of_generic_interface_or_embedded_struct_test.v new 185 0 method values of generic interfaces and embedded structs
vlib/v/parser/parser.v 146 16 constraints parsed and kept; quick sums; v fmt spacing of compile-time conditions
vlib/v/types/vls_query.v 144 1 queries: type parameters, $if conditions, branches left out, generic bodies
vlib/v/types/vls_definition.v 116 12 definition of type parameters and of values in generic bodies
vlib/v/transform/transform.v 122 3 no late library lowering in a check; method values (embedded, alias, generic body)
vlib/v/types/parallel_check_min_items_test.v new 116 0 small programs on the worker pool
vlib/v/tests/generics/method_value_in_generic_body_test.v new 105 0 method values in generic bodies
vlib/v/types/generic_body_combinations_test.v new 102 0 combinations of constraint types (pairwise)
vlib/v/compiler_tests/review_transform_regressions_test.v 100 0 method values through embedded structs
vlib/v/types/vls_skipped.v new 89 0 answers in branches the parse left out
vlib/v/tests/generics/generic_method_value_type_args_test.v new 84 0 h.first[int] as a method value
vlib/v/driver/instance_check_nix.c.v new 83 0 the forked check of instances in builds
vlib/v/tests/generics/method_repeated_receiver_type_param_test.v new 82 0 methods that repeat the type parameter of their receiver
vlib/v/compiler_tests/method_form/method_form_test.v new 80 0 the method form itself
vlib/v/types/vls_comptime_ways_test.v new 79 0 the reader of $if conditions
vlib/v/tests/structs/embed_generic_struct_init_by_name_test.v new 78 0 embedded generic struct set by its bare name
vlib/v/tests/aliases/alias_struct_method_value_test.v new 64 0 method value of a struct alias
vlib/v/gen/v/gen_test.v 63 0 v fmt keeps constraints; spacing of compile-time conditions
vlib/v/flat/flat.v 55 6 constraints in the node payload
vlib/v/compiler_tests/review_checker_regressions_test.v 54 0 method values through embedded structs and aliases
vlib/v/token/position.v 46 5 quick sums of sources
vlib/v/types/module_import_lines_test.v new 47 0 lines with several imports
vlib/v/checker/tests/generic_constraint_method_call_err.vv new 46 0 fixture: constraints at the calls of methods
vlib/v/tests/generics/embedded_generic_method_own_type_param_test.v new 43 0 own type parameter inferred through an embed
vlib/v/tests/generics/generic_struct_literal_type_args_from_fields_test.v new 43 0 generic struct literals without type arguments
doc/docs.md 40 0 section Constraints
vlib/v/checker/tests/generic_constraint_call_err.vv new 40 0 fixture: constraints at the calls
vlib/v/diagserver/diagserver_default.c.v 37 1 the new server API off Linux
vlib/v/types/vls_completion.v 37 1 completion of constrained values
vlib/v/gen/v/gen.v 20 16 v fmt writes constraints
vlib/v/checker/tests/generic_constraint_call_err.out new 35 0 fixture output
vlib/v/checker/tests/generic_constraint_method_call_err.out new 35 0 fixture output
vlib/v/checker/tests/generic_constraint_method_body_err.vv new 32 0 fixture: constraints in the bodies of methods
vlib/v/types/vls_locals.v 30 1 it, a, b, err and lambda parameters; branches left out
vlib/v/tests/generics/generic_fn_literal_then_lambda_same_param_test.v new 29 0 a lambda after a function literal in map
vlib/v/checker/tests/generic_constraint_body_err.out new 28 0 fixture output
vlib/v/checker/tests/generic_constraint_method_body_err.out new 28 0 fixture output
vlib/v/checker/tests/generic_constraint_body_err.vv new 26 0 fixture: constraints in the bodies
vlib/v/transform/fn.v 18 5 generic call told from an index by its brackets; method values
vlib/v/diagserver/digest.v new 22 0 quick-sum digests
vlib/v/token/token_test.v 21 0 quick sums
vlib/v/tests/generics/generics_from_modules/embedded_generic_struct_init_test.v new 20 0 embedded generic struct of another module
vlib/v/compiler_tests/cross_output_codegen_test.v 19 0 Linux-only calls of the server behind guards in portable C
vlib/v/types/scoped_check_batches_test.v new 19 0 batch size of a serial check
vlib/v/diagserver/digest_test.v new 17 0 quick-sum digests
vlib/v/driver/environment_test.v 10 7 no native inputs prepared in a check
vlib/v/parser/parser_parallel_notd_v3_no_parallel.v 16 1 constraints and quick sums in the serial parser
vlib/v/tests/generics/generics_from_modules/embedded_generic_method_own_type_param_test.v new 17 0 own type parameter through an embed of another module
vlib/v/driver/instance_check_windows.c.v new 14 0 no forked instance check on Windows
vlib/v/types/checker_comptime.v 12 2 unsafe walk stops at a function; $if x in in body checks
vlib/v/tests/generics/generics_from_modules/method_value_in_generic_body_test.v new 13 0 method value in a generic body, across modules
vlib/v/driver/job_limit_test.v new 12 0 eight jobs for a diagnostics server
vlib/v/driver/instance_check.v new 10 0 entry of the instance check in builds
vlib/v/markused/markused.v 10 0 method values with type arguments
vlib/v/transform/array.v 7 2 element type of map with a lambda
cmd/v/v3_fallback_diagnostics_compiler_error_test.v 6 1 a replay is never a diagnostics server
vlib/v/transform/struct.v 4 3 embedded generic struct by its bare name
vlib/v/driver/native_inputs_serial_build_test.v 3 3 native inputs in builds only
vlib/v/gen/c/fn.v 3 1 C wrapper of an interface method value returns the type argument
cmd/v/v3_fallback_diagnostics.v 3 0 a replay is never a diagnostics server

…nd unknown enum values

V1 reports these three mistakes; V3 accepted them, so they only failed later
(in the C compiler or at run time) or not at all, and editors showed nothing.

- `.wait()` on an array that is not an array of threads reports V1's error.
  A `wait()` declared on an alias of an array (or an alias of that alias) is
  still allowed.
- A method maps do not have (`m.foo()`) reports "unknown function".
- `Color.nope` and a `.nope` branch in a `match` on a `Color` report
  "unknown enum field". Static functions declared for the enum, methods passed
  as values and escaped keywords (`Mode.@none`) are still accepted.
Review follow-up: the diagnostic tests covered `.wait()` on arrays that are
not thread handles and user `wait` methods, but not the builtin call they
must keep accepting. The new test calls it on `[]thread int` and `[]thread`
and uses the results as values of the element type. An alias of a thread
handle type is left out: V1 rejects it (`aliases of `thread` types are not
allowed`) and reports the same `.wait()` error this check does.
master now words unknown function errors as `unknown function: m.close`
(2dcb74c), so the exact-message assertion of this test failed once the
branch was merged with it. Assert the error kind and the named method
instead: that is what this check adds, and the wording belongs to the
existing unknown-call path.
`<`, `>`, `<=` and `>=` had their operands checked only when both were
arrays, fn values or structs. Anything else went to the C backend as it
was: a thread handle, map, sum type or struct compared with a number
failed in the C compiler, and a bool, enum, channel or fn value compared
with a number compiled into a comparison that means nothing. They now get
the compatibility check of `==`, with the messages V1 gives for them:
numbers compare with any number, a pointer with an integer, a `voidptr`
with what C can order it with, and a struct with its own `<` only with
its own type.

V1 let three more kinds of operands reach the C compiler, and they are
rejected too: maps, interfaces, and an option against `none`.

`unsafe { x == 0 }` is accepted only when `x` is a struct reached through
a pointer, such as a `mut` parameter, and only there does the checker
suggest `unsafe`: a struct value compared with zero failed in the C
compiler.

The conditions of `match` branches, as in `match true { a < b {} }`, now
get the checks an `if` condition gets; none of them ran before.

Finding the operator of a comparison for a diagnostic no longer slices
past the end of the V file that template code is attributed to, whose
offsets can run beyond it.
A match condition such as `!(t > 1)`, `(t > 1)` or `is_true(t > 1)` has a
prefix, parenthesis or call node at its root, so the comparison inside it was
still left unchecked. Every condition that is an expression now gets the
checks of an `if` condition; names, which can stand for types, type patterns,
ranges, enum shorthands and `none` keep the checks of their own.

The test helpers quote the paths they pass to the shell, so a checkout or a
temporary directory whose path has spaces works.
A match condition such as `predicate(t > 1).value` has a selector at its
root, and every selector was left unchecked because `mod.Type` is one too.
Only a chain of names, which can be a qualified type, is left out now; a
selector on a call, a struct initializer, an index or any other expression
gets the checks of an `if` condition, so a comparison in its receiver is
reported.

The contexts test also checks that each error is a mismatch of the
operands, reported at the comparison.
The fixture is about a method that duplicates a builtin method of an enum,
but that method's body compared a flag enum with an int literal,
`f & f2 > 0`, which V1 rejects too and which this check now reports,
before the error the fixture is about. The body casts to int, as V asks,
and the fixture shows its own error only.
A server mode for editors: with V_DIAGNOSTICS_SERVER set, the compiler parses
builtin once and answers each `check <token>` line on stdin in a forked child
that finishes the one-shot run of the same command line. The child enters its
input directory again by name, gives the worker pools new threads (a pool from
an earlier fork generation revives on first use), keeps a memory watchdog of
its own, and its pid is announced so a client can stop a stale check. The
server also asks for V_CHECK_SELECTED_FILES_ONLY: library bodies and
signatures, which can only report on themselves, are not checked, and the
check-only markused pass, whose errors were discarded anyway, is skipped.

Faster for every compilation, with the same output:
- closest_identifier_span walked the whole file, byte by byte, for a name that
  is not written at all (a method's qualified `Type.name`), once per
  declaration; it now searches outward with memmem.
- remap_template_source rendered every template line again for each generated
  line it compared it with; each line is rendered once.
- the parser looked for a duplicate method in every node of the program; only
  the current file's nodes can match.
- import_is_used walked every node of the program for every import; it walks
  the import's own file.

`v -check` now also prints the warnings of a program without errors.
A build reports the private functions and constants that nothing uses once
markused knows what is reachable. `v -check`, which an editor runs, returned
before that point and never reported them; it now reports the same ones.

Markused runs only when a private function is neither called nor named as a
value. Every other function is taken as used, a callback included, so an
ordinary check only adds a scan of the names. A callback named only by code
that is itself unused, such as an unused method, can then go unreported where
a build reports it.

The notices are computed before the check-only monomorphization of programs
with globals rewrites the tree: after it, markused missed functions a build
finds reachable, and two callbacks of VLS were reported as unused.
An editor shows the diagnostics of a program while its errors are being
fixed. A check with errors returned before looking for unused private
declarations, so their notices went away with the first error and came
back with the last fix.

With errors, what is reachable cannot be told: a call the checker could
not resolve would leave its function looking unused. There, only the
functions and constants that nothing calls or names are reported, and
markused does not run. A syntax error still stops a check at the parser,
with no type errors and no notices.
An editor checks a module that no program imports on its own, as a
library, and a check without a main function reported no unused
declarations at all.

Now it reports the private functions and constants that nothing names.
The public declarations of a library are what other programs use, so
they are not reported, and a function exported to C or kept with
`@[markused]` is used although no V code names it. Without a program,
what a private declaration serves cannot be told beyond that: one that
is named anywhere counts as used, as in a check with errors.
VLS asks the compiler about a position of a file with
`-vls-mode -line-info <file>:<line>:<code><column>`: hover (`hv^`),
go-to-definition (`gd^`), signature help (`fn^`), completion (a bare column)
and inlay hints (`ih^`). Only V1, frozen at 0.5.2, answered them; V3 now does
too, from the checked program and in the formats V1 prints, so that VLS can
move to V3.

The answers come from the flat AST and the tables of the type checker, after
a serial check (vlib/v/types/vls_*.v):
- hover: the declaration of the name under the cursor as V1 writes it
  (`p main.Point`, `fn total(base int, nums ...int) int`, `struct Point`),
  also for the variables of `if x := f()`, `err` and `it`, and the value of
  an enum field (`Color.blue = 6`, `Perm.write = 0b10 (2)`);
- go-to-definition: `path:line:column`, a file of the checked directory
  written with that directory as the command line wrote it (`./main.v` for
  `.`), as V1 did;
- signature help: the signature of the call around the cursor, with the
  argument under the cursor as the active parameter;
- completion: the fields and methods of the value before a dot, the public
  members of a module, or the function a call names; the client writes a
  placeholder name after the dot, as the code has to parse;
- inlay hints: the types of variables and consts, parameter names, the
  implicit `err` of the `or {}` and guard `else {}` blocks after a Result,
  range bounds, enum values, the fields of a positional struct literal and a
  field listed out of declaration order.

The column is the 0-based byte of the cursor, and a name counts from its first
byte to just past its last one: V1 subtracted one and missed the first letter.

The launcher still sends `-vls-mode` to V1 unless `-new-compiler` is given.
Besides `check <token>`, the server now reads
`query <token> <file>:<line>:<code><column>`: its child answers the question
as `-line-info` does, in place of the diagnostics, and the server ends the
answer as it ends a check. The token comes first, as the path may hold spaces.
VLS writes the file as the editor holds it before it asks, as it does for a
check, and gets its answer without starting a compiler.

A one-shot `-line-info` also leaves the library bodies unchecked, as the
server does: they cannot change an answer about the program's own files.
A client that verifies many positions, as a rename or a references request
does, can now ask them all from one check: the questions go in one
`-line-info` value, or one `query` request, separated by tabs. Each gets an
answer on a line of its own after its index, `<index>\t<answer>`, empty when
there is none, so that other output cannot shift them. One question is
answered as before.
`err` names the error an `or {}` block handles, and the error of the call of
an `if x := call() {` guard in the plain `else` block right after it. V3 gave
every other `err` the type `IError` too, so a program that used it anywhere
passed `v -check` and then failed in the C compiler, where no `err` exists.

The `else` block of a guard now declares `err` in a scope of its own, and an
`err` anywhere else is an undefined identifier, unless the program declares
something with that name. A check that keeps no scope for such a block, as the
one of an instance of a generic function, finds `err` by where it is written.
A call that returns a Result was reported only in some places, such as an
assignment, an operand or an argument of a type. As a statement, as an
argument of `println` or `dump`, in a string interpolation, in a `defer`,
assigned to `_` or as an element of an array literal it went through: the
program ran on without the error, or printed the Result as if it were the
value.

There it is now a warning. `!` at the end passes the error on and an
`or {}` block handles it, as does `if x := call() {`; returning the call
from a function that returns a Result, or spawning it, passes it on too.
In a `defer`, where `!` cannot pass the error out, the warning asks for an
`or {}` block. Where the checker already reports an error, no warning is
added. With -W it is an error.

A check without errors prints no warnings, so the test builds each
program, as `v run` does before running it.
Before it renames a name, VLS asks where each of its occurrences is
declared, the declaring one too. With the cursor on the name of a struct
field, a function or a method, a method's receiver, a const, a method of
an interface, a local of `:=` or a loop variable, V3 answered nothing, and
VLS depended on a V1 patched to answer there. A local named like a symbol
of an imported module, `offset := 7` or `time := [1, 2]` with `import time`,
led to that symbol instead.

The declaration of such a name is now the name itself, as V1 answered. A
struct embedded by its name still stands for its type.
`u.id`, where `id` is a field of a struct that the struct of `u` embeds,
led nowhere: the embedded struct declares that field. A rename then saw
`u.id` as unresolved, and which occurrences it changed depended on where it
started. The definition is now the field of the embedded struct, when one
embedded struct declares it; with two the name is ambiguous, and V rejects
it.
The parser builds a slice `s[a..b]` from a `.range` node whose bounds the
slice then takes as its own children, and leaves the range out of the
tree. The index of parents can name that range as the parent of a bound,
so the answers to VLS that walk up from a name stopped there: a local in
the bounds was not found, and one named like a function, `close` in
`text[begin..close + 1]`, led to that function (`C.close`). A rename then
left it behind and broke the program.

The query engine now takes the slice as the parent of its bounds.
The statements of a loop body are children of the loop node, not of a
block, and a loop without an init or a step gets `.empty` placeholders for
them, which the parser may add after the body. The walk that looks for the
declaration of a local looked at the variables of a `for ... in` and at the
init of a C-style loop only, and it stopped at the first statement placed
after the use, a placeholder: a local declared in a loop body, `c :=
content[i]` in `for i < content.len {`, was not found further on in that
body. In the code of VLS that left 14 of the positions a rename checks
unanswered, and a rename slow or refused.

The walk now looks through the statements before the use in any node that
holds them, skipping placeholders. The variables of a `for ... in` exist in
its body only, not in its container.
The checker does not type the body of a generic function, and resolves no
call there: a method called on a receiver, `app.stop_run_commands()` in
`handle_requests[T]`, had no definition, hover or signature, and a rename
could not verify that occurrence. Where the checker resolved no call, the
method is now the one of the declared type of the receiver, or of the
struct that type embeds, as it already was for the builtin methods of
arrays, strings and maps.
To find the node a question is about, V3 looks at every node of the file,
and for some it took a copy of all the file before the node: before the
value of a `name: value` field, before a method's name to find its
receiver, before an argument that may follow `mut`. On the 252 KB
handlers.v of VLS, a question took 321 MB and 35 ms more than on a small
file. The same comparisons are now made in place, and the name of a
receiver is looked for in the text the question read already: a question
there takes 0.4 MB.
A query child checked the whole program for each question, a second on the
code of VLS, while an editor asks one question after another about the
same files. The child that answered now stays, with the program it
checked: it answers the next query in a few milliseconds while every file
it read holds the same bytes (their SHA-256 when it answered, then a quick
sum) and no file was added next to one of them or removed, and a new child
checks the program otherwise. The server names the child only once it
knows that it answers, so a client sees the same protocol.

What a child allocates is never freed: once it has grown by
V_DIAGNOSTICS_RETIRE_MB (1024 by default) since its first answer, it
answers its last question and leaves. The server keeps nothing for it and
reads its short replies through one buffer, so the server does not grow. A
child also ends with the server.

On the code of VLS, with the VLS of the vls-v3 branch, three renames went
from 1.3 s, 2.8 s and 6.1 s to 0.15 s, 1.3 s and 3.4 s.
A local name was looked up among the declarations of the code first, out
to the parameters of its function, and only then among the variables the
language declares: `err` in an `or {}` block and in the `else` of an
`if x := f()`, `it` in the argument of `.map()` and the like, `a` and `b`
in that of `.sort()`. With a parameter `err` outside, the `err` of an
`else` inside led to the parameter: go-to-definition answered it, hover
showed its type, and a rename of the parameter changed the other one too.
Likewise a local `it` or `a` outside `.map()` or `.sort()`.

The walk out from the use now finds both kinds together, and the nearest
one is the one, as in the scopes of the checker: go-to-definition answers
the `{` of the block or the name of the method, and hover the type of the
implicit variable.
A child that answered a query answers the next ones while the files it
read hold the same bytes and their directories the same names. Which
module an unchanged import means depends on more: the directories searched
before the one it was found in, which held no such module and were read
nowhere. With `-path first|second|@vlib` and `helper` in `second` only, a
`helper` added to `first` changed nothing the child watched, and it went
on answering about the module in `second`, where a new check would read
the one in `first`.

The child now keeps what each import resolved to, and before it answers
again resolves each one again as the compiler does, from its own module
roots, the project root and the directory of a file that imports it; a
different directory, or one found for an import that found none, makes it
stale, as a changed file does. On the code of VLS this takes 2 ms of the
7 ms of an answer, and 0.6 MB that the child never frees: it answers some
1400 queries before it leaves, where it answered some 12000.
A generic function is checked with its type parameters open, where `x.name`
cannot be judged, and nothing checked its concrete instances again: `show(1)`
with `x.name` in the body passed `v -check` and failed in the C compiler.
V1 reported "`int` has no property `name`" there.

When a diagnosed file declares a generic function, a check now monomorphizes
from the program's own functions and checks each concrete clone of its
generics, keeping only what the concrete types decide: a field or a method
such a type lacks. The full check of a clone stays limited to fixtures:
outside them it failed 266 of 498 valid generic programs of the tree. With
only member errors kept, those 498 give the same output as before.

The clones of library generics are not checked and their bodies are not
lowered, and the parent index covers each clone before it is checked;
otherwise every parent query of the clone scanned the whole tree.
V3's parser read the name of each type parameter and skipped what followed
it up to the next `,` or `]`: `fn longest[T Named]` was `fn longest[T]`,
`fn id[T Foo + 42]` compiled, and `v fmt` deleted the constraint. It now
reads a constraint after the name, a type name qualified by its module or
not, with type arguments or not, and reports anything else there
("unexpected token `+`, expecting `,` or `]`"); after a name it reported
already, it skips the rest as before. Functions, methods, structs,
interfaces and type declarations keep the constraints next to their generic
params, in the node payload, through the passes that move the params to
longer-lived memory, and `v fmt` writes them back.
For a type parameter that names an interface, `fn longest[T Named](a T, b T) T`:
- a call whose type argument does not implement the interface is reported
  at the argument that decided it, with the message V gives for a value
  passed to an interface parameter; a generic struct, `Box[int]{}`, too.
- in the body, a field or a method used on a value of type `T` that the
  interface does not declare is reported there, whether a call instantiates
  the function or not: "type `T` has no field named `age`: its constraint
  `Named` does not declare it".
- a constraint that names no interface, `[T User]`, is reported.

A type parameter without a constraint works as before.
…4 | f64`

A type parameter can also name a set of types, declared at the top level
with `constraint` (and `pub constraint`; `constraint` stays a name anywhere
else, as vlib uses it):
- a call whose type argument is not in the set is reported at the argument
  that decided it, "cannot use `string` as `T`: it is not in its constraint
  `Number`"; a type argument of a generic struct too.
- in the body, a field or a method used on a value of type `T` must be one
  that every type of the set has, as `x.str()` for numbers: "type `T` has
  no method `greet`: `Pet`, in its constraint `Animal`, does not have it".
- a type of the set that does not exist is reported.

`v fmt` keeps the declarations, and a module that has one is read from its
sources, not from a cached header. Operators on a value of such a `T` are
not checked yet. Also tests that a program with constraints builds and runs.
Inside `fn longest[T Named](a T, b T) T`, completion after `a.` offered
nothing, and hover and go-to-definition on `a.name` had no answer: the type
of `a` is an open type parameter. A value of a type parameter that names an
interface as its constraint now has the members of that interface: `a.`
completes `greet` and `name`, hover on `a.name` shows `name string`, and
go-to-definition leads to `name` in `interface Named`. For a set of types,
`constraint Number = int | f64`, completion offers what every type of the
set has, as `str`.
The conflict, in driver.v: master types a slice in the scan for implicit
imports (vlang#29180) where the experiment passes the whole scan index to
implicit_normalize_type; the slice is taken with the experiment's call. The
new implicit_match_type of master is adapted to the experiment's index the
same way, and notes its read of the fields index (index.read), which a
prepared server's replay of the scan needs to see every key a scan asked.
vlang#29186 (unqualified os consts of a .vsh) and vlang#29177 (toml) merge without
conflicts.
vlang#29184 (main types kept in imported generics reached through compile-time
fields) merges without conflicts with the monomorphization of the
experiment.
vlang#29181 (map value types and wrapper layers when matching generic type
names) merges without conflicts with the constraint checks of the
experiment in checker.v and checker_tail_stmt.v.
36 commits of master, none in conflict with the experiment; what the
experiment adds over master stays the same (84 files, the same lines).
Master's vlang#29197 passes `&&flat.Node` where V3 takes `&flat.Node` in
transform/array.v, so no test that imports v.transform compiles: the next
commit fixes it.
… holds

Master's vlang#29197 wrote `t.a.child_node(&node, 0)` with `node := t.a.node(...)`,
already a `&flat.Node`, and `t.a.child(&field, 0)` with `field` one too: V3
rejects the `&&flat.Node`, so no test that imports v.transform compiled
(every test of vlib/v/driver, for one).
The conflict, in token/token_test.v: master adds a test of the source digest
of a cloned file (vlang#29240) where the experiment adds one of the quick sums;
both stay. Master's vlang#29252 fixes the `&&flat.Node` of transform/array.v as
5c695d9 did.
@MARCROCK22
MARCROCK22 marked this pull request as ready for review October 2, 2026 03:14

@medvednikov medvednikov left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed current head f93481d. Confirmed merge blockers with independent reproductions; leaving this PR open.

Comment thread vlib/v/types/checker_generic_constraints.v

@medvednikov medvednikov left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented the reported review fixes in 48aed49.

Nested generic sum constraints retain every distinct instantiated variant while bounding recursive growth and preserving finite alias-nested instances.

Validation:

  • ./v self final rebuild passed.
  • Independent bounded function/method, finite nesting/alias nesting, direct/mutual/argument-hidden expanding sum controls pass.
  • Full strict Clang generic-constraints suite passed 1/1, including all existing function/method checks and all new controls.
  • Both known diagnostic fixture outputs are byte-identical on exact original and fixed PR compilers.
  • Formatting and git diff --check passed.
  • Added documentation prose passes check-md; all original fenced code is unchanged.
  • Full explicit-old docs check completed: 363 examples, 307 OK, 2 warnings, 12 errors, 4 format differences in unchanged examples.
  • Full strict types suite passed 86/86 combined: 15 Clang passes, one TCC trial pass, all 70 remaining TCC cases passed.
  • Extra finite/alias-nested Clang runtime probe reproduces byte-identical generic-sum forward-declaration errors on exact-original unconstrained control; cached and -nocache modes tested.

Validation limits:

  • Broad compiler validation has pre-existing comparing_typesymbol/private-import diagnostic fixture mismatches; exact-original comparisons are recorded. No .out expectations changed.
  • Only the identified review blocker and associated constraint behavior are covered; original experimental 84-file PR is not exhaustively reviewed.
  • Explicit-old Markdown tooling does not support all existing modern compiler examples, including constraints; full docs check remains baseline-incompatible.
  • Existing generic-sum runtime codegen can omit Part_First/Part_Part_int/Part_string forward declarations; exact-original unconstrained control reproduces byte-identical C errors. Only checker acceptance blocker is fixed.

This follow-up covers the reported generic-constraint finding and its recursion guard.

@medvednikov
medvednikov dismissed their stale review October 2, 2026 10:28

Reported findings are fixed in 48aed49; verified source push, focused regressions and independent fix review are complete.

@medvednikov medvednikov left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed revision 48aed49 against f93481d. The earlier finding about Part[int] | Part[string] losing the second instance is fixed: the isolated compiler rebuild, original independent string probe, and five relevant constraint test functions pass. A second source review found no new high-confidence issue in the finite-instance/active-expansion changes.

GitHub currently reports this branch CONFLICTING with master, so it cannot be squash merged as submitted. The original large experimental-feature review stopped after its concrete blocker; this targeted re-review does not constitute approval of the entire 84-file feature. Conflict resolution and the remaining full-feature audit are still needed before merge.

An extra recursive-argument probe accepts int but rejects []int; the same behavior reproduces on the prior head. It is not a regression in this revision, and the targeted checks do not settle the intended recursive-constraint semantics.

@medvednikov medvednikov left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the complete PR at 48aed49 with parallel source audits and independent reproductions. The earlier distinct generic-sum-instance issue is fixed, but the attached comments identify four P2 issues in promoted generic method values, constraint import accounting, recursive sum membership and editor declaration scope, plus a P3 embedding-depth limit.

Validation: 21 focused types/transform/generic/markused test files, five driver/editor/digest files, 19 parser/token/flat/formatter files and the embedded method-value integration test passed. The two remaining parser files could not complete because nested tool builds require an unavailable libgc.a. The full VLS suite hit its nested compiler memory limit; a large supplemental transform suite was deliberately stopped. Linux diagnostic-server lifecycle remains unverified. Independent failure cases and positive controls reproduce the findings at this exact head.

The PR also currently has merge conflicts. Please address the findings and update the branch before merging.

Comment thread vlib/v/transform/monomorphize.v Outdated
Comment thread vlib/v/types/checker.v
Comment thread vlib/v/types/checker_generic_constraints.v Outdated
Comment thread vlib/v/types/checker_generic_constraints.v Outdated
Comment thread vlib/v/types/vls_query.v
@medvednikov

Copy link
Copy Markdown
Member

@codex review

@medvednikov medvednikov left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed ec7b2ae after the master merge. The merge conflict is resolved, but all five existing findings still reproduce on this head:

Fresh exact-head compiler rebuild, 18 independent probes with passing controls, five focused authored constraint tests, and an independent editor baseline check completed. The earlier distinct generic-sum-instance fix remains correct. No duplicate inline threads were added. Changes are still required; this is not approval of the broader experimental feature.

@medvednikov medvednikov left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked the review continuity at current head ec7b2ae against the previously fully reviewed 48aed49.

The latest update is a master merge resolving checker configuration conflicts. It does not repair the outstanding feature findings: the recursive-constraint and VLS finding files are unchanged; promoted generic method-value declaration lookup still omits the embedded declaring receiver; and the import-usage changes address SQL without reading generic-constraint payloads. The previous changes-requested review remains applicable, including its embedding-depth finding.

This is a source comparison of the merge update, not a repeat of the full 84-file feature audit. No tests were rerun for this continuity check; the independent reproductions and validation are recorded in the existing complete-feature review. Please address those findings before merging.

Bind promoted generic method values to the concrete declaring embedded
receiver. Keep editor type-parameter fallback within its declaration,
including cached function-type returns. Preserve constraint-only imports,
reject growing recursive sum constraints and accept finite deep embedding.

Document the behavior and update the intended growing-sum expectation.
Integrate current master without rewriting either source history.

@medvednikov medvednikov left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented the reported review fixes through ad6ca28.

Nested generic sum constraints retain every distinct instantiated variant while bounding recursive growth and preserving finite alias-nested instances. Specialized promoted generic method values through their concrete declaring embedded receiver. Restricted editor generic-parameter fallback to its declaration, including cached function-type returns. Counted constraint-only imports, rejected type-growing recursive sum constraints and accepted finite struct embeddings without a fixed depth cutoff. Updated the intended growing-sum compile-error expectation and public documentation. Preserved incoming source/master history and all unrelated upstream tool/driver changes.

Validation of the latest fix:

  • Matching native-TCC ./v self passed with fallback disabled after the final cached-header follow-up; touched V files formatted and git diff checks passed.
  • Complete unfiltered type-checker suite passed89/89 on the final combined source, including constraint/import/deep-embedding and 42 cached-header scope cases.
  • Complete focused suite passed5/5: generic constraints, VLS generic scope, promoted method values, existing generic method-value type arguments and monomorphize.
  • Complete compiler output suite passed102 cases:90 ordinary,11 expected panics,1 existing skip,0 errors.
  • Independent strict Clang method/runtime controls passed, including padded/deep/generic embedded receivers and imported homonyms; combined checker/source-preservation audits found no blocker.
  • Full check-md completed362 documentation fences with317 OK and one inherited SQLite formatting error; its one whitespace-only canonical formatting correction passed exact-fence check-md1/1. No remaining changed documentation fence failure.
  • Selected mandatory compiler diagnostics reproduced two known baseline expectation mismatches. Explicitly filtered broader compiler harness passed1/1; no .out files changed.

Validation limits:

  • Broad compiler validation has pre-existing comparing_typesymbol/private-import diagnostic fixture mismatches; exact-original comparisons are recorded. No .out expectations changed.
  • Only the identified review blocker and associated constraint behavior are covered; original experimental 84-file PR is not exhaustively reviewed.
  • Explicit-old Markdown tooling does not support all existing modern compiler examples, including constraints; full docs check remains baseline-incompatible.
  • Existing generic-sum runtime codegen can omit Part_First/Part_Part_int/Part_string forward declarations; exact-original unconstrained control reproduces byte-identical C errors. Only checker acceptance blocker is fixed.
  • Two old-tooling warnings came from inherited VFLAGS being forwarded as tool path arguments; added prose/report checks were rerun with VFLAGS unset and passed cleanly.
  • The full compiler suite was not run; broader compiler harness used explicit selectors. Two selected baseline diagnostics differ in v.ast/private import expectations; no .out edits were used.
  • Direct pointer receiver generic method values already fail C generation on exact48 baseline and were not broadened by this focused embedded receiver fix.
  • Existing VLS line-info assertions passed through an external setup-only copy adding the compiler memory flag and anchoring fixture paths; the assertions were unchanged.
  • The full documentation command was not repeated after the single SQLite whitespace correction; the corrected final fence passed an exact-byte focused recheck.
  • Runtime validation is macOS ARM64; native Windows and other backends were not executed.

@medvednikov
medvednikov dismissed stale reviews from themself October 2, 2026 15:37

Reported findings are addressed in verified source head ad6ca28; relevant regression tests and an independent fix review completed.

@medvednikov
medvednikov merged commit c1bd176 into vlang:master Oct 2, 2026
medvednikov pushed a commit that referenced this pull request Oct 6, 2026
* tests: assert a lowered minimum really checks on the pool

check_program sets VJOBS=4 and builds the checker pool, so the parallel path
runs on Windows too. The `$if windows` branch asserted !was_parallel, which
contradicted the test's own name and failed there. It has been there since the
file was added in #29254.

* tests: compare the module source root with matching separators

current_file_module_source_root() answers with '/' separated paths because it
applies .replace('\\', '/'), while the raw os.real_path(project) expectation is
'\\' separated on Windows, so the two could never be equal there.

* tests: set VMODULES in-process instead of execing env

env is not on PATH on Windows, so the exec failed with CreateProcess code 2
before the child compiler started. Setting it in the test process, and restoring
it afterwards, gives the child the same environment.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants