From d118d29429c5016de4efc3cceae110a4c218a303 Mon Sep 17 00:00:00 2001 From: sergeych Date: Thu, 27 Nov 2025 20:02:19 +0100 Subject: [PATCH] map literals --- docs/Map.md | 81 +++++++++- docs/declaring_arguments.md | 8 + docs/proposals/map_literal.md | 121 ++++++++------- docs/tutorial.md | 59 ++++++- .../syntaxes/lyng.tmLanguage.json | 20 +++ lynglib/build.gradle.kts | 2 +- .../kotlin/net/sergeych/lyng/Compiler.kt | 119 ++++++++++++++- .../kotlin/net/sergeych/lyng/obj/ObjMap.kt | 63 +++++++- .../kotlin/net/sergeych/lyng/obj/ObjRef.kt | 31 ++++ .../src/commonTest/kotlin/MapLiteralTest.kt | 144 ++++++++++++++++++ .../lyng/highlight/MapLiteralHighlightTest.kt | 57 +++++++ lyngweb/build.gradle.kts | 2 +- site/src/jsMain/kotlin/HomePage.kt | 15 +- site/src/jsMain/kotlin/TryLyngPage.kt | 3 +- site/src/jsMain/resources/index.html | 2 +- site/src/jsTest/kotlin/HighlightSmokeTest.kt | 20 +++ 16 files changed, 669 insertions(+), 78 deletions(-) create mode 100644 lynglib/src/commonTest/kotlin/MapLiteralTest.kt create mode 100644 lynglib/src/commonTest/kotlin/net/sergeych/lyng/highlight/MapLiteralHighlightTest.kt diff --git a/docs/Map.md b/docs/Map.md index c7001aa..70ecd0c 100644 --- a/docs/Map.md +++ b/docs/Map.md @@ -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) \ No newline at end of file diff --git a/docs/declaring_arguments.md b/docs/declaring_arguments.md index 0017612..3e8df3c 100644 --- a/docs/declaring_arguments.md +++ b/docs/declaring_arguments.md @@ -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. diff --git a/docs/proposals/map_literal.md b/docs/proposals/map_literal.md index 350ce5a..e0b51a7 100644 --- a/docs/proposals/map_literal.md +++ b/docs/proposals/map_literal.md @@ -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. diff --git a/docs/tutorial.md b/docs/tutorial.md index f96965b..b278c30 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -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 diff --git a/editors/lyng-textmate/syntaxes/lyng.tmLanguage.json b/editors/lyng-textmate/syntaxes/lyng.tmLanguage.json index 345d18d..e8a34c6 100644 --- a/editors/lyng-textmate/syntaxes/lyng.tmLanguage.json +++ b/editors/lyng-textmate/syntaxes/lyng.tmLanguage.json @@ -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": [ { diff --git a/lynglib/build.gradle.kts b/lynglib/build.gradle.kts index a1f0998..6445cdc 100644 --- a/lynglib/build.gradle.kts +++ b/lynglib/build.gradle.kts @@ -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 diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt index b5528a5..a491fde 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt @@ -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() + val usedKeys = mutableSetOf() + + 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 diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjMap.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjMap.kt index 63bb1de..4d560e2 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjMap.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjMap.kt @@ -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 = mutableMapOf()) : Obj() { @@ -96,7 +102,22 @@ class ObjMap(val map: MutableMap = 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 = 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") + } + } } \ No newline at end of file diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRef.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRef.kt index de5e9e1..0bec81c 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRef.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRef.kt @@ -1492,6 +1492,37 @@ class ListLiteralRef(private val entries: List) : 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) : 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. */ diff --git a/lynglib/src/commonTest/kotlin/MapLiteralTest.kt b/lynglib/src/commonTest/kotlin/MapLiteralTest.kt new file mode 100644 index 0000000..7c8b629 --- /dev/null +++ b/lynglib/src/commonTest/kotlin/MapLiteralTest.kt @@ -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 { + eval("""{ foo: 1, " + '"' + "foo" + '"' + ": 2 }""".trimIndent()) + } + assertFailsWith { + 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 { + eval("""{ ...[1,2,3] }""") + } + } + + @Test + fun spreadNonStringKeysIsRuntimeError() = runTest { + assertFailsWith { + eval("""{ ...Map(1 => "x") }""") + } + } + + @Test + fun mergeNonStringKeyIsRuntimeError() = runTest { + assertFailsWith { + eval(""" + val e = (1 => "x") + { "a": 1 } + e + """) + } + } + + @Test + fun shorthandUndefinedIdIsError() = runTest { + assertFailsWith { + eval("""{ z: }""") + } + } +} diff --git a/lynglib/src/commonTest/kotlin/net/sergeych/lyng/highlight/MapLiteralHighlightTest.kt b/lynglib/src/commonTest/kotlin/net/sergeych/lyng/highlight/MapLiteralHighlightTest.kt new file mode 100644 index 0000000..6ad245a --- /dev/null +++ b/lynglib/src/commonTest/kotlin/net/sergeych/lyng/highlight/MapLiteralHighlightTest.kt @@ -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): List> = + 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 }) + } +} diff --git a/lyngweb/build.gradle.kts b/lyngweb/build.gradle.kts index bc60787..08db5f4 100644 --- a/lyngweb/build.gradle.kts +++ b/lyngweb/build.gradle.kts @@ -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 { diff --git a/site/src/jsMain/kotlin/HomePage.kt b/site/src/jsMain/kotlin/HomePage.kt index 1cc323f..1a314cb 100644 --- a/site/src/jsMain/kotlin/HomePage.kt +++ b/site/src/jsMain/kotlin/HomePage.kt @@ -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 = "
" + htmlEscape(code) + "
" - Div({ classes("markdown-body") }) { - UnsafeRawHtml(highlightLyngHtml(ensureBootstrapCodeBlocks(codeHtml))) + val mapHtml = "
" + htmlEscape(code) + "
" + Div({ classes("markdown-body", "mt-3") }) { + UnsafeRawHtml(highlightLyngHtml(ensureBootstrapCodeBlocks(mapHtml))) } // Short features list diff --git a/site/src/jsMain/kotlin/TryLyngPage.kt b/site/src/jsMain/kotlin/TryLyngPage.kt index f467017..53abab8 100644 --- a/site/src/jsMain/kotlin/TryLyngPage.kt +++ b/site/src/jsMain/kotlin/TryLyngPage.kt @@ -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 }, diff --git a/site/src/jsMain/resources/index.html b/site/src/jsMain/resources/index.html index a33b739..968634b 100644 --- a/site/src/jsMain/resources/index.html +++ b/site/src/jsMain/resources/index.html @@ -181,7 +181,7 @@
- v1.0.2 + v1.0.3
diff --git a/site/src/jsTest/kotlin/HighlightSmokeTest.kt b/site/src/jsTest/kotlin/HighlightSmokeTest.kt index 651eb07..01eecdd 100644 --- a/site/src/jsTest/kotlin/HighlightSmokeTest.kt +++ b/site/src/jsTest/kotlin/HighlightSmokeTest.kt @@ -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, "{") + assertContains(html, "}") + assertTrue(html.contains("hl-punc") && html.contains(","), "comma should be punctuation") + // Colon after key is punctuation + assertContains(html, ":") + // Spread operator present + assertContains(html, "...") + // String key and identifier key appear + assertContains(html, "\"a\"") + assertContains(html, "b") + } + @Test fun renderHtmlContainsCorrectClasses() { val text = "assertEquals( [9,10], r.takeLast(2).toList() )"