map literals
This commit is contained in:
1 parent
cb9df79ce3
commit
d118d29429
16 files changed
+669
-78
No files matched your search
@@ -572,7 +572,15 @@ class Compiler(
|
||||
blockArgument = true,
|
||||
isOptional = t.type == Token.Type.NULL_COALESCE_BLOCKINVOKE
|
||||
)
|
||||
} ?: parseLambdaExpression()
|
||||
} ?: run {
|
||||
// Disambiguate between lambda and map literal.
|
||||
// Heuristic: if there is a top-level '->' before the closing '}', it's a lambda.
|
||||
// Otherwise, try to parse a map literal; if it fails, fall back to lambda.
|
||||
val isLambda = hasTopLevelArrowBeforeRbrace()
|
||||
if (!isLambda) {
|
||||
parseMapLiteralOrNull() ?: parseLambdaExpression()
|
||||
} else parseLambdaExpression()
|
||||
}
|
||||
}
|
||||
|
||||
Token.Type.RBRACKET, Token.Type.RPAREN -> {
|
||||
@@ -674,6 +682,115 @@ class Compiler(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look ahead from current position (right after a leading '{') to find a top-level '->' before the matching '}'.
|
||||
* Returns true if such arrow is found, meaning the construct should be parsed as a lambda.
|
||||
* The scanner respects nested braces depth; only depth==1 arrows count.
|
||||
* The current cursor is restored on exit.
|
||||
*/
|
||||
private fun hasTopLevelArrowBeforeRbrace(): Boolean {
|
||||
val start = cc.savePos()
|
||||
var depth = 1
|
||||
var found = false
|
||||
while (cc.hasNext()) {
|
||||
val t = cc.next()
|
||||
when (t.type) {
|
||||
Token.Type.LBRACE -> depth++
|
||||
Token.Type.RBRACE -> {
|
||||
depth--
|
||||
if (depth == 0) break
|
||||
}
|
||||
Token.Type.ARROW -> if (depth == 1) {
|
||||
found = true
|
||||
// Do not break; we still restore position below
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
cc.restorePos(start)
|
||||
return found
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to parse a map literal starting at the position after '{'.
|
||||
* Returns null if the sequence does not look like a map literal (e.g., empty or first token is not STRING/ID/ELLIPSIS),
|
||||
* in which case caller should treat it as a lambda/block.
|
||||
* When it recognizes a map literal, it commits and throws on syntax errors.
|
||||
*/
|
||||
private suspend fun parseMapLiteralOrNull(): ObjRef? {
|
||||
val startAfterLbrace = cc.savePos()
|
||||
// Peek first non-ws token to decide whether it's likely a map literal
|
||||
val first = cc.peekNextNonWhitespace()
|
||||
// Empty {} should NOT be taken as a map literal to preserve block/lambda semantics
|
||||
if (first.type == Token.Type.RBRACE) return null
|
||||
if (first.type !in listOf(Token.Type.STRING, Token.Type.ID, Token.Type.ELLIPSIS)) return null
|
||||
|
||||
// Commit to map literal parsing
|
||||
cc.skipWsTokens()
|
||||
val entries = mutableListOf<net.sergeych.lyng.obj.MapLiteralEntry>()
|
||||
val usedKeys = mutableSetOf<String>()
|
||||
|
||||
while (true) {
|
||||
// Skip whitespace/comments/newlines between entries
|
||||
val t0 = cc.nextNonWhitespace()
|
||||
when (t0.type) {
|
||||
Token.Type.RBRACE -> {
|
||||
// end of map literal
|
||||
return net.sergeych.lyng.obj.MapLiteralRef(entries)
|
||||
}
|
||||
Token.Type.COMMA -> {
|
||||
// allow stray commas; continue
|
||||
continue
|
||||
}
|
||||
Token.Type.ELLIPSIS -> {
|
||||
// spread element: ... expression
|
||||
val expr = parseExpressionLevel() ?: throw ScriptError(t0.pos, "invalid map spread: expecting expression")
|
||||
entries += net.sergeych.lyng.obj.MapLiteralEntry.Spread(expr)
|
||||
// Expect comma or '}' next; loop will handle
|
||||
}
|
||||
Token.Type.STRING, Token.Type.ID -> {
|
||||
val isIdKey = t0.type == Token.Type.ID
|
||||
val keyName = if (isIdKey) t0.value else t0.value
|
||||
// After key we require ':'
|
||||
cc.skipWsTokens()
|
||||
val colon = cc.next()
|
||||
if (colon.type != Token.Type.COLON) {
|
||||
// Not a map literal after all; backtrack and signal null
|
||||
cc.restorePos(startAfterLbrace)
|
||||
return null
|
||||
}
|
||||
// Check for shorthand (only for id-keys): if next non-ws is ',' or '}'
|
||||
cc.skipWsTokens()
|
||||
val next = cc.next()
|
||||
if ((next.type == Token.Type.COMMA || next.type == Token.Type.RBRACE)) {
|
||||
if (!isIdKey) throw ScriptError(next.pos, "missing value after string-literal key '$keyName'")
|
||||
// id: shorthand; value is the variable with the same name
|
||||
// rewind one step if RBRACE so outer loop can handle it
|
||||
if (next.type == Token.Type.RBRACE) cc.previous()
|
||||
// Duplicate detection for literals only
|
||||
if (!usedKeys.add(keyName)) throw ScriptError(t0.pos, "duplicate key '$keyName'")
|
||||
entries += net.sergeych.lyng.obj.MapLiteralEntry.Named(keyName, net.sergeych.lyng.obj.LocalVarRef(keyName, t0.pos))
|
||||
// If the token was COMMA, the loop continues; if it's RBRACE, next iteration will end
|
||||
} else {
|
||||
// There is a value expression: push back token and parse expression
|
||||
cc.previous()
|
||||
val valueRef = parseExpressionLevel() ?: throw ScriptError(colon.pos, "expecting map entry value")
|
||||
if (!usedKeys.add(keyName)) throw ScriptError(t0.pos, "duplicate key '$keyName'")
|
||||
entries += net.sergeych.lyng.obj.MapLiteralEntry.Named(keyName, valueRef)
|
||||
// After value, allow optional comma; do not require it
|
||||
cc.skipTokenOfType(Token.Type.COMMA, isOptional = true)
|
||||
// The loop will continue and eventually see '}'
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// Not a map literal; backtrack and let caller treat as lambda
|
||||
cc.restorePos(startAfterLbrace)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse argument declaration, used in lambda (and later in fn too)
|
||||
* @return declaration or null if there is no valid list of arguments
|
||||
|
||||
@@ -75,6 +75,12 @@ class ObjMapEntry(val key: Obj, val value: Obj) : Obj() {
|
||||
addFn("size") { 2.toObj() }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun plus(scope: Scope, other: Obj): Obj {
|
||||
// Build a new map starting from this entry, then merge `other`.
|
||||
val result = ObjMap(mutableMapOf(key to value))
|
||||
return result.plus(scope, other)
|
||||
}
|
||||
}
|
||||
|
||||
class ObjMap(val map: MutableMap<Obj, Obj> = mutableMapOf()) : Obj() {
|
||||
@@ -96,7 +102,22 @@ class ObjMap(val map: MutableMap<Obj, Obj> = mutableMapOf()) : Obj() {
|
||||
if( other is ObjMap && other.map == map) return 0
|
||||
return -1
|
||||
}
|
||||
override fun toString(): String = map.toString()
|
||||
|
||||
override suspend fun toString(scope: Scope, calledFromLyng: Boolean): ObjString {
|
||||
val reusult = buildString {
|
||||
append("Map(")
|
||||
var first = true
|
||||
for( (k,v) in map) {
|
||||
if( !first ) append(",")
|
||||
append(k.inspect(scope))
|
||||
append(" => ")
|
||||
append(v.toString(scope).value)
|
||||
first = false
|
||||
}
|
||||
append(")")
|
||||
}
|
||||
return ObjString(reusult)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return map.hashCode()
|
||||
@@ -187,4 +208,44 @@ class ObjMap(val map: MutableMap<Obj, Obj> = mutableMapOf()) : Obj() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge operations
|
||||
override suspend fun plus(scope: Scope, other: Obj): Obj {
|
||||
val result = ObjMap(map.toMutableMap())
|
||||
result.mergeIn(scope, other)
|
||||
return result
|
||||
}
|
||||
|
||||
override suspend fun plusAssign(scope: Scope, other: Obj): Obj {
|
||||
mergeIn(scope, other)
|
||||
return this
|
||||
}
|
||||
|
||||
private suspend fun mergeIn(scope: Scope, other: Obj) {
|
||||
when (other) {
|
||||
is ObjMap -> {
|
||||
// Rightmost wins: copy all entries from `other` over existing ones
|
||||
for ((k, v) in other.map) {
|
||||
val key = k as? ObjString ?: scope.raiseIllegalArgument("map merge expects string keys; got $k")
|
||||
map[key] = v
|
||||
}
|
||||
}
|
||||
is ObjMapEntry -> {
|
||||
val key = other.key as? ObjString ?: scope.raiseIllegalArgument("map merge expects string keys; got ${other.key}")
|
||||
map[key] = other.value
|
||||
}
|
||||
is ObjList -> {
|
||||
// Treat as list of map entries
|
||||
for (e in other.list) {
|
||||
val entry = when (e) {
|
||||
is ObjMapEntry -> e
|
||||
else -> scope.raiseIllegalArgument("map can only be merged with MapEntry elements; got $e")
|
||||
}
|
||||
val key = entry.key as? ObjString ?: scope.raiseIllegalArgument("map merge expects string keys; got ${entry.key}")
|
||||
map[key] = entry.value
|
||||
}
|
||||
}
|
||||
else -> scope.raiseIllegalArgument("map can only be merged with Map, MapEntry, or List<MapEntry>")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1492,6 +1492,37 @@ class ListLiteralRef(private val entries: List<ListEntry>) : ObjRef {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Map literal support ---
|
||||
|
||||
sealed class MapLiteralEntry {
|
||||
data class Named(val key: String, val value: ObjRef) : MapLiteralEntry()
|
||||
data class Spread(val ref: ObjRef) : MapLiteralEntry()
|
||||
}
|
||||
|
||||
class MapLiteralRef(private val entries: List<MapLiteralEntry>) : ObjRef {
|
||||
override suspend fun get(scope: Scope): ObjRecord {
|
||||
val result = ObjMap(mutableMapOf())
|
||||
for (e in entries) {
|
||||
when (e) {
|
||||
is MapLiteralEntry.Named -> {
|
||||
val v = if (PerfFlags.RVAL_FASTPATH) e.value.evalValue(scope) else e.value.get(scope).value
|
||||
result.map[ObjString(e.key)] = v
|
||||
}
|
||||
is MapLiteralEntry.Spread -> {
|
||||
val m = if (PerfFlags.RVAL_FASTPATH) e.ref.evalValue(scope) else e.ref.get(scope).value
|
||||
if (m !is ObjMap) scope.raiseIllegalArgument("spread element in map literal must be a Map")
|
||||
// Enforce string keys for map literals
|
||||
for ((k, v) in m.map) {
|
||||
val sKey = k as? ObjString ?: scope.raiseIllegalArgument("spread map must have string keys; got $k")
|
||||
result.map[sKey] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.asReadonly
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Range literal: left .. right or left ..< right. Right may be omitted in certain contexts.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
/*
|
||||
* Map literal and merging tests
|
||||
*/
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import net.sergeych.lyng.ExecutionError
|
||||
import net.sergeych.lyng.ScriptError
|
||||
import net.sergeych.lyng.eval
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
|
||||
class MapLiteralTest {
|
||||
|
||||
@Test
|
||||
fun basicStringAndIdKeysAndShorthand() = runTest {
|
||||
eval(
|
||||
"""
|
||||
val x = 2
|
||||
val y = 2
|
||||
val m = { "a": 1, x: x*10, y: }
|
||||
assertEquals(1, m["a"])
|
||||
assertEquals(20, m["x"])
|
||||
assertEquals(2, m["y"])
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spreadsAndOverwriteOrder() = runTest {
|
||||
eval(
|
||||
"""
|
||||
val base = { a: 1, b: 2 }
|
||||
val m = { a: 0, ...base, b: 3, c: 4 }
|
||||
assertEquals(1, m["a"]) // base overwrites a:0
|
||||
assertEquals(3, m["b"]) // literal overwrites spread
|
||||
assertEquals(4, m["c"]) // new key
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trailingCommaAccepted() = runTest {
|
||||
eval(
|
||||
"""
|
||||
val m = { "a": 1, b: 2, }
|
||||
assertEquals(1, m["a"])
|
||||
assertEquals(2, m["b"])
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun duplicateLiteralKeysAreCompileTimeError() = runTest {
|
||||
assertFailsWith<ScriptError> {
|
||||
eval("""{ foo: 1, " + '"' + "foo" + '"' + ": 2 }""".trimIndent())
|
||||
}
|
||||
assertFailsWith<ScriptError> {
|
||||
eval("""{ foo:, foo: 2 }""".trimIndent())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lambdaDisambiguationWithTypedArgs() = runTest {
|
||||
eval(
|
||||
"""
|
||||
val f = { x: Int, y: Int -> x + y }
|
||||
assertEquals(3, f(1,2))
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun plusMergingAndPlusAssign() = runTest {
|
||||
eval(
|
||||
"""
|
||||
val m1 = ("1" => 10) + ("2" => 20)
|
||||
assertEquals(10, m1["1"])
|
||||
assertEquals(20, m1["2"])
|
||||
|
||||
val m2 = { "1": 10 } + ("2" => 20)
|
||||
assertEquals(10, m2["1"])
|
||||
assertEquals(20, m2["2"])
|
||||
|
||||
val m3 = { "1": 10 } + { "2": 20 }
|
||||
assertEquals(10, m3["1"])
|
||||
assertEquals(20, m3["2"])
|
||||
|
||||
var m = { "a": 1 }
|
||||
m += ("b" => 2)
|
||||
assertEquals(1, m["a"])
|
||||
assertEquals(2, m["b"])
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spreadNonMapIsRuntimeError() = runTest {
|
||||
assertFailsWith<ExecutionError> {
|
||||
eval("""{ ...[1,2,3] }""")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun spreadNonStringKeysIsRuntimeError() = runTest {
|
||||
assertFailsWith<ExecutionError> {
|
||||
eval("""{ ...Map(1 => "x") }""")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeNonStringKeyIsRuntimeError() = runTest {
|
||||
assertFailsWith<ExecutionError> {
|
||||
eval("""
|
||||
val e = (1 => "x")
|
||||
{ "a": 1 } + e
|
||||
""")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shorthandUndefinedIdIsError() = runTest {
|
||||
assertFailsWith<ExecutionError> {
|
||||
eval("""{ z: }""")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package net.sergeych.lyng.highlight
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class MapLiteralHighlightTest {
|
||||
|
||||
private fun spansToLabeled(text: String, spans: List<HighlightSpan>): List<Pair<String, HighlightKind>> =
|
||||
spans.map { text.substring(it.range.start, it.range.endExclusive) to it.kind }
|
||||
|
||||
@Test
|
||||
fun highlightsMapLiteralBasics() {
|
||||
val text = """
|
||||
val x = 2
|
||||
val m = { "a": 1, b: , ...base, }
|
||||
""".trimIndent()
|
||||
|
||||
val spans = SimpleLyngHighlighter().highlight(text)
|
||||
val labeled = spansToLabeled(text, spans)
|
||||
|
||||
// Brace punctuation
|
||||
assertTrue(labeled.any { it.first == "{" && it.second == HighlightKind.Punctuation })
|
||||
assertTrue(labeled.any { it.first == "}" && it.second == HighlightKind.Punctuation })
|
||||
|
||||
// String key and number value
|
||||
assertTrue(labeled.any { it.first == "\"a\"" && it.second == HighlightKind.String })
|
||||
assertTrue(labeled.any { it.first == "1" && it.second == HighlightKind.Number })
|
||||
|
||||
// Identifier key and shorthand (b:)
|
||||
assertTrue(labeled.any { it.first == "b" && it.second == HighlightKind.Identifier })
|
||||
// The colon after key is punctuation
|
||||
assertTrue(labeled.any { it.first == ":" && it.second == HighlightKind.Punctuation })
|
||||
|
||||
// Spread operator
|
||||
assertTrue(labeled.any { it.first == "..." && it.second == HighlightKind.Operator })
|
||||
|
||||
// Trailing comma
|
||||
assertTrue(labeled.any { it.first == "," && it.second == HighlightKind.Punctuation })
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user