map literals

This commit is contained in:
Sergey Chernov 2025-11-27 20:02:19 +01:00
parent cb9df79ce3
commit d118d29429
16 changed files with 669 additions and 78 deletions

View File

@ -1,19 +1,22 @@
# Map
Map is a mutable collection of key-value pars, where keys are unique. Maps could be created with
constructor or `.toMap` methods. When constructing from a list, each list item must be a [Collection] with exactly 2 elements, for example, a [List].
Map is a mutable collection of key-value pairs, where keys are unique. You can create maps in two ways:
- with the constructor `Map(...)` or `.toMap()` helpers; and
- with map literals using braces: `{ "key": value, id: expr, id: }`.
When constructing from a list, each list item must be a [Collection] with exactly 2 elements, for example, a [List].
Important thing is that maps can't contain `null`: it is used to return from missing elements.
Constructed map instance is of class `Map` and implements `Collection` (and therefore `Iterable`)
Constructed map instance is of class `Map` and implements `Collection` (and therefore `Iterable`).
val map = Map( "foo" => 1, "bar" => "buzz" )
assert(map is Map)
assert(map.size == 2)
assert(map is Iterable)
val oldForm = Map( "foo" => 1, "bar" => "buzz" )
assert(oldForm is Map)
assert(oldForm.size == 2)
assert(oldForm is Iterable)
>>> void
Notice usage of the `=>` operator that creates `MapEntry`, which implements also [Collection] of
Notice usage of the `=>` operator that creates `MapEntry`, which also implements [Collection] of
two items, first, at index zero, is a key, second, at index 1, is the value. You can use lists too.
Map keys could be any objects (hashable, e.g. with reasonable hashCode, most of standard types are). You can access elements with indexing operator:
@ -29,6 +32,36 @@ Map keys could be any objects (hashable, e.g. with reasonable hashCode, most of
assert( map["foo"] == -1)
>>> void
## Map literals { ... }
Lyng supports JavaScript-like map literals. Keys can be string literals or identifiers, and there is a handy identifier shorthand:
- String key: `{ "a": 1 }`
- Identifier key: `{ foo: 2 }` is the same as `{ "foo": 2 }`
- Identifier shorthand: `{ foo: }` is the same as `{ "foo": foo }`
Access uses brackets: `m["a"]`.
val x = 10
val y = 10
val m = { "a": 1, x: x * 2, y: }
assertEquals(1, m["a"]) // string-literal key
assertEquals(20, m["x"]) // identifier key
assertEquals(10, m["y"]) // identifier shorthand expands to y: y
>>> void
Trailing commas are allowed for nicer diffs and multiline formatting:
val m = {
"a": 1,
b: 2,
}
assertEquals(1, m["a"])
assertEquals(2, m["b"])
>>> void
Empty `{}` is reserved for blocks/lambdas; use `Map()` for an empty map.
To remove item from the collection. use `remove`. It returns last removed item or null. Be careful if you
hold nulls in the map - this is not a recommended practice when using `remove` returned value. `clear()`
removes all.
@ -110,4 +143,36 @@ is equal.
assert( m1 != m3 )
>>> void
## Spreads and merging
Inside map literals you can spread another map with `...` and items will be merged left-to-right; rightmost wins:
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
>>> void
Maps and entries can also be merged with `+` and `+=`:
val m1 = ("x" => 1) + ("y" => 2)
assertEquals(1, m1["x"])
assertEquals(2, m1["y"])
val m2 = { "a": 10 } + ("b" => 20)
assertEquals(10, m2["a"])
assertEquals(20, m2["b"])
var m3 = { a: 1 }
m3 += ("b" => 2)
assertEquals(1, m3["a"])
assertEquals(2, m3["b"])
>>> void
Notes:
- Map literals always use string keys (identifier keys are converted to strings).
- Spreads inside map literals and `+`/`+=` merges require string keys on the right-hand side; this aligns with named-argument splats.
- When you need computed or non-string keys, use the constructor form `Map(...)` or build entries with `=>` and then merge.
[Collection](Collection.md)

View File

@ -126,6 +126,14 @@ Splat (`...`) of a Map provides named arguments to the call. Only string keys ar
assertEquals(["A?","B!","c","D!"], r)
```
The same with a map literal is often more concise. Define the literal, then splat the variable:
fun test(a="a", b="b", c="c", d="d") { [a, b, c, d] }
val patch = { d: "D!", b: "B!" }
val r = test("A?", ...patch)
assertEquals(["A?","B!","c","D!"], r)
>>> void
Constraints:
- Map splat keys must be strings; otherwise, a clean error is thrown.

View File

@ -1,82 +1,93 @@
Note for AI. please ignore this file until this remark is removed.
# Map literals proposal
# Map literals — refined proposal
Note to the AI: please ignore this file until this note is removed.
Implement JavaScript-like literals for maps. The syntax and semantics align with named arguments in function calls, but map literals are expressions that construct `Map` values.
Implement javascript-like literals for maps. It uses the similar syntax already adopted for named arguments in function calls so it should be organic and familiar, as the purpose is similar.
Keys can be either:
- string literals: `{ "some key": value }`, or
- identifiers: `{ name: expr }`, where the key becomes the string `"name"`.
String literal keys
Identifier shorthand inside map literals is supported:
- `{ name: }` desugars to `{ "name": name }`.
Property access sugar is not provided for maps: use bracket access only, e.g. `m["a"]`, not `m.a`.
Examples:
```lyng
val m = { a: "foo", b: "bar" }
assertEqual(m.a, "foo")
assertEqual(m.b, "bar")
val x = 2
val m = { "a": 1, x: x*10, y: }
assertEquals(1, m["a"]) // string-literal key
assertEquals(20, m["x"]) // identifier key
assertEquals(2, m["y"]) // identifier shorthand
```
The map literal is a map with keys that are string literals only. This is important. In a relatively rare case when keys are calculated, or extravagant but still possible case when keys are of different types, literal could be combined with "=>":
Spreads (splats) in map literals are allowed and merged left-to-right with “rightmost wins” semantics:
```lyng
val k1 = "bar"
val m = { "foo": 123 } + k1 => "buzz"
// this is same as Map("foo" => 123) + Map("bar" => k2) but can be optimized by compiler
assertEqual(m["foo"], 123)
assertEqual(m["bar"], "buzz")
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
```
The lambda syntax is different, it can start with the `map_lteral_start` above, it should produce compile time error, so we can add map literals of this sort.
Trailing commas are allowed (optional):
Also, we will allow splats in map literals:
```
val m = { foo: "bar", ...{bar: "buzz"} }
assertEquals("bar",m["foo"])
assertEquals("buzz", m["bar"])
```lyng
val m = {
"a": 1,
b: 2,
...other,
}
```
When the literal argument and splats are used together, they must be evaluated left-to-right with allowed overwriting
between named elements and splats, allowing any combination and multiple splats:
Duplicate keys among literal entries (including identifier shorthand) are a compile-time error:
```
val m = { foo: "bar", ...{bar: "buzz"}, ...{foo: "foobar"}, bar: "bar" }
assertEquals("foobar",m["foo"])
assertEquals("bar", m["bar"])
```lyng
{ foo: 1, "foo": 2 } // error: duplicate key "foo"
{ foo:, foo: 2 } // error: duplicate key "foo"
```
Still we disallow duplicating _string literals_:
Spreads are evaluated at runtime. Overlaps from spreads are resolved by last write wins. If a spread is not a map, or yields a map with non-string keys, it’s a runtime error.
```
// this is an compile-time exception:
{ foo: 1, bar: 2, foo: 3 }
Merging with `+`/`+=` and entries:
```lyng
("1" => 10) + ("2" => 20) // Map("1"=>10, "2"=>20)
{ "1": 10 } + ("2" => 20) // same
{ "1": 10 } + { "2": 20 } // same
var m = { "a": 1 }
m += ("b" => 2) // m = { "a":1, "b":2 }
```
Special syntax allows to insert key-value pair from the variable which name should be the key, and content is value:
Rightmost wins on duplicates consistently across spreads and merges. All map merging operations require string keys; encountering a non-string key during merge is a runtime error.
Empty map literal `{}` is not supported to avoid ambiguity with blocks/lambdas. Use `Map()` for an empty map.
Lambda disambiguation
- A `{ ... }` with typed lambda parameters must have a top-level `->` in its header. The compiler disambiguates by looking for a top-level `->`. If none is found, it attempts to parse a map literal; if that fails, it is parsed as a lambda or block.
Grammar (EBNF)
```
val foo = "bar"
val bar = "buzz"
assertEquals( {foo: "bar", bar: "buzz"}, { *foo, *bar } )
ws = zero or more whitespace (incl. newline/comments)
map_literal = '{' ws map_entries ws '}'
map_entries = map_entry ( ws ',' ws map_entry )* ( ws ',' )?
map_entry = map_key ws ':' ws map_value_opt
| '...' ws expression
map_key = string_literal | ID
map_value_opt = expression | ε // ε allowed only if map_key is ID
```
Question to the AI: maybe better syntax than asterisk for that case?
Notes:
- Identifier shorthand (`id:`) is allowed only for identifiers, not string-literal keys.
- Spreads accept any expression; at runtime it must yield a `Map` with string keys.
- Duplicate keys are detected at compile time among literal keys; spreads are merged at runtime with last-wins.
So, summarizing, overwriting/duplication rules are:
- string literals can't duplicate
- splats add or update content, effectively overwrite preceding content,
- string literals overwrite content received from preceding splats (as no duplication string literal keys allowed)
- the priority and order is left-to-right, rightmost wins.
- var inclusion is treated as form of the literal
This approach resolves the ambiguity from lambda syntax, as
```ebnf
ws = zero or more whitespace characters including newline
map_literal start = "{", ws, (s1 | s2 | s3)
s1 = string_literal, ws, ":", ws, expression
s2 = "...", string_literal
s3 = "*", string_literal
```
as we can see, `map_literal_start` is not a valid lambda beginning so it is not create ambiguity.
Rationale
- The `{ name: value }` style is familiar and ergonomic.
- Disambiguation with lambdas leverages the required `->` in typed lambda headers.
- Avoiding `m.a` sidesteps method/field shadowing and keeps semantics clear.

View File

@ -676,14 +676,63 @@ Please see [Set] for detailed description.
# Maps
Maps are unordered collection of key-value pairs, where keys are unique. See [Map] for details. Map also
are [Iterable]:
Maps are unordered collections of key-value pairs, where keys are unique. Maps are also [Iterable].
val m = Map( "foo" => 77, "bar" => "buzz" )
assertEquals( m["foo"], 77 )
You can create them either with the classic constructor (still supported):
val old = Map( "foo" => 77, "bar" => "buzz" )
assertEquals( old["foo"], 77 )
>>> void
Please see [Map] reference for detailed description on using Maps.
…or with map literals, which are often more convenient:
val x = 10
val y = 10
val m = { "a": 1, x: x * 2, y: }
// identifier keys become strings; `y:` is shorthand for y: y
assertEquals(1, m["a"]) // string-literal key
assertEquals(20, m["x"]) // identifier key
assertEquals(10, m["y"]) // shorthand
>>> void
Map literals support trailing commas for nicer diffs:
val m2 = {
"a": 1,
b: 2,
}
assertEquals(1, m2["a"])
assertEquals(2, m2["b"])
>>> void
You can spread other maps inside a literal with `...`. Items merge left-to-right and the rightmost value wins:
val base = { a: 1, b: 2 }
val merged = { a: 0, ...base, b: 3, c: 4 }
assertEquals(1, merged["a"]) // base overwrites a:0
assertEquals(3, merged["b"]) // literal overwrites spread
assertEquals(4, merged["c"]) // new key
>>> void
Merging also works with `+` and `+=`, and you can combine maps and entries conveniently:
val m3 = { "a": 10 } + ("b" => 20)
assertEquals(10, m3["a"])
assertEquals(20, m3["b"])
var m4 = ("x" => 1) + ("y" => 2) // entry + entry → Map
m4 += { z: 3 } // merge literal
assertEquals(1, m4["x"])
assertEquals(2, m4["y"])
assertEquals(3, m4["z"])
>>> void
Notes:
- Access keys with brackets: `m["key"]`. There is no `m.key` sugar.
- Empty `{}` remains a block/lambda; use `Map()` to create an empty map.
- When you need computed (expression) keys or non-string keys, use `Map(...)` constructor with entries, e.g. `Map( ("a" + "b") => 1 )`, then merge with a literal if needed: `{ base: } + (computedKey => 42)`.
Please see the [Map] reference for a deeper guide.
# Flow control operators

View File

@ -13,6 +13,7 @@
{ "include": "#keywords" },
{ "include": "#constants" },
{ "include": "#types" },
{ "include": "#mapLiterals" },
{ "include": "#namedArgs" },
{ "include": "#annotations" },
{ "include": "#labels" },
@ -42,6 +43,25 @@
]
},
"annotations": { "patterns": [ { "name": "entity.name.label.at.lyng", "match": "@[\\p{L}_][\\p{L}\\p{N}_]*:" }, { "name": "storage.modifier.annotation.lyng", "match": "@[\\p{L}_][\\p{L}\\p{N}_]*" } ] },
"mapLiterals": {
"patterns": [
{
"name": "meta.map.entry.lyng",
"match": "(?:(?<=\\{|,))\\s*(\"[^\"]*\"|[\\p{L}_][\\p{L}\\p{N}_]*)\\s*(:)(?!:)",
"captures": {
"1": { "name": "variable.other.property.key.lyng" },
"2": { "name": "punctuation.separator.colon.lyng" }
}
},
{
"name": "meta.map.spread.lyng",
"match": "(?:(?<=\\{|,))\\s*(\\.\\.\\.)",
"captures": {
"1": { "name": "keyword.operator.spread.lyng" }
}
}
]
},
"namedArgs": {
"patterns": [
{

View File

@ -21,7 +21,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
group = "net.sergeych"
version = "1.0.1-SNAPSHOT"
version = "1.0.3"
// Removed legacy buildscript classpath declarations; plugins are applied via the plugins DSL below

View File

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

View File

@ -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>")
}
}
}

View File

@ -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.
*/

View File

@ -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: }""")
}
}
}

View File

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

View File

@ -56,7 +56,7 @@ kotlin {
implementation("org.jetbrains.compose.runtime:runtime:1.9.3")
implementation("org.jetbrains.compose.html:html-core:1.9.3")
implementation(libs.kotlinx.coroutines.core)
implementation(project(":lynglib"))
api(project(":lynglib"))
}
}
val jsTest by getting {

View File

@ -80,12 +80,19 @@ import lyng.stdlib
val data = 1..5 // or [1,2,3,4,5]
val evens2 = data.filter { it % 2 == 0 }.map { it * it }
assertEquals([4, 16], evens2)
// Map literal with identifier keys, shorthand, and spread
val base = { a: 1, b: 2 }
val patch = { b: 3, c: }
val m = { "a": 0, ...base, ...patch, d: 4 }
assertEquals(1, m["a"]) // base overwrites 0
assertEquals(3, m["b"]) // patch overwrites base
assertEquals(4, m["d"]) // literal key
>>> void
""".trimIndent()
val codeHtml = "<pre><code>" + htmlEscape(code) + "</code></pre>"
Div({ classes("markdown-body") }) {
UnsafeRawHtml(highlightLyngHtml(ensureBootstrapCodeBlocks(codeHtml)))
val mapHtml = "<pre><code>" + htmlEscape(code) + "</code></pre>"
Div({ classes("markdown-body", "mt-3") }) {
UnsafeRawHtml(highlightLyngHtml(ensureBootstrapCodeBlocks(mapHtml)))
}
// Short features list

View File

@ -17,6 +17,7 @@
import androidx.compose.runtime.*
import kotlinx.coroutines.launch
import net.sergeych.lyng.LyngVersion
import net.sergeych.lyng.Scope
import net.sergeych.lyng.ScriptError
import net.sergeych.lyngweb.EditorWithOverlay
@ -134,7 +135,7 @@ fun TryLyngPage() {
// Editor
Div({ classes("mb-3") }) {
Div({ classes("form-label", "fw-semibold") }) { Text("Code") }
Div({ classes("form-label", "fw-semibold") }) { Text("Code (v${LyngVersion})") }
EditorWithOverlay(
code = code,
setCode = { code = it },

View File

@ -181,7 +181,7 @@
<!-- Top-left version ribbon -->
<div class="corner-ribbon bg-danger text-white">
<span class="me-3">
v1.0.2
v1.0.3
</span>
</div>
<!-- Fixed top navbar for the whole site -->

View File

@ -44,6 +44,26 @@ class HighlightSmokeTest {
assertTrue(labeled.any { it.first == "2" && it.second == HighlightKind.Number })
}
@Test
fun highlightMapLiteralHtml() {
val text = """
val base = { a: 1, b: }
val m = { "a": 1, b: , ...base, }
""".trimIndent()
val html = SiteHighlight.renderHtml(text)
// Curly braces and commas are punctuation
assertContains(html, "<span class=\"hl-punc\">{</span>")
assertContains(html, "<span class=\"hl-punc\">}</span>")
assertTrue(html.contains("hl-punc") && html.contains(","), "comma should be punctuation")
// Colon after key is punctuation
assertContains(html, "<span class=\"hl-punc\">:</span>")
// Spread operator present
assertContains(html, "<span class=\"hl-op\">...</span>")
// String key and identifier key appear
assertContains(html, "<span class=\"hl-str\">\"a\"</span>")
assertContains(html, "<span class=\"hl-id\">b</span>")
}
@Test
fun renderHtmlContainsCorrectClasses() {
val text = "assertEquals( [9,10], r.takeLast(2).toList() )"