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 } } }