From ee25c0c5f4162ebd1338d9d76ce995149892f0ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=B0=AC=EC=98=81?= Date: Tue, 12 May 2026 11:19:52 +0900 Subject: [PATCH] feat: add destructuring declarations Support object and list destructuring across parsing, resolution, type checking, runtime execution, and script module exports. Also adds membership checks with the in operator for lists, objects, and strings. --- .../dev/jetpack/engine/parser/Parser.kt | 88 +++++++++++++++++-- .../jetpack/engine/parser/ast/Statement.kt | 19 ++++ .../jetpack/engine/resolver/NameResolver.kt | 10 +++ .../jetpack/engine/resolver/TypeChecker.kt | 47 +++++++++- .../dev/jetpack/engine/runtime/Interpreter.kt | 61 +++++++++++++ .../dev/jetpack/script/ScriptModuleFactory.kt | 24 +++++ .../kotlin/dev/jetpack/script/ScriptRunner.kt | 7 +- 7 files changed, 247 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/dev/jetpack/engine/parser/Parser.kt b/src/main/kotlin/dev/jetpack/engine/parser/Parser.kt index 81d2c70..ab22ba4 100644 --- a/src/main/kotlin/dev/jetpack/engine/parser/Parser.kt +++ b/src/main/kotlin/dev/jetpack/engine/parser/Parser.kt @@ -237,16 +237,20 @@ class Parser(private val tokens: List) { TokenType.KW_COMMAND -> parseCommandDecl(access ?: AccessModifier.PRIVATE, line, isRoot = true) TokenType.KW_CONST -> { advance() - if (!isTypeKeyword(peekType())) { + if (peekType() == TokenType.LBRACE || peekType() == TokenType.LBRACKET) { + parseDestructuring(access ?: AccessModifier.PRIVATE, isConst = true, line) + } else if (!isTypeKeyword(peekType())) { throw ParseException("Const can only be used with variable declarations", line) + } else { + parseVarDecl(access ?: AccessModifier.PRIVATE, isConst = true, line) } - parseVarDecl(access ?: AccessModifier.PRIVATE, isConst = true, line) } else -> { - if (access != null && !isTypeKeyword(peekType())) { + if ((peekType() == TokenType.LBRACE || peekType() == TokenType.LBRACKET) && isDestructuringAhead()) { + parseDestructuring(access ?: AccessModifier.PRIVATE, isConst = false, line) + } else if (access != null && !isTypeKeyword(peekType())) { throw ParseException("Access modifier can only be used with top-level declarations", line) - } - if (isTypeKeyword(peekType())) { + } else if (isTypeKeyword(peekType())) { parseVarDecl(access ?: AccessModifier.PRIVATE, isConst = false, line) } else { if (access != null) { @@ -697,7 +701,7 @@ class Parser(private val tokens: List) { private fun parseComparison(): Expression { var left = parseRange() - while (peekType() in COMPARISON_OPS) { + while (peekType() in COMPARISON_OPS || peekType() == TokenType.KW_IN) { val op = advance() left = Expression.BinaryOp(left, op, parseRange(), op.line) } @@ -1063,4 +1067,76 @@ class Parser(private val tokens: List) { expect(TokenType.RBRACE, "Expected '}' to close object literal") 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 + var i = pos + 1 + var depth = 0 + while (i < tokens.size) { + when (tokens[i].type) { + openType -> depth++ + closeType -> { + if (depth == 0) { + i++ + while (i < tokens.size && (tokens[i].type == TokenType.NEWLINE || tokens[i].type == TokenType.SEMICOLON)) i++ + return i < tokens.size && tokens[i].type == TokenType.EQ + } + depth-- + } + TokenType.EOF -> return false + else -> {} + } + i++ + } + 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 parseObjectDestructuring(access: AccessModifier, isConst: Boolean, line: Int): Statement.ObjectDestructuring { + expect(TokenType.LBRACE, "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)) { + advance() + expect(TokenType.IDENTIFIER, "Expected local variable name after ':' in object destructuring").value + } else { + fieldName + } + 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") + val initializer = parseExpression() + return Statement.ObjectDestructuring(access, isConst, 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() } + } + 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) + } } 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 02e6978..294ec67 100644 --- a/src/main/kotlin/dev/jetpack/engine/parser/ast/Statement.kt +++ b/src/main/kotlin/dev/jetpack/engine/parser/ast/Statement.kt @@ -83,6 +83,20 @@ sealed class Statement { val annotations: CommandAnnotations, override val line: Int, ) : Statement() + data class ObjectDestructuring( + 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 initializer: Expression, + override val line: Int, + ) : Statement() } data class CommandAnnotations( @@ -122,4 +136,9 @@ data class TypeRef( val typeArgRef: TypeRef? = null, ) +data class DestructuringBinding( + val fieldName: String, + val localName: String, +) + 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 cf9ca5a..e3dab23 100644 --- a/src/main/kotlin/dev/jetpack/engine/resolver/NameResolver.kt +++ b/src/main/kotlin/dev/jetpack/engine/resolver/NameResolver.kt @@ -202,6 +202,16 @@ class NameResolver(private val reservedNames: Set = emptySet()) { insideLoop = prevLoop isFileScope = prevFile } + + is Statement.ObjectDestructuring -> { + 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) + } } } diff --git a/src/main/kotlin/dev/jetpack/engine/resolver/TypeChecker.kt b/src/main/kotlin/dev/jetpack/engine/resolver/TypeChecker.kt index 49a7baa..a150576 100644 --- a/src/main/kotlin/dev/jetpack/engine/resolver/TypeChecker.kt +++ b/src/main/kotlin/dev/jetpack/engine/resolver/TypeChecker.kt @@ -238,6 +238,27 @@ 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.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)) + } + 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) + } + } + is Statement.CommandDecl -> checkCommandDecl(stmt, stmt.senderName) } } @@ -675,6 +696,28 @@ class TypeChecker(private val typeProvider: BuiltinTypeProvider? = null) { left == JetType.TInt && right == JetType.TInt -> JetType.TInt else -> JetType.TFloat } + op == TokenType.KW_IN -> { + if (isNullableType(left)) { + errors.add(TypeCheckerError("Operator 'in' cannot be applied to nullable type '$left'", expr.line)) + } + when { + right is JetType.TList -> Unit + right == JetType.TObject -> { + if (left != JetType.TString && left != JetType.TUnknown) { + errors.add(TypeCheckerError("'in' on object requires a string operand, got '$left'", expr.line)) + } + } + right == JetType.TString -> { + if (left != JetType.TString && left != JetType.TUnknown) { + errors.add(TypeCheckerError("'in' on string requires a string operand, got '$left'", expr.line)) + } + } + right != JetType.TUnknown -> { + errors.add(TypeCheckerError("Operator 'in' cannot be applied to type '$right'", expr.line)) + } + } + JetType.TBool + } isNullableType(left) || isNullableType(right) -> { val nullableType = if (isNullableType(left)) left else right errors.add(TypeCheckerError( @@ -1088,7 +1131,9 @@ class TypeChecker(private val typeProvider: BuiltinTypeProvider? = null) { is Statement.FunctionDecl, is Statement.IntervalDecl, is Statement.ListenerDecl, - is Statement.CommandDecl -> setOf(FlowSignal.FALLTHROUGH) + is Statement.CommandDecl, + is Statement.ObjectDestructuring, + is Statement.ListDestructuring -> 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 24eccd5..0513a47 100644 --- a/src/main/kotlin/dev/jetpack/engine/runtime/Interpreter.kt +++ b/src/main/kotlin/dev/jetpack/engine/runtime/Interpreter.kt @@ -290,6 +290,41 @@ 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 -> { + val list = evalExpr(stmt.initializer, scope) + if (list !is JList) throw RuntimeError( + "List destructuring requires a list, got '${list.typeName()}'", + stmt.line, "TypeException", + ) + for ((index, name) in stmt.bindings.withIndex()) { + if (name == null) continue + val value = list.elements.getOrNull(index) + ?: throw RuntimeError( + "List destructuring index $index is out of range (list has ${list.elements.size} elements)", + stmt.line, "IndexException", + ) + withScopeRuntimeError(stmt.line) { + scope.defineCoerced(name, value, stmt.isConst, null) + } + } + } is Statement.Metadata, is Statement.Using, is Statement.Manifest, is Statement.CommandDecl -> Unit } } @@ -778,6 +813,32 @@ class Interpreter( JString(right.value.repeat(count)) } left.isNumeric() && right.isNumeric() -> evalNumericOp(left, right, op, expr.line) + op == TokenType.KW_IN -> when (right) { + is JList -> JBool(right.elements.any { jetEquals(left, it) }) + is JObject -> { + val key = (left as? JString) + ?: throw RuntimeError( + "'in' on object requires a string key, got '${left.typeName()}'", + expr.line, + "TypeException", + ) + JBool(right.hasField(key.value)) + } + is JString -> { + val sub = (left as? JString) + ?: throw RuntimeError( + "'in' on string requires a string value, got '${left.typeName()}'", + expr.line, + "TypeException", + ) + JBool(right.value.contains(sub.value)) + } + else -> throw RuntimeError( + "Operator 'in' cannot be applied to type '${right.typeName()}'", + expr.line, + "TypeException", + ) + } else -> throw RuntimeError( "Operator '${expr.operator.value}' cannot be applied to types '${left.typeName()}' and '${right.typeName()}'", expr.line, diff --git a/src/main/kotlin/dev/jetpack/script/ScriptModuleFactory.kt b/src/main/kotlin/dev/jetpack/script/ScriptModuleFactory.kt index fd0ab46..cce2790 100644 --- a/src/main/kotlin/dev/jetpack/script/ScriptModuleFactory.kt +++ b/src/main/kotlin/dev/jetpack/script/ScriptModuleFactory.kt @@ -79,6 +79,30 @@ 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) { + exports[name] = ModuleExportDefinition( + name = name, + access = stmt.access, + type = JetType.TUnknown, + isReadOnly = stmt.isConst || stmt.access == AccessModifier.PROTECTED, + availableAfterDeclaration = false, + ) + } + } + } else -> Unit } } diff --git a/src/main/kotlin/dev/jetpack/script/ScriptRunner.kt b/src/main/kotlin/dev/jetpack/script/ScriptRunner.kt index 6357913..16d9826 100644 --- a/src/main/kotlin/dev/jetpack/script/ScriptRunner.kt +++ b/src/main/kotlin/dev/jetpack/script/ScriptRunner.kt @@ -200,8 +200,11 @@ class ScriptRunner(private val plugin: JetpackPlugin) { is Statement.CommandDecl -> Unit else -> { interpreter.executeStmt(stmt, scope) - if (stmt is Statement.VarDecl) { - module.initializedExports += stmt.name + 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() + else -> Unit } } }