diff --git a/AGENTS.md b/AGENTS.md index 2c375fb..fa74f8f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,7 @@ - `void` is a singleton of class `Void` (syntax sugar for return type). - Object members are always allowed even on unknown types; non-Object members require explicit casts. Remove `inspect` from Object and use `toInspectString()` instead. - Type expression checks: `x is T` is value instance check; `T1 is T2` is type-subset; `A in T` means `A` is subset of `T`; `==` is structural type equality. +- Type aliases: `type Name = TypeExpr` (generic allowed) expand to their underlying type expressions; no nominal distinctness. - Do not reintroduce bytecode fallback opcodes (e.g., `GET_NAME`, `EVAL_*`, `CALL_FALLBACK`) or runtime name-resolution fallbacks; all symbol resolution must stay compile-time only. ## Bytecode frame-first migration plan diff --git a/docs/generics.md b/docs/generics.md index 8ab6ea4..2c933ed 100644 --- a/docs/generics.md +++ b/docs/generics.md @@ -32,6 +32,20 @@ Generic types are invariant by default. You can specify declaration-site varianc `out` makes the type covariant (produced), `in` makes it contravariant (consumed). +# Type aliases + +Type aliases name type expressions (including unions/intersections): + + type Num = Int | Real + type AB = A & B + +Aliases can be generic and can use bounds and defaults: + + type Maybe = T? + type IntList = List + +Aliases expand to their underlying type expressions. They can be used anywhere a type expression is expected. + # Inference rules - Literals set obvious types (`1` is `Int`, `1.0` is `Real`, etc.). diff --git a/docs/tutorial.md b/docs/tutorial.md index f68ad3c..5f9a054 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -467,6 +467,13 @@ It cannot be combined with an explicit type annotation. class A(x?) { ... } // x: Object? fun f(x?) { x == null } // x: Object? +Type aliases name type expressions and can be generic: + + type Num = Int | Real + type Maybe = T? + +Aliases expand to their underlying type expressions. See `docs/generics.md` for details. + `void` is a singleton value of the class `Void`. `Void` can be used as an explicit return type: fun log(msg): Void { println(msg); void } @@ -503,6 +510,8 @@ Empty list/map literals default to `List` and `Map` until val xs = [] // List val ys: List = [] // List +Map literals infer key/value types from entries; named keys are `String`. See `docs/generics.md` for details. + ## Flow analysis Lyng uses flow analysis to narrow types inside branches: diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt index 1a0773c..a40150e 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt @@ -129,6 +129,13 @@ class Compiler( private val nameObjClass: MutableMap = mutableMapOf() private val slotTypeDeclByScopeId: MutableMap> = mutableMapOf() private val nameTypeDecl: MutableMap = mutableMapOf() + private data class TypeAliasDecl( + val name: String, + val typeParams: List, + val body: TypeDecl, + val pos: Pos + ) + private val typeAliases: MutableMap = mutableMapOf() private val methodReturnTypeDeclByRef: MutableMap = mutableMapOf() private val callableReturnTypeByScopeId: MutableMap> = mutableMapOf() private val callableReturnTypeByName: MutableMap = mutableMapOf() @@ -558,6 +565,59 @@ class Compiler( return typeParams } + private suspend fun parseTypeAliasDeclaration(): Statement { + if (codeContexts.lastOrNull() is CodeContext.ClassBody) { + throw ScriptError(cc.currentPos(), "type alias is not allowed in class body") + } + val nameToken = cc.requireToken(Token.Type.ID, "type alias name expected") + val name = nameToken.value + if (typeAliases.containsKey(name)) { + throw ScriptError(nameToken.pos, "type alias $name already declared") + } + if (resolveTypeDeclObjClass(TypeDecl.Simple(name, false)) != null) { + throw ScriptError(nameToken.pos, "type alias $name conflicts with existing class") + } + val typeParams = parseTypeParamList() + val uniqueParams = typeParams.map { it.name }.toSet() + if (uniqueParams.size != typeParams.size) { + throw ScriptError(nameToken.pos, "type alias $name has duplicate type parameters") + } + val typeParamNames = uniqueParams + if (typeParamNames.isNotEmpty()) pendingTypeParamStack.add(typeParamNames) + val body = try { + cc.skipWsTokens() + val eq = cc.nextNonWhitespace() + if (eq.type != Token.Type.ASSIGN) { + throw ScriptError(eq.pos, "type alias $name expects '='") + } + parseTypeExpressionWithMini().first + } finally { + if (typeParamNames.isNotEmpty()) pendingTypeParamStack.removeLast() + } + val alias = TypeAliasDecl(name, typeParams, body, nameToken.pos) + typeAliases[name] = alias + declareLocalName(name, isMutable = false) + resolutionSink?.declareSymbol(name, SymbolKind.LOCAL, isMutable = false, pos = nameToken.pos) + pendingDeclDoc = null + + val aliasExpr = net.sergeych.lyng.obj.TypeDeclRef(body, nameToken.pos) + val initStmt = ExpressionStatement(aliasExpr, nameToken.pos) + val slotPlan = slotPlanStack.lastOrNull() + val slotIndex = slotPlan?.slots?.get(name)?.index + val scopeId = slotPlan?.id + return VarDeclStatement( + name = name, + isMutable = false, + visibility = Visibility.Public, + initializer = initStmt, + isTransient = false, + slotIndex = slotIndex, + scopeId = scopeId, + startPos = nameToken.pos, + initializerObjClass = null + ) + } + private fun lookupSlotLocation(name: String, includeModule: Boolean = true): SlotLocation? { for (i in slotPlanStack.indices.reversed()) { if (!includeModule && i == 0) continue @@ -2743,7 +2803,112 @@ class Compiler( } private fun parseTypeExpressionWithMini(): Pair { - return parseTypeUnionWithMini() + val start = cc.currentPos() + val (decl, mini) = parseTypeUnionWithMini() + return expandTypeAliases(decl, start) to mini + } + + private fun expandTypeAliases(type: TypeDecl, pos: Pos, seen: MutableSet = mutableSetOf()): TypeDecl { + return when (type) { + is TypeDecl.Simple -> expandTypeAliasReference(type.name, emptyList(), type.isNullable, pos, seen) ?: type + is TypeDecl.Generic -> { + val expandedArgs = type.args.map { expandTypeAliases(it, pos, seen) } + expandTypeAliasReference(type.name, expandedArgs, type.isNullable, pos, seen) + ?: TypeDecl.Generic(type.name, expandedArgs, type.isNullable) + } + is TypeDecl.Function -> { + val receiver = type.receiver?.let { expandTypeAliases(it, pos, seen) } + val params = type.params.map { expandTypeAliases(it, pos, seen) } + val ret = expandTypeAliases(type.returnType, pos, seen) + TypeDecl.Function(receiver, params, ret, type.nullable) + } + is TypeDecl.Union -> { + val options = type.options.map { expandTypeAliases(it, pos, seen) } + TypeDecl.Union(options, type.isNullable) + } + is TypeDecl.Intersection -> { + val options = type.options.map { expandTypeAliases(it, pos, seen) } + TypeDecl.Intersection(options, type.isNullable) + } + else -> type + } + } + + private fun expandTypeAliasReference( + name: String, + args: List, + isNullable: Boolean, + pos: Pos, + seen: MutableSet + ): TypeDecl? { + val alias = typeAliases[name] ?: typeAliases[name.substringAfterLast('.')] ?: return null + if (!seen.add(alias.name)) throw ScriptError(pos, "circular type alias: ${alias.name}") + val bindings = buildTypeAliasBindings(alias, args, pos) + val substituted = substituteTypeAliasTypeVars(alias.body, bindings) + val expanded = expandTypeAliases(substituted, pos, seen) + seen.remove(alias.name) + return if (isNullable) makeTypeDeclNullable(expanded) else expanded + } + + private fun buildTypeAliasBindings( + alias: TypeAliasDecl, + args: List, + pos: Pos + ): Map { + val params = alias.typeParams + val resolvedArgs = if (args.size >= params.size) { + if (args.size > params.size) { + throw ScriptError(pos, "type alias ${alias.name} expects ${params.size} type arguments") + } + args.toMutableList() + } else { + val filled = args.toMutableList() + for (param in params.drop(args.size)) { + val fallback = param.defaultType + ?: throw ScriptError(pos, "type alias ${alias.name} expects ${params.size} type arguments") + filled.add(fallback) + } + filled + } + val bindings = mutableMapOf() + for ((index, param) in params.withIndex()) { + val arg = resolvedArgs[index] + val bound = param.bound + if (bound != null && arg !is TypeDecl.TypeVar && !typeDeclSatisfiesBound(arg, bound)) { + throw ScriptError(pos, "type alias ${alias.name} type argument ${param.name} does not satisfy bound") + } + bindings[param.name] = arg + } + return bindings + } + + private fun substituteTypeAliasTypeVars(type: TypeDecl, bindings: Map): TypeDecl { + return when (type) { + is TypeDecl.TypeVar -> { + val bound = bindings[type.name] ?: return type + if (type.isNullable) makeTypeDeclNullable(bound) else bound + } + is TypeDecl.Simple -> type + is TypeDecl.Generic -> { + val args = type.args.map { substituteTypeAliasTypeVars(it, bindings) } + TypeDecl.Generic(type.name, args, type.isNullable) + } + is TypeDecl.Function -> { + val receiver = type.receiver?.let { substituteTypeAliasTypeVars(it, bindings) } + val params = type.params.map { substituteTypeAliasTypeVars(it, bindings) } + val ret = substituteTypeAliasTypeVars(type.returnType, bindings) + TypeDecl.Function(receiver, params, ret, type.nullable) + } + is TypeDecl.Union -> { + val options = type.options.map { substituteTypeAliasTypeVars(it, bindings) } + TypeDecl.Union(options, type.isNullable) + } + is TypeDecl.Intersection -> { + val options = type.options.map { substituteTypeAliasTypeVars(it, bindings) } + TypeDecl.Intersection(options, type.isNullable) + } + else -> type + } } private fun makeTypeDeclNullable(type: TypeDecl): TypeDecl { @@ -4499,6 +4664,12 @@ class Compiler( parseEnumDeclaration() } + "type" -> { + pendingDeclStart = id.pos + pendingDeclDoc = consumePendingDoc() + parseTypeAliasDeclaration() + } + "try" -> parseTryStatement() "throw" -> parseThrowStatement(id.pos) "when" -> parseWhenStatement() diff --git a/lynglib/src/commonTest/kotlin/TypesTest.kt b/lynglib/src/commonTest/kotlin/TypesTest.kt index 5c8d676..9916a6d 100644 --- a/lynglib/src/commonTest/kotlin/TypesTest.kt +++ b/lynglib/src/commonTest/kotlin/TypesTest.kt @@ -239,6 +239,32 @@ class TypesTest { """) } + @Test + fun testTypeAliases() = runTest { + eval(""" + type Num = Int | Real + type AB = A & B + class A + class B + class C: A, B + val c = C() + assert( c is AB ) + assert( 1 is Num ) + assert( !(true is Num) ) + val v: Num = 1.5 + assert( v is Num ) + + type Maybe = T? + fun f(x: Maybe) = x + assertEquals(null, f(null)) + assertEquals(1, f(1)) + + type IntList = List + fun accept(xs: IntList) { } + accept([1,2,3]) + """.trimIndent()) + } + @Test fun multipleReceivers() = runTest { eval(""" diff --git a/notes/new_lyng_type_system_spec.md b/notes/new_lyng_type_system_spec.md index be6f4e9..277abdb 100644 --- a/notes/new_lyng_type_system_spec.md +++ b/notes/new_lyng_type_system_spec.md @@ -106,6 +106,11 @@ 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 +Type aliases: +- `type Name = TypeExpr` +- can be generic: `type Maybe = T?` +- aliases expand to their underlying type expressions (no nominal distinctness). + Instead of template expansions, we might provide explicit `inline` later. Notes and open questions to answer in this spec: