Update compile-time resolution and tests

This commit is contained in:
sergeych committed 2026-02-03 02:07:29 +03:00
1 parent 8f60a84e3b
commit 523b9d338b
46 files changed
+3771 -967

No files matched your search

+52
View File
@@ -0,0 +1,52 @@
AI State (for session restart)
Project: /home/sergeych/dev/ling_lib
Module focus: :lynglib
Current focus
- Enforce compile-time name/member resolution only; no runtime scope lookup or fallback.
- Bytecode uses memberId-based ops (CALL_MEMBER_SLOT/GET_MEMBER_SLOT/SET_MEMBER_SLOT).
- Fallbacks are forbidden and throw BytecodeFallbackException.
Key recent changes
- Added memberId storage and lookup on ObjClass/ObjInstance; compiler emits memberId ops.
- Removed GET_THIS_MEMBER/SET_THIS_MEMBER usage from bytecode.
- Added ObjIterable.iterator() abstract method to ensure methodId exists.
- Bytecode compiler now throws if receiver type is unknown for member calls.
- Added static receiver inference attempts (name/slot maps), but still failing for stdlib list usage.
- Compiler now wraps function bodies into BytecodeStatement when possible.
- VarDeclStatement now includes initializerObjClass (compile-time type hint).
Known failing test
- ScriptTest.testForInIterableUnknownTypeDisasm (jvmTest)
Failure: BytecodeFallbackException "Member call requires compile-time receiver type: add"
Location: stdlib root.lyng filter() -> "val result = []" then "result.add(item)"
Current debug shows LocalSlotRef(result) slotClass/nameClass/initClass all null.
Likely cause
- Initializer type inference for "val result = []" in stdlib is not reaching BytecodeCompiler:
- stdlib is generated at build time; initializer is wrapped in BytecodeStatement and losing ListLiteralRef info.
- Need a stable compile-time type hint from the compiler or a way to preserve list literal info.
Potential fixes to pursue
1) Preserve ObjRef for var initializers (e.g., store initializer ref/type in VarDeclStatement).
2) When initializer is BytecodeStatement, use its CmdFunction to detect list/range literal usage.
3) Ensure stdlib compilation sets initializerObjClass for list literals.
Files touched recently
- notes/type_system_spec.md (spec updated)
- AGENTS.md (type inference reminders)
- lynglib/src/commonMain/kotlin/net/sergeych/lyng/VarDeclStatement.kt
- lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt
- lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCompiler.kt
- lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjIterable.kt
- various bytecode runtime/disassembler files (memberId ops)
Last test run
- ./gradlew :lynglib:jvmTest --tests ScriptTest.testForInIterableUnknownTypeDisasm
Spec decisions (notes/type_system_spec.md)
- Nullability: Kotlin-style, T non-null, T? nullable, !! asserts non-null.
- void is singleton of class Void (syntax sugar).
- Untyped params default to Object (non-null); syntax sugar: fun foo(x?) and class X(a,b?).
- Object member access requires explicit cast; remove inspect, use toInspectString().
+3 -2
View File
@@ -7,8 +7,9 @@
- Keep metaprogramming via explicit reflective APIs only.
## Non-Goals (initial phase)
- Dynamic by-name lookup as part of core execution path.
- Runtime scope walking to discover names.
- firbidden: Dynamic by-name lookup as part of core execution path.
- forbidden: Runtime scope walking to discover names.
## Overview
Compilation is split into two passes:
+254
View File
@@ -0,0 +1,254 @@
The goal is to add types with minimal changes to existing language,
using inference as wide as possible, and providing generic styles
better than even in C++ ;)
```lyng
fun t(x) {
// x is Object, and x is nullable
println(x)
}
t(3) // ok
```
Inferred by defaults:
```lyng
fun t1(a=0,b="foo") {
// a is Int, b is String
}
class Point(x=0.0, y=0.0) // Real, Real
```
val/ver declaration infers the type:
```lyng
val x = 3 // INT
extern fun obj_fun(): Object
extern fun int_fun(): Int
val i = int_fun() // inferred as Int
val o = obj_fun() // inferred as Object
val x: Int = objfun() // compiler generates "objfun as Int"
val y = objfun() as Int // Int, inferred
```
Inferring result:
```lyng
fun ifun1(a=0) = a+1 // type is (Int)->Int, inferred
fun ifun2(a=0) { a+1 } // type is (Int)->Int, inferred
fun ifun3(a=0) { void } // type is void inferred
fun ifun4(a: Int): Void { }
fun ifun5() { return 5 } // Inferred, ()->Int
```
In more complex cases we will use type parameters, like in Kotlin, Scala, Typescript:
```lyng
fun f1<T,R>(a: T,b: T): R {
(a + b) as R
}
// Type parameters are first class citizens:
fun f<T>(x: T) = T::class.name + "!"
assertEquals( "String!", f("foo") )
```
When the compiler ecnounters template types, it adds it as invisible parameters to a fun/method
call, or a class instance (to constructor, much the same as with a fun), so it is usable inside
the function/class body, as a `Class` instance:
```lyng
class Test<P> {
fun P_is_Int() = P is Int
}
```
So we do not expand templates, but we store generic types. Types can have bounds, like in Typescript:
```
class Foo<T: Iterable & Comparable> {
// ...
}
class Bar<T: Iterable | Comparable> {
val description by lazy {
if( T is Iterable ) "iterable and maybe comparable"
else "comparable only"
}
}
// compile time error:
assertEquals( "comparable only", Bar(42).description )
assertEquals( "iterable and maybe comparable", Bar([42]).description )
```
When bounds are specified, they are checked at compile time. Type compositions also can be used
when declare types much the same:
```lyng
fun x10(x: Int | Real) {
x * 10
}
assertEquals( 20, x10(2) )
assert( x10(2) is Int )
assertEquals(25, x10(2.5) )
assert( x10(2.5) is Real )
// the followinf is a compile time error:
x10("20") // string does not fit bounds
```
It is possible to create bounds that probably can't be satisfied:
```lyng
fun x10(x: Int & String) {
// ....
}
```
but in fact some strange programmer can create `class T: String, Int` so we won't check it for sanity, except that we certainly disallow <T: Void &...>
Instead of template expansions, we might provide explicit `inline` later.
Notes and open questions to answer in this spec:
- Void vs void: Void is a class, and void is the same as Void (alias).
`void` is a singleton of class Void; so `return void` is ok. `fun (): void` is allowed as syntax sugar and is checked as returning Void.
- Nullability rule: Are all types nullable by default (Object?) or non-null by default (Object), and how is nullable spelled? (e.g., Object?, Int?)
Not null by default (Object), must be specified with `?` suffix. We use Kotlin-style `!!` for non-null assertion. Therefore we check nullability at compile time, and we throw NPE only at `x!!` or `obj as X` (if obj is nullable, it is same as `obj!! as X`).
- Default type of untyped values: If a parameter has no type and no default, is it Object? (dynamic), or a new top type?
Lets discuss in more details. For example:
```lyng
var x // Unset, yet no type, no type check
x = 1 // set to 1, since now type is Int and is checled
x = "11" // error. type is already determined
```
With classes it is more interesting:
```lyng
// we assume it is x: Object, y: Object, no need to specify type
class Point(x,y) {
val d by lazy { sqrt(x*x + y*y) } // real!
}
assert( 5, Point(3,4).distance.roundToInt() )
// and this is ok too:
assert( 5, Point(3,4.0).distance.roundToInt() )
```
Syntax sugar for parameters:
- `fun foo(x?)` means `fun foo(x: Object?)`
- `class X(a, b?)` means `class X(a: Object, b: Object?)`
- `fun foo(x=3?)` means `fun foo(x: Int?) { ... }` with a nullable default
Untyped parameters:
- `fun foo(x)` means `x: Object`
- `fun foo(x?)` means `x: Object?`
- `fun foo(x=3)` means `x: Int`
- `fun foo(x=3?)` means `x: Int?`
Untyped vars and vals:
- `var x` is `Unset`. First assignment fixes its type (including nullability).
- If first assignment is `null`, the type becomes `Object?`.
- `val x = null` is allowed; type is `Null` (practically not useful and cannot be reassigned).
- `var x = null` is allowed; type is `Object?`.
Class-scope val initialization:
- `val x` at class scope is Unset until initialized
- it must be initialized by the constructor or init blocks
- indirect initialization via nested `run {}` or other delayed blocks should be disallowed (unless we add an explicit rule later)
- Implicit runtime checks: When assigning/calling with a declared type, do we insert "as Type" automatically, or require explicit casts? If inserted, what exception is thrown on failure?
We check that it is possible using inference or declared type, and if it _is possible_, and if it _is necessary_ we insert `as Type`:
```lyng
(3 as Int) // no insert, it is Int
val x: Object = // ...
(x as Real) // insert, possible, necessary
((x as Real) as String) // strange but still possible unless Real and Int will be final types (we don't have yet?)
(3 as String) // compile time error, impossible
```
Necessary means: if the compile-time type is fully known and assignable, emit direct assignment. If it is not fully known at compile time but potentially compatible, emit `(x as T)` which can throw `ClassCastException` at runtime.
- Numeric literals: default Int or Real? Is there literal suffix syntax? What about overflow?
as in docs, `3` is `Int`, `3.0` is `Real`. I think it is already implemented properly.
Let's not allow type conversion in `as`: let it only look for a proper `this` in inheritance graph and process nullability. `3.14.toInt()` checks for overflow, but (3.14 as Int) is a compile time error, as Int has no base class Real.
- Union/Intersection runtime checks: If T is inferred from value, are T | U bounds checked at compile time, runtime, or both?
At compile time, checked at call site. If the compile time exchaustive check is possible, we don't emit runtime check. Otherwise, we emit (x as T):
```lyng
// here no checks, any check is at the call site.
fun square<T: Int | Real>(x: T) = x*x
// this one checks at runtime as x is Object, and we have no idea at compile time what it is:
fun f(x) = suqare(x)
// this one checks at compile time only, no runtime checks:
fun f1(x: Int) = square(x)
// Here the compiler checks, but no runtime checks:
f1(100)
// and this is comlipe time error:
square("3.14")
```
- Generics runtime model: Are type params reified via hidden Class args always, or only when used (T::class, T is ...)? How does this interact with Kotlin interop?
I think we can omit if not used. For kotlin interop: if the class has at least one `extern` symbol, that means native implementation, we always include type parameters, to kotlin implementation can rely on it.
- Member access rules: If a variable is Object (dynamic), is member access a compile-time error, or allowed with fallback (which we are trying to remove)? If error, do we require explicit cast first?
Compile time error unless it is an Object own method. Let's force rewriting existing code in favor of explicit casts. It will repay itself: I laready have a project on Lyng that suffers from implicit casts har to trace errors.
Example:
```lyng
fun f(x) { // x: Object
x.size() // compile time error
val s = (x as String).size() // ok
val l = (x as List).size() // ok
}
```
Object methods:
- remove `inspect` from Object (too valuable a name)
- prefer `toInspectString()` as a reserved method
- keep `toString()` as Object method
- if we need extra metadata later, use explicit helpers like `Object.getHashCode(obj)`
- Builtin classes inheritance: Are Int/String final? If so, is "class T: String, Int" forbidden (and thus Int & String is unsatisfiable but still allowed)?
What keyword we did used for final vals/vars/funs? "closed"? Anyway I am uncertain whether to make Int or String closed, it is a discussion subject. But if we have some closed independent classes A, B, <T: A & B> is a compile time error.
Contextual typing rules (minimal):
1. If a contextual/expected type is known (declared type, parameter type, return position), use it to type literals, `Unset`, and empty collections.
2. Otherwise infer from literal contents:
- `[]` is `List<Object>` (non-null elements).
- non-empty list uses union element type with nullability.
- map literals infer from keys and values.
3. If contextual and literal inference disagree, it is a compile-time error.
4. Declared type is never erased by inference.
List inference:
- Empty list: `[]` is `List<Object>`.
- Non-empty list literal infers union type and nullability:
- `[1, 2, null]` is `List<Int?>`.
- `[1, 2, "3"]` is `List<Int|String>`.
Normalize unions by removing duplicates and collapsing nullability (e.g. `Int|Int?` -> `Int?`).
Map inference:
- `{ "a": 1, "b": 2 }` is `Map<String,Int>`.
- Empty map literal uses `{:}` (since `{}` is empty callable).
- `extern class Map<K=String,V=Object>` so `Map()` is `Map<String,Object>()` unless contextual type overrides.
Flow typing:
- Compiler should narrow types based on control-flow (e.g., `if (x != null)` narrows `x` to non-null inside the branch).
- Flow typing is permissive: it only makes types more precise.
- If a branch asserts a type that is impossible (e.g., `x is Int` then `x as String`), it is a compile-time error.
Class-scope val initialization:
- `val` must be initialized either in place or on every execution path in `init`, except paths that explicitly throw.