type-inference #39

Open
alex-eg wants to merge 17 commits from type-inference into main
Owner
No description provided.
alex-eg added 9 commits 2026-09-30 00:34:38 +02:00
Explicitly rename capturing lambdas to closures, they will be
implemented later
Expands to C11 _Static_assert keyword
Was an fmt-c-writer bug: unary-op must parenthize itself, and not it's
arg
Now only C type keywords will stay as a part of type, i.e.
(¤ unsigned int) -> unsigned int a[] was and is okay
The type IR, unification with the unknown type, constraints and
schemes, built and tested on its own. Nothing calls it yet.
A bare list was read as several forms, so a macro returning
`((make-adder 10) 5)' -- a call of what make-adder returns -- was
spliced into two. Add a new $ char to denote splicing, so ($ form ...)
splices.
Two things out of one mechanism. `_' as a type means "work it out from
the initializer", so (var n _ (strlen s)) stops needing size-t spelled
out; `type-of' hands a macro the type of an expression, so a macro can
dispatch on what it was handed rather than on what was declared. Both
read the same answers from two sides.

Algorithm W's core, intra-procedural, with the extensions C forces:

  - an unknown type, since (include stdio.h) brings in names we never
    parsed. Unification is consistency rather than equality, so
    anything touching an unparsed declaration stops constraining
    instead of rejecting a program that compiled yesterday;
  - the usual arithmetic conversions, since `+' is not a function of
    one type;
  - checking mode for initializers, since #(0 0) has no type of its own
    and takes one from its context. #(T : ...) is the way out of that.

What it wanted on the way:

  - what type a *name* has, which neither the typedef nor the tag
    database recorded. One table serves functions and variables, since
    a function type already has a surface spelling;
  - a scope chain, so a (var c int 9) inside a do ends with the block;
  - form-type, keyed by cons cell, so one form has one type;
  - macros expanded during the walk rather than before it, so type-of
    is answered in the scope the macro was written in.

Closures take the same machinery: a receiver whose type comes from a
call, captures written (name expr) and typed from the expression, and
conversion from a bare function wherever a closure is expected.

type-match grew `_' on the pattern side, since (closure ((int)) int)
and (closure ((float)) int) were separate clauses for one case.
ignore the attic directory
All checks were successful
Sex CI / build-linux (pull_request) Successful in 5m12s
9daaa42d6a
alex-eg requested review from pkulev 2026-09-30 00:34:38 +02:00
pkulev requested changes 2026-09-30 11:39:23 +02:00
pkulev left a comment
Collaborator

Built and ran this on CHICKEN 6.0.0 at 9daaa42. make run-tests is green, including closures, fixpoint, wildcards, and inference. The holes below are outside that suite. I compiled each one with ./sexc -C and, where it emitted C, with cc.

Closures (inline environment, the receiver evaluated once, conversion from a bare function, the fixpoint forward declaration), $ splicing, static-assert, the (*p).x parentheses, and symbolic array bounds all match the new tests.

Before the freeze

  1. _ writes a C type that is not the type of the expression. arith-join keeps the left operand when the ranks tie. size-t is an unparsed name, so it ties with int. With strlen declared size-t, an unsigned int u, an int i, and a char c:
size_t n = strlen(s);
int m1 = 1 + n;
size_t m2 = n + 1;
unsigned int x = u + i;
int y = i + u;
char z = c + c;

cc accepts the function. In C, 1 + n has type size_t and i + u has type unsigned int; these declarations narrow. The comment on arith-join already records the unsigned case. size-t is the same tie, and it is the next line after the strlen example.

  1. The operators the writer emits are absent from expression-type. The bool clause still names c-and and c-or. (var ok _ (&& a b)) stops with cannot infer the type of, and so do ||, |, &, <<, and ++.

  2. decay is never called. (var p _ (+ a 1)) on int a[4] becomes int p[4] = a + 1. cc: invalid initializer. (var e _ (¤ p 0)) on an (* int) stops with cannot infer the type of. Those are the two positions the comment on decay names.

  3. A toplevel _ is copied into the C. (var n _ 1) emits static _ n = 1. cc: unknown type name '_'. Locals go through resolve-wildcard. process-global-var does not.

  4. The body walk rewrites types. With a local c of closure type, (var fp (fn ((c int)) int) ...) is processed into (fn ((ƛint_int_call c int)) int) and emitted as int (*fp)(c int). The same declaration with no closure named c emits int (*fp)(int). cc: expected declaration specifiers or '...' before 'c'.

  5. A closure literal in call position is not a call of a closure. ((closure ((x int)) int () (return x)) 5) emits return ƛ0_f_make()(5). cc: called object is not a function or function pointer. ((lambda ...) 5) and ((make-adder 20) 22) both work. The callee's type is read before the literal is lifted.

Can wait

  • An undeclared (strlen "hi"), with no extern, reports cannot infer. The walk never builds the unknown type ?. inference.sex describes a ? error. Failing closed is right; the two messages should meet.
  • infer.scm still opens with "Nothing in the compiler calls this unit yet."
  • Unifying a rigid variable with a flexible one depends on which argument is rigid. Nothing in the compiler builds a rigid variable yet.

I would want 1–6 pinned by tests that fail on this tree. The suite is green because none of these programs are in it.

Built and ran this on CHICKEN 6.0.0 at `9daaa42`. `make run-tests` is green, including `closures`, `fixpoint`, `wildcards`, and `inference`. The holes below are outside that suite. I compiled each one with `./sexc -C` and, where it emitted C, with `cc`. Closures (inline environment, the receiver evaluated once, conversion from a bare function, the fixpoint forward declaration), `$` splicing, `static-assert`, the `(*p).x` parentheses, and symbolic array bounds all match the new tests. ## Before the freeze 1. **`_` writes a C type that is not the type of the expression.** `arith-join` keeps the left operand when the ranks tie. `size-t` is an unparsed name, so it ties with `int`. With `strlen` declared `size-t`, an `unsigned int u`, an `int i`, and a `char c`: ```c size_t n = strlen(s); int m1 = 1 + n; size_t m2 = n + 1; unsigned int x = u + i; int y = i + u; char z = c + c; ``` `cc` accepts the function. In C, `1 + n` has type `size_t` and `i + u` has type `unsigned int`; these declarations narrow. The comment on `arith-join` already records the unsigned case. `size-t` is the same tie, and it is the next line after the `strlen` example. 2. **The operators the writer emits are absent from `expression-type`.** The bool clause still names `c-and` and `c-or`. `(var ok _ (&& a b))` stops with `cannot infer the type of`, and so do `||`, `|`, `&`, `<<`, and `++`. 3. **`decay` is never called.** `(var p _ (+ a 1))` on `int a[4]` becomes `int p[4] = a + 1`. `cc`: `invalid initializer`. `(var e _ (¤ p 0))` on an `(* int)` stops with `cannot infer the type of`. Those are the two positions the comment on `decay` names. 4. **A toplevel `_` is copied into the C.** `(var n _ 1)` emits `static _ n = 1`. `cc`: `unknown type name '_'`. Locals go through `resolve-wildcard`. `process-global-var` does not. 5. **The body walk rewrites types.** With a local `c` of closure type, `(var fp (fn ((c int)) int) ...)` is processed into `(fn ((ƛint_int_call c int)) int)` and emitted as `int (*fp)(c int)`. The same declaration with no closure named `c` emits `int (*fp)(int)`. `cc`: `expected declaration specifiers or '...' before 'c'`. 6. **A closure literal in call position is not a call of a closure.** `((closure ((x int)) int () (return x)) 5)` emits `return ƛ0_f_make()(5)`. `cc`: `called object is not a function or function pointer`. `((lambda ...) 5)` and `((make-adder 20) 22)` both work. The callee's type is read before the literal is lifted. ## Can wait - An undeclared `(strlen "hi")`, with no `extern`, reports `cannot infer`. The walk never builds the unknown type `?`. `inference.sex` describes a `?` error. Failing closed is right; the two messages should meet. - `infer.scm` still opens with "Nothing in the compiler calls this unit yet." - Unifying a rigid variable with a flexible one depends on which argument is rigid. Nothing in the compiler builds a rigid variable yet. I would want 1–6 pinned by tests that fail on this tree. The suite is green because none of these programs are in it.
Collaborator

When the ranks tie this keeps the left operand, so the declared type depends on the order of the source.

With n a size-t (an unparsed name, rank 1, same as int):

int m1 = 1 + n;
size_t m2 = n + 1;

cc accepts both. C types 1 + n as size_t, and the int declaration narrows. The same tie makes (+ i u) an int and (+ u i) an unsigned int. char + char is declared char.

size-t never reaches the long rank because it is not a typedef Sex has parsed, so the strlen example's (+ 1 n) lands here.

When the ranks tie this keeps the left operand, so the declared type depends on the order of the source. With `n` a `size-t` (an unparsed name, rank 1, same as `int`): ```c int m1 = 1 + n; size_t m2 = n + 1; ``` `cc` accepts both. C types `1 + n` as `size_t`, and the `int` declaration narrows. The same tie makes `(+ i u)` an `int` and `(+ u i)` an `unsigned int`. `char + char` is declared `char`. `size-t` never reaches the `long` rank because it is not a typedef Sex has parsed, so the `strlen` example's `(+ 1 n)` lands here.
Collaborator

The forms that reach this walk are &&, ||, |, &, <<, >>, ++. c-and and c-or are the names from before those spellings. The writer already emits a && b from (&& a b).

(var ok _ (&& a b)) and (var x _ (<< a b)) and (var x _ (++ a)) all stop with cannot infer the type of.

The forms that reach this walk are `&&`, `||`, `|`, `&`, `<<`, `>>`, `++`. `c-and` and `c-or` are the names from before those spellings. The writer already emits `a && b` from `(&& a b)`. `(var ok _ (&& a b))` and `(var x _ (<< a b))` and `(var x _ (++ a))` all stop with `cannot infer the type of`.
Collaborator

An array operand is returned as the array. decay in infer.scm is the conversion this comment describes, and nothing in the walk calls it.

(var a (¤ int 4) #(1 2 3 4))
(var p _ (+ a 1))

emits int p[4] = a + 1. cc: invalid initializer.

An array operand is returned as the array. `decay` in `infer.scm` is the conversion this comment describes, and nothing in the walk calls it. ```sex (var a (¤ int 4) #(1 2 3 4)) (var p _ (+ a 1)) ``` emits `int p[4] = a + 1`. `cc`: `invalid initializer`.
Collaborator

A subscript types only when the base's surface form is already (¤ ...). An (* int) is not, so

(fn f ((p (* int))) int
  (var e _ (¤ p 0))
  (return e))

stops with cannot infer the type of. This is the other use named on decay.

A subscript types only when the base's surface form is already `(¤ ...)`. An `(* int)` is not, so ```sex (fn f ((p (* int))) int (var e _ (¤ p 0)) (return e)) ``` stops with `cannot infer the type of`. This is the other use named on `decay`.
Collaborator

resolve-closure-types runs here and resolve-wildcard does not. (var n _ 1) at toplevel emits static _ n = 1. cc: unknown type name '_'. The same form inside a function becomes int n = 1.

`resolve-closure-types` runs here and `resolve-wildcard` does not. `(var n _ 1)` at toplevel emits `static _ n = 1`. `cc`: `unknown type name '_'`. The same form inside a function becomes `int n = 1`.
Collaborator

walk-parts walks the type as well as the initializer. A list whose head is an in-scope closure is rewritten as a closure call, including a parameter name inside a fn type.

(var c (closure ((int)) int) (make-adder 1))
(var fp (fn ((c int)) int) (lambda ((c int)) int (return c)))

-m shows (var fp (fn ((ƛint_int_call c int)) int) ...), and the C is int (*fp)(c int). With no closure named c, the same declaration emits int (*fp)(int).

`walk-parts` walks the type as well as the initializer. A list whose head is an in-scope closure is rewritten as a closure call, including a parameter name inside a `fn` type. ```sex (var c (closure ((int)) int) (make-adder 1)) (var fp (fn ((c int)) int) (lambda ((c int)) int (return c))) ``` `-m` shows `(var fp (fn ((ƛint_int_call c int)) int) ...)`, and the C is `int (*fp)(c int)`. With no closure named `c`, the same declaration emits `int (*fp)(int)`.
Collaborator

The callee is typed before it is walked, so a closure literal has no type yet and falls through to convert-arguments. After the walk the form is a call of the constructor:

return ƛ0_f_make()(5);

cc: called object is not a function or function pointer. ((lambda ((x int)) int (return x)) 5) works, because the lifted function is registered before the call is typed. ((make-adder 20) 22) works, because the callee is already a call with a closure return type.

The callee is typed before it is walked, so a closure literal has no type yet and falls through to `convert-arguments`. After the walk the form is a call of the constructor: ```c return ƛ0_f_make()(5); ``` `cc`: `called object is not a function or function pointer`. `((lambda ((x int)) int (return x)) 5)` works, because the lifted function is registered before the call is typed. `((make-adder 20) 22)` works, because the callee is already a call with a closure return type.
pkulev added the feature label 2026-09-30 11:42:25 +02:00
alex-eg added 8 commits 2026-09-30 22:58:22 +02:00
An arglist entry, an array's bound and its element type ask one
question, and disagreed: `(unsigned int)' was a name plus a type, a
trailing typedef a bound, a subscript the type's second word.
fmt-c takes a parameter's name with `cadr', so an unnamed one handed
over bare lost its second word to it: `(* const char)' dropped its
star, and `(int)' had no second word at all.
A `while' or `switch' body is a block with no `do' around it, and its
declarations landed in the enclosing frame. Two operands of one narrow
type skipped the conversions, so `(+ c c)' answered `char'.
The argument list was flattened with one separator throughout, so
`((long long))' and `((long) (long))' named one struct. A capture
borrowing a name looked only at the local scope chain.
`max_align_t' is C11 and the union goes into every unit, so a program
with no closure in it stopped building under -std=c99. `unify' also
bound a rigid variable one way round only.
`&&', the bitwise operators, the shifts and `++' each stopped with
`cannot infer'; an array operand came back as the array; a rank tie
went to whichever operand came first; and a toplevel `_' reached the
writer unsolved.
`walk-parts' walked the whole `var' form, and a `fn' type's parameter
list is shaped like a call: `(fn ((c int)) int)' came out as
`int (*fp)(c int)'. A closure literal had no type, so calling one in
place missed the rewrite; sextest fed sexc symbols Sex cannot read.
show the cases the comments were describing
All checks were successful
Sex CI / build-linux (pull_request) Successful in 5m18s
a860da6d7e
alex-eg reviewed 2026-09-30 23:11:00 +02:00
alex-eg left a comment
Author
Owner

Replies to the review, each on the line it concerns. All eight are in 9daaa42..a860da6.

Replies to the review, each on the line it concerns. All eight are in `9daaa42..a860da6`.
@@ -95,3 +98,2 @@
SEX_TEST_PROGRAMS = hello-world lists comments unicode serialize features \
feature-flags lambdas compound-literals
SEX_TEST_PROGRAMS = c99 \
Author
Owner

Re 351. One per line, alphabetical. e87463b.

Re 351. One per line, alphabetical. e87463b.
@@ -250,0 +384,4 @@
env))
(fourth form))))))))
((var)
Author
Owner

Re 347. The type gets what a fn header gets -- macro expansion and closure resolution, no walk.

(var c (closure ((int)) int) (make-adder 1))
(var fp (fn ((c int)) int) area)   ->  int (*fp)(int)

fe72a10.

Re 347. The type gets what a `fn` header gets -- macro expansion and closure resolution, no walk. ``` (var c (closure ((int)) int) (make-adder 1)) (var fp (fn ((c int)) int) area) -> int (*fp)(int) ``` fe72a10.
@@ -250,0 +431,4 @@
(else
(let ((closure (receiver-closure-type (car form) env)))
(if closure
;; the receiver is walked first: `((closure ((x int)) int
Author
Owner

Re 348. A closure literal answers with its own type. The receiver is walked before the call helper is registered, so the struct is declared ahead of the signature naming it.

((closure ((x int)) int () (return x)) 5)  ->  5

fe72a10.

Re 348. A closure literal answers with its own type. The receiver is walked before the call helper is registered, so the struct is declared ahead of the signature naming it. ``` ((closure ((x int)) int () (return x)) 5) -> 5 ``` fe72a10.
@@ -252,0 +602,4 @@
(case (car expr)
;; (¤ pts 1), pts : (¤ struct point 2) -> (struct point)
;; (¤ p 1), p : (* int) -> int
((¤) (let ((base (expression-type (second expr) env)))
Author
Owner

Re 345.

(var e _ (¤ p 0))    p   : (* int)            ->  int e = p[0];
(var a _ (¤ pts 1))  pts : (¤ struct point 2) ->  struct point, was struct

2b73fcf.

Re 345. ``` (var e _ (¤ p 0)) p : (* int) -> int e = p[0]; (var a _ (¤ pts 1)) pts : (¤ struct point 2) -> struct point, was struct ``` 2b73fcf.
@@ -252,0 +630,4 @@
((^ |\||) (arithmetic-type expr env))
;; a shift is the promoted left operand, not a join:
;; (<< l b), l : long -> long; (>> c b), c : char -> int
((<< >>) (promoted-type (expression-type (second expr) env)))
Author
Owner

Re 343.

(&& a b) -> bool          (| a b) -> int
(<< l b) l : long -> long (>> c b) c : char -> int
(++ c)   c : char -> char

sextest wrote || escaped, which Sex does not read, so no test could have reached it. Fixed with it. e87463b.

Re 343. ``` (&& a b) -> bool (| a b) -> int (<< l b) l : long -> long (>> c b) c : char -> int (++ c) c : char -> char ``` sextest wrote `||` escaped, which Sex does not read, so no test could have reached it. Fixed with it. e87463b.
@@ -252,0 +656,4 @@
#f
(cdr expr)))
(define (arith-join left right)
Author
Owner

Re 342. Unsigned takes the tie whichever side it is written on, and a name with no declaration parsed has no rank to compare:

(+ i u) -> unsigned int      (+ u i) -> unsigned int
(var m _ (+ 1 n))       n : size-t -> type of this is unknown; write it out
(var m size-t (+ 1 n))             -> size_t m = 1 + n;

578feb6, e87463b.

Re 342. Unsigned takes the tie whichever side it is written on, and a name with no declaration parsed has no rank to compare: ``` (+ i u) -> unsigned int (+ u i) -> unsigned int (var m _ (+ 1 n)) n : size-t -> type of this is unknown; write it out (var m size-t (+ 1 n)) -> size_t m = 1 + n; ``` 578feb6, e87463b.
@@ -252,0 +696,4 @@
;;;
;;; (var p _ (+ a 1)) a : (¤ int 4) -> int * p = a + 1;
;;; not int p[4] = a + 1;
(define (decayed written type)
Author
Owner

Re 344. decay has its caller.

(var a (¤ int 4) #(1 2 3 4))
(var p _ (+ a 1))   ->  int * p = a + 1;

e87463b.

Re 344. `decay` has its caller. ``` (var a (¤ int 4) #(1 2 3 4)) (var p _ (+ a 1)) -> int * p = a + 1; ``` e87463b.
@@ -284,2 +1140,3 @@
(define (process-global-var sex-var acc)
(cons sex-var acc))
;; A global is not walked for lambdas, but the writer spells neither
Author
Owner

Re 346. Solved from get-name-type alone, so a global names only what is declared above it.

(var n _ 1)     ->  static int n = 1;
(var s _ "hi")  ->  static const char * s = "hi";

e87463b.

Re 346. Solved from `get-name-type` alone, so a global names only what is declared above it. ``` (var n _ 1) -> static int n = 1; (var s _ "hi") -> static const char * s = "hi"; ``` e87463b.
alex-eg requested review from pkulev 2026-09-30 23:13:08 +02:00
All checks were successful
Sex CI / build-linux (pull_request) Successful in 5m18s
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin type-inference:type-inference
git checkout type-inference
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: alex-eg/sex#39