From 7a972107f3b876c3c0569e7151b9e237a64bad90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=B0=AC=EC=98=81?= Date: Fri, 15 May 2026 10:52:23 +0900 Subject: [PATCH] feat: support typed list deconstruction assignments --- .../dev/jetpack/engine/parser/Parser.kt | 125 +++++++++++------- .../jetpack/engine/parser/ast/Statement.kt | 18 +-- .../jetpack/engine/resolver/NameResolver.kt | 19 ++- .../jetpack/engine/resolver/TypeChecker.kt | 59 ++++++--- .../dev/jetpack/engine/runtime/Interpreter.kt | 35 ++--- .../dev/jetpack/script/ScriptModuleFactory.kt | 20 +-- .../kotlin/dev/jetpack/script/ScriptRunner.kt | 7 +- 7 files changed, 163 insertions(+), 120 deletions(-) diff --git a/src/main/kotlin/dev/jetpack/engine/parser/Parser.kt b/src/main/kotlin/dev/jetpack/engine/parser/Parser.kt index c5816e5..e358a39 100644 --- a/src/main/kotlin/dev/jetpack/engine/parser/Parser.kt +++ b/src/main/kotlin/dev/jetpack/engine/parser/Parser.kt @@ -258,8 +258,15 @@ class Parser(private val tokens: List) { TokenType.KW_COMMAND -> parseCommandDecl(access ?: AccessModifier.PRIVATE, line, isRoot = true) TokenType.KW_CONST -> { advance() - if (peekType() == TokenType.LBRACE || peekType() == TokenType.LBRACKET) { - parseDestructuring(access ?: AccessModifier.PRIVATE, isConst = true, line) + if (peekType() == TokenType.LPAREN) { + parseDeconstruction( + access = access ?: AccessModifier.PRIVATE, + isConst = true, + isDeclaration = true, + allowTypes = true, + requireTypes = false, + line = line, + ) } else if (!isTypeKeyword(peekType())) { throw ParseException("Const can only be used with variable declarations", line) } else { @@ -267,8 +274,29 @@ class Parser(private val tokens: List) { } } else -> { - if ((peekType() == TokenType.LBRACE || peekType() == TokenType.LBRACKET) && isDestructuringAhead()) { - parseDestructuring(access ?: AccessModifier.PRIVATE, isConst = false, line) + if (peekType() == TokenType.KW_VAR && peekType(1) == TokenType.LPAREN) { + advance() + parseDeconstruction( + access = access ?: AccessModifier.PRIVATE, + isConst = false, + isDeclaration = true, + allowTypes = false, + requireTypes = false, + line = line, + ) + } else if (peekType() == TokenType.LPAREN && isDeconstructionAhead()) { + val isDeclaration = isTypedDeconstructionPattern() + if (access != null && !isDeclaration) { + throw ParseException("Access modifier can only be used with declarations", line) + } + parseDeconstruction( + access = access ?: AccessModifier.PRIVATE, + isConst = false, + isDeclaration = isDeclaration, + allowTypes = isDeclaration, + requireTypes = isDeclaration, + line = line, + ) } else if (access != null && !isTypeKeyword(peekType())) { throw ParseException("Access modifier can only be used with top-level declarations", line) } else if (isTypeKeyword(peekType())) { @@ -1090,16 +1118,14 @@ class Parser(private val tokens: List) { return Expression.ObjectLiteral(entries, line) } - private fun isDestructuringAhead(): Boolean { - val openType = peekType() - if (openType != TokenType.LBRACE && openType != TokenType.LBRACKET) return false - val closeType = if (openType == TokenType.LBRACE) TokenType.RBRACE else TokenType.RBRACKET + private fun isDeconstructionAhead(): Boolean { + if (peekType() != TokenType.LPAREN) return false var i = pos + 1 var depth = 0 while (i < tokens.size) { when (tokens[i].type) { - openType -> depth++ - closeType -> { + TokenType.LPAREN -> depth++ + TokenType.RPAREN -> { if (depth == 0) { i++ while (i < tokens.size && (tokens[i].type == TokenType.NEWLINE || tokens[i].type == TokenType.SEMICOLON)) i++ @@ -1115,50 +1141,59 @@ class Parser(private val tokens: List) { return false } - private fun parseDestructuring(access: AccessModifier, isConst: Boolean, line: Int): Statement = - when (peekType()) { - TokenType.LBRACE -> parseObjectDestructuring(access, isConst, line) - TokenType.LBRACKET -> parseListDestructuring(access, isConst, line) - else -> throw ParseException("Expected '{' or '[' for destructuring", line) - } + private fun isTypedDeconstructionPattern(): Boolean { + if (peekType() != TokenType.LPAREN) return false + var i = pos + 1 + while (i < tokens.size && tokens[i].type in setOf(TokenType.NEWLINE, TokenType.SEMICOLON)) i++ + return i < tokens.size && isTypeKeyword(tokens[i].type) + } - private fun parseObjectDestructuring(access: AccessModifier, isConst: Boolean, line: Int): Statement.ObjectDestructuring { - expect(TokenType.LBRACE, "Expected '{'") + private fun parseDeconstruction( + access: AccessModifier, + isConst: Boolean, + isDeclaration: Boolean, + allowTypes: Boolean, + requireTypes: Boolean, + line: Int, + ): Statement.Deconstruction { + expect(TokenType.LPAREN, "Expected '('") skipNewlines() - val bindings = mutableListOf() - while (!check(TokenType.RBRACE) && !isAtEnd()) { - val fieldName = expect(TokenType.IDENTIFIER, "Expected field name in object destructuring").value - val localName = if (check(TokenType.COLON)) { + val bindings = mutableListOf() + while (!check(TokenType.RPAREN) && !isAtEnd()) { + bindings += parseDeconstructionBinding(isDeclaration, allowTypes, requireTypes) + skipNewlines() + if (check(TokenType.COMMA)) { advance() - expect(TokenType.IDENTIFIER, "Expected local variable name after ':' in object destructuring").value + skipNewlines() } else { - fieldName + break } - bindings.add(DestructuringBinding(fieldName, localName)) - skipNewlines() - if (check(TokenType.COMMA)) { advance(); skipNewlines() } } - expect(TokenType.RBRACE, "Expected '}' to close object destructuring") - expect(TokenType.EQ, "Expected '=' after object destructuring pattern") + expect(TokenType.RPAREN, "Expected ')' to close deconstruction pattern") + expect(TokenType.EQ, "Expected '=' after deconstruction pattern") val initializer = parseExpression() - return Statement.ObjectDestructuring(access, isConst, bindings, initializer, line) + return Statement.Deconstruction(access, isConst, isDeclaration, bindings, initializer, line) } - private fun parseListDestructuring(access: AccessModifier, isConst: Boolean, line: Int): Statement.ListDestructuring { - expect(TokenType.LBRACKET, "Expected '['") - skipNewlines() - val bindings = mutableListOf() - while (!check(TokenType.RBRACKET) && !isAtEnd()) { - bindings.add( - if (check(TokenType.IDENTIFIER) && peek().value == "_") { advance(); null } - else expect(TokenType.IDENTIFIER, "Expected variable name or '_' in list destructuring").value - ) - skipNewlines() - if (check(TokenType.COMMA)) { advance(); skipNewlines() } + private fun parseDeconstructionBinding( + isDeclaration: Boolean, + allowTypes: Boolean, + requireTypes: Boolean, + ): DeconstructionBinding { + if (check(TokenType.IDENTIFIER) && peek().value == "_") { + advance() + return DeconstructionBinding(null, null) } - expect(TokenType.RBRACKET, "Expected ']' to close list destructuring") - expect(TokenType.EQ, "Expected '=' after list destructuring pattern") - val initializer = parseExpression() - return Statement.ListDestructuring(access, isConst, bindings, initializer, line) + if (!allowTypes && isTypeKeyword(peekType())) { + throw ParseException("Deconstruction declared with 'var' cannot specify item types", peek().line) + } + if (requireTypes && !isTypeKeyword(peekType())) { + throw ParseException("Expected typed deconstruction item", peek().line) + } + val typeRef = if (isDeclaration && allowTypes && isTypeKeyword(peekType())) { + parseTypeRef() + } else null + val name = expect(TokenType.IDENTIFIER, "Expected variable name or '_' in deconstruction pattern").value + return DeconstructionBinding(name, typeRef) } } diff --git a/src/main/kotlin/dev/jetpack/engine/parser/ast/Statement.kt b/src/main/kotlin/dev/jetpack/engine/parser/ast/Statement.kt index f09fb53..717281e 100644 --- a/src/main/kotlin/dev/jetpack/engine/parser/ast/Statement.kt +++ b/src/main/kotlin/dev/jetpack/engine/parser/ast/Statement.kt @@ -84,17 +84,11 @@ sealed class Statement { val annotations: CommandAnnotations, override val line: Int, ) : Statement() - data class ObjectDestructuring( + data class Deconstruction( val access: AccessModifier, val isConst: Boolean, - val bindings: List, - val initializer: Expression, - override val line: Int, - ) : Statement() - data class ListDestructuring( - val access: AccessModifier, - val isConst: Boolean, - val bindings: List, + val isDeclaration: Boolean, + val bindings: List, val initializer: Expression, override val line: Int, ) : Statement() @@ -146,9 +140,9 @@ data class TypeRef( val typeArgRef: TypeRef? = null, ) -data class DestructuringBinding( - val fieldName: String, - val localName: String, +data class DeconstructionBinding( + val name: String?, + val typeName: TypeRef?, ) enum class AccessModifier { PUBLIC, PRIVATE, PROTECTED } diff --git a/src/main/kotlin/dev/jetpack/engine/resolver/NameResolver.kt b/src/main/kotlin/dev/jetpack/engine/resolver/NameResolver.kt index 0f3387d..359da0f 100644 --- a/src/main/kotlin/dev/jetpack/engine/resolver/NameResolver.kt +++ b/src/main/kotlin/dev/jetpack/engine/resolver/NameResolver.kt @@ -209,14 +209,16 @@ class NameResolver(private val reservedNames: Set = emptySet()) { isFileScope = prevFile } - is Statement.ObjectDestructuring -> { + is Statement.Deconstruction -> { resolveExpr(stmt.initializer) - for (binding in stmt.bindings) declare(binding.localName, stmt.line) - } - - is Statement.ListDestructuring -> { - resolveExpr(stmt.initializer) - for (name in stmt.bindings) if (name != null) declare(name, stmt.line) + for (binding in stmt.bindings) { + val name = binding.name ?: continue + if (stmt.isDeclaration) { + declare(name, stmt.line) + } else if (!isDeclared(name)) { + error("Undefined identifier '$name'", stmt.line) + } + } } } } @@ -347,6 +349,9 @@ class NameResolver(private val reservedNames: Set = emptySet()) { current[name] = line } + private fun isDeclared(name: String): Boolean = + scopes.any { name in it } + private fun error(message: String, line: Int) { errors.add(ResolverError(message, line)) } diff --git a/src/main/kotlin/dev/jetpack/engine/resolver/TypeChecker.kt b/src/main/kotlin/dev/jetpack/engine/resolver/TypeChecker.kt index a150576..d4c41e0 100644 --- a/src/main/kotlin/dev/jetpack/engine/resolver/TypeChecker.kt +++ b/src/main/kotlin/dev/jetpack/engine/resolver/TypeChecker.kt @@ -238,24 +238,52 @@ class TypeChecker(private val typeProvider: BuiltinTypeProvider? = null) { is Statement.BreakStmt, is Statement.ContinueStmt -> Unit - is Statement.ObjectDestructuring -> { - val initType = inferExpr(stmt.initializer) - if (initType != JetType.TObject && initType != JetType.TUnknown) { - errors.add(TypeCheckerError("Object destructuring requires an object, got '$initType'", stmt.line)) - } - for (binding in stmt.bindings) { - defineType(binding.localName, JetType.TUnknown, stmt.line, stmt.isConst) + is Statement.Deconstruction -> { + val positionalTypes = (stmt.initializer as? Expression.ListLiteral)?.elements?.map(::inferExpr) + val initType = if (positionalTypes != null) { + JetType.TList(if (positionalTypes.isEmpty()) JetType.TUnknown else commonSupertype(positionalTypes)) + } else { + inferExpr(stmt.initializer) } - } - - is Statement.ListDestructuring -> { - val initType = inferExpr(stmt.initializer) if (initType !is JetType.TList && initType != JetType.TUnknown) { - errors.add(TypeCheckerError("List destructuring requires a list, got '$initType'", stmt.line)) + errors.add(TypeCheckerError("Deconstruction requires a list, got '$initType'", stmt.line)) } val elementType = if (initType is JetType.TList) initType.elementType else JetType.TUnknown - for (name in stmt.bindings) { - if (name != null) defineType(name, elementType, stmt.line, stmt.isConst) + for ((index, binding) in stmt.bindings.withIndex()) { + val name = binding.name ?: continue + val bindingElementType = positionalTypes?.getOrNull(index) ?: elementType + if (positionalTypes != null && index >= positionalTypes.size) { + errors.add(TypeCheckerError("Deconstruction index $index is out of range", stmt.line)) + } + val declaredType = binding.typeName?.let { + resolveTypeRef(it, stmt.line, "Deconstruction variable '$name'") + } + if (declaredType != null && + declaredType != JetType.TUnknown && + bindingElementType != JetType.TUnknown && + !declaredType.accepts(bindingElementType) + ) { + errors.add(TypeCheckerError( + "Deconstruction variable '$name' has type '$declaredType' but list yields '$bindingElementType'", + stmt.line, + )) + } + if (stmt.isDeclaration) { + defineType(name, declaredType ?: bindingElementType, stmt.line, stmt.isConst) + } else { + val targetType = lookupType(name) ?: JetType.TUnknown + when { + isConst(name) -> errors.add(TypeCheckerError("Cannot reassign const variable '$name'", stmt.line)) + isReadOnly(name) -> errors.add(TypeCheckerError("Cannot reassign foreach item '$name'", stmt.line)) + targetType != JetType.TUnknown && + bindingElementType != JetType.TUnknown && + !targetType.accepts(bindingElementType) -> + errors.add(TypeCheckerError( + "Cannot assign list element of type '$bindingElementType' to variable '$name' of type '$targetType'", + stmt.line, + )) + } + } } } @@ -1132,8 +1160,7 @@ class TypeChecker(private val typeProvider: BuiltinTypeProvider? = null) { is Statement.IntervalDecl, is Statement.ListenerDecl, is Statement.CommandDecl, - is Statement.ObjectDestructuring, - is Statement.ListDestructuring -> setOf(FlowSignal.FALLTHROUGH) + is Statement.Deconstruction -> setOf(FlowSignal.FALLTHROUGH) } private fun analyzeTryFlow(stmt: Statement.TryStmt): Set { diff --git a/src/main/kotlin/dev/jetpack/engine/runtime/Interpreter.kt b/src/main/kotlin/dev/jetpack/engine/runtime/Interpreter.kt index 9935dfe..fd4613c 100644 --- a/src/main/kotlin/dev/jetpack/engine/runtime/Interpreter.kt +++ b/src/main/kotlin/dev/jetpack/engine/runtime/Interpreter.kt @@ -296,38 +296,27 @@ class Interpreter( } is Statement.BreakStmt -> throw BreakSignal() is Statement.ContinueStmt -> throw ContinueSignal() - is Statement.ObjectDestructuring -> { - val obj = evalExpr(stmt.initializer, scope) - if (obj !is JObject) throw RuntimeError( - "Object destructuring requires an object, got '${obj.typeName()}'", - stmt.line, "TypeException", - ) - for (binding in stmt.bindings) { - val value = obj.getField(binding.fieldName) - ?: throw RuntimeError( - "Object field '${binding.fieldName}' does not exist", - stmt.line, "KeyException", - ) - withScopeRuntimeError(stmt.line) { - scope.defineCoerced(binding.localName, value, stmt.isConst, null) - } - } - } - is Statement.ListDestructuring -> { + is Statement.Deconstruction -> { val list = evalExpr(stmt.initializer, scope) if (list !is JList) throw RuntimeError( - "List destructuring requires a list, got '${list.typeName()}'", + "Deconstruction requires a list, got '${list.typeName()}'", stmt.line, "TypeException", ) - for ((index, name) in stmt.bindings.withIndex()) { - if (name == null) continue + for ((index, binding) in stmt.bindings.withIndex()) { + val name = binding.name ?: continue val value = list.elements.getOrNull(index) ?: throw RuntimeError( - "List destructuring index $index is out of range (list has ${list.elements.size} elements)", + "Deconstruction index $index is out of range (list has ${list.elements.size} elements)", stmt.line, "IndexException", ) + val declaredType = binding.typeName?.toJetTypeOrNull() + val coerced = coerceValueToType(value, declaredType) withScopeRuntimeError(stmt.line) { - scope.defineCoerced(name, value, stmt.isConst, null) + if (stmt.isDeclaration) { + scope.defineCoerced(name, coerced, stmt.isConst, declaredType) + } else { + scope.set(name, coerced) + } } } } diff --git a/src/main/kotlin/dev/jetpack/script/ScriptModuleFactory.kt b/src/main/kotlin/dev/jetpack/script/ScriptModuleFactory.kt index cce2790..a920a26 100644 --- a/src/main/kotlin/dev/jetpack/script/ScriptModuleFactory.kt +++ b/src/main/kotlin/dev/jetpack/script/ScriptModuleFactory.kt @@ -79,24 +79,14 @@ internal class ScriptModuleFactory(private val scriptsRoot: File) { availableAfterDeclaration = true, ) } - is Statement.ObjectDestructuring -> { - for (binding in stmt.bindings) { - exports[binding.localName] = ModuleExportDefinition( - name = binding.localName, - access = stmt.access, - type = JetType.TUnknown, - isReadOnly = stmt.isConst || stmt.access == AccessModifier.PROTECTED, - availableAfterDeclaration = false, - ) - } - } - is Statement.ListDestructuring -> { - for (name in stmt.bindings) { - if (name != null) { + is Statement.Deconstruction -> { + if (stmt.isDeclaration) { + for (binding in stmt.bindings) { + val name = binding.name ?: continue exports[name] = ModuleExportDefinition( name = name, access = stmt.access, - type = JetType.TUnknown, + type = binding.typeName?.toJetType() ?: JetType.TUnknown, isReadOnly = stmt.isConst || stmt.access == AccessModifier.PROTECTED, availableAfterDeclaration = false, ) diff --git a/src/main/kotlin/dev/jetpack/script/ScriptRunner.kt b/src/main/kotlin/dev/jetpack/script/ScriptRunner.kt index 838337f..d5b9729 100644 --- a/src/main/kotlin/dev/jetpack/script/ScriptRunner.kt +++ b/src/main/kotlin/dev/jetpack/script/ScriptRunner.kt @@ -203,8 +203,11 @@ class ScriptRunner(private val plugin: JetpackPlugin) { interpreter.executeStmt(stmt, scope) when (stmt) { is Statement.VarDecl -> module.initializedExports += stmt.name - is Statement.ObjectDestructuring -> module.initializedExports += stmt.bindings.map { it.localName } - is Statement.ListDestructuring -> module.initializedExports += stmt.bindings.filterNotNull() + is Statement.Deconstruction -> { + if (stmt.isDeclaration) { + module.initializedExports += stmt.bindings.mapNotNull { it.name } + } + } else -> Unit } }