diff --git a/CHANGELOG.md b/CHANGELOG.md index b302d12..79dda64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ ### Unreleased +- Language: Named arguments and named splats + - New call-site syntax for named arguments using colon: `name: value`. + - Positional arguments must come before named; positionals after a named argument inside parentheses are rejected. + - Trailing-lambda interaction: if the last parameter is already assigned by name (or via a named splat), a trailing `{ ... }` block is illegal. + - Named splats: `...` can now expand a Map into named arguments. + - Only string keys are allowed; non-string keys raise a clear error. + - Duplicate assignment across named args and named splats is an error. + - Ellipsis (variadic) parameters remain positional-only and cannot be named. + - Rationale: `=` is assignment and an expression in Lyng; `:` at call sites avoids ambiguity. Declarations keep `name: Type`; call-site casts continue to use `as` / `as?`. + - Documentation updated: proposals and declaring-arguments sections now cover named args/splats and error cases. + - Tests added covering success cases and errors for named args/splats and trailing-lambda interactions. + +- Tooling: Highlighters and TextMate bundle updated for named args + - Website/editor highlighter (lyngweb + site) works with `name: value` and `...Map("k" => v)`; added JS tests covering punctuation/operator spans for `:` and `...`. + - TextMate grammar updated to recognize named call arguments: `name: value` after `(` or `,` with `name` highlighted as `variable.parameter.named.lyng` and `:` as punctuation; excludes `::`. + - TextMate bundle version bumped to 0.0.3; README updated with details and guidance. + - Multiple Inheritance (MI) completed and enabled by default: - Active C3 Method Resolution Order (MRO) for deterministic, monotonic lookup across complex hierarchies and diamonds. - Qualified dispatch: diff --git a/docs/declaring_arguments.md b/docs/declaring_arguments.md index f467a6a..0017612 100644 --- a/docs/declaring_arguments.md +++ b/docs/declaring_arguments.md @@ -5,7 +5,7 @@ lambdas and class declarations. ## Regular -## default values +## Default values Default parameters should not be mixed with mandatory ones: @@ -95,6 +95,53 @@ There could be any number of splats at any positions. You can splat any other [I testSplat("start", ...range, "end") >>> [start,1,2,3,end] >>> void + +## Named arguments in calls + +Lyng supports named arguments at call sites using colon syntax `name: value`: + +```lyng + fun test(a="foo", b="bar", c="bazz") { [a, b, c] } + + assertEquals(["foo", "b", "bazz"], test(b: "b")) + assertEquals(["a", "bar", "c"], test("a", c: "c")) +``` + +Rules: + +- Named arguments must follow positional arguments. After the first named argument, no positional arguments may appear inside the parentheses. +- The only exception is the syntactic trailing block after the call: `f(args) { ... }`. This block is outside the parentheses and is handled specially (see below). +- A named argument cannot reassign a parameter already set positionally. +- If the last parameter has already been assigned by a named argument (or named splat), a trailing block is not allowed and results in an error. + +Why `:` and not `=` at call sites? In Lyng, `=` is an expression (assignment), so we use `:` to avoid ambiguity. Declarations continue to use `:` for types, while call sites use `as` / `as?` for type operations. + +## Named splats (map splats) + +Splat (`...`) of a Map provides named arguments to the call. Only string keys are allowed: + +```lyng + fun test(a="a", b="b", c="c", d="d") { [a, b, c, d] } + val r = test("A?", ...Map("d" => "D!", "b" => "B!")) + assertEquals(["A?","B!","c","D!"], r) +``` + +Constraints: + +- Map splat keys must be strings; otherwise, a clean error is thrown. +- Named splats cannot duplicate parameters already assigned (by positional or named arguments). +- Named splats must follow all positional arguments and positional splats. +- Ellipsis parameters (variadic) remain positional-only and cannot be assigned by name. + +## Trailing-lambda rule interaction + +If a call is immediately followed by a block `{ ... }`, it is treated as an extra last argument and bound to the last parameter. However, if the last parameter is already assigned by a named argument or a named splat, using a trailing block is an error: + +```lyng + fun f(x, onDone) { onDone(x) } + f(x: 1) { 42 } // ERROR + f(1) { 42 } // OK +``` [tutorial]: tutorial.md diff --git a/docs/proposals/map_literal.md b/docs/proposals/map_literal.md new file mode 100644 index 0000000..af46d5c --- /dev/null +++ b/docs/proposals/map_literal.md @@ -0,0 +1,80 @@ +Note for AI. please ignore this file until this remark is removed. + +# Map literals proposal + +Note to the AI: please ignore this file until this note is removed. + +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. + +String literal keys + +```lyng + val m = { a: "foo", b: "bar" } + assertEqual(m.a, "foo") + assertEqual(m.b, "bar") +``` + +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 "=>": + +```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") +``` + +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. + +Also, we will allow splats in map literals: + +``` + val m = { foo: "bar", ...{bar: "buzz"} } + assertEquals("bar",m["foo"]) + assertEquals("buzz", m["bar"]) +``` + +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: + +``` + val m = { foo: "bar", ...{bar: "buzz"}, ...{foo: "foobar"}, bar: "bar" } + assertEquals("foobar",m["foo"]) + assertEquals("bar", m["bar"]) +``` + +Still we disallow duplicating _string literals_: + +``` + // this is an compile-time exception: + { foo: 1, bar: 2, foo: 3 } +``` + +Special syntax allows to insert key-value pair from the variable which name should be the key, and content is value: + +``` + val foo = "bar" + val bar = "buzz" + assertEquals( {foo: "bar", bar: "buzz"}, { foo, bar } ) +``` + + +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, ("," | "}") +``` +is not a valid lambda beginning. + diff --git a/docs/proposals/named_args.md b/docs/proposals/named_args.md new file mode 100644 index 0000000..2fee6e9 --- /dev/null +++ b/docs/proposals/named_args.md @@ -0,0 +1,67 @@ +# Named arguments proposal + +Extend function/method calls to allow setting arguments by name using colon syntax at call sites. This is especially convenient with many parameters and default values. + +Examples: + +```lyng + fun test(a="foo", b="bar", c="bazz") { [a, b, c] } + + assertEquals(test(b: "b"), ["foo", "b", "bazz"]) + assertEquals(test("a", c: "c"), ["a", "bar", "c"]) +``` + +Rules: + +- Named arguments are optional. If named arguments are present, their order is not important. +- Named arguments must follow positional arguments; positional arguments cannot follow named ones (the only exception is the syntactic trailing block outside parentheses, see below). +- A named argument cannot reassign a parameter already set positionally. +- If the last parameter is already assigned by a named argument (or named splat), the trailing-lambda rule must NOT apply: a following `{ ... }` after the call is an error. + +Rationale for using `:` instead of `=` in calls: in Lyng, assignment `=` is an expression; using `:` avoids ambiguity and keeps declarations (`name: Type`) distinct from call sites, where casting uses `as` / `as?`. + +Migration note: earlier drafts/examples used `name = value`. The final syntax is `name: value` at call sites. + +## Extended call argument splats: named splats + +With named arguments, splats (`...`) are extended to support maps as named splats. When a splat evaluates to a Map, its entries provide name→value assignments: + +```lyng + fun test(a="a", b="b", c="c", d="d") { [a, b, c, d] } + + assertEquals(test("A?", ...Map("d" => "D!", "b" => "B!")), ["A?", "B!", "c", "D!"]) +``` + +Constraints for named splats: + +- Only string keys are allowed in map splats; otherwise, a clean error is thrown. +- Named splats cannot reassign parameters already set (positionally or by earlier named arguments/splats). +- Named splats follow the same ordering as named arguments: they must appear after all positional arguments and positional splats. + +## Trailing-lambda interaction + +Lyng supports a syntactic trailing block after a call: `f(args) { ... }`. With named args/splats, if the last parameter is already assigned by name, the trailing block must not apply and the call is an error: + +```lyng + fun f(x, onDone) { onDone(x) } + f(x: 1) { 42 } // ERROR: last parameter already assigned by name + f(1) { 42 } // OK +``` + +## Errors (non-exhaustive) + +- Positional argument after any named argument inside parentheses: error. +- Positional splat after any named argument: error. +- Duplicate named assignment (directly or via map splats): error. +- Unknown parameter name in a named argument/splat: error. +- Map splat with non-string keys: error. +- Attempt to target the ellipsis parameter by name: error. + +## Notes + +- Declarations continue to use `:` for types, while call sites use `:` for named arguments and `as` / `as?` for type casts/checks. + + + + + diff --git a/editors/lyng-textmate/README.md b/editors/lyng-textmate/README.md index a51b774..b3ac197 100644 --- a/editors/lyng-textmate/README.md +++ b/editors/lyng-textmate/README.md @@ -25,6 +25,7 @@ Files - Operators including ranges (`..`, `..<`, `...`), null-safe (`?.`, `?[`, `?(`, `?{`, `?:`, `??`), arrows (`->`, `=>`, `::`), match operators (`=~`, `!~`), bitwise, arithmetic, etc. - Shuttle operator `<=>` - Division operator `/` (note: Lyng has no regex literal syntax; `/` is always division) + - Named arguments at call sites `name: value` (the `name` part is highlighted as `variable.parameter.named.lyng` and the `:` as punctuation). The rule is anchored to `(` or `,` and excludes `::` to avoid conflicts. Install in IntelliJ IDEA (and other JetBrains IDEs) --------------------------------------------------- @@ -56,6 +57,7 @@ Notes and limitations --------------------- - Type highlighting is heuristic (Capitalized identifiers). The IntelliJ plugin will use language semantics and avoid false positives. - If your language adds or changes tokens, please update patterns in `lyng.tmLanguage.json`. The Kotlin sources in `lynglib/src/commonMain/kotlin/net/sergeych/lyng/highlight/` are a good reference for token kinds. +- Labels `name:` at statement level remain supported and are kept distinct from named call arguments by context. The grammar prefers named-argument matching when a `name:` appears right after `(` or `,`. Lyng specifics -------------- diff --git a/editors/lyng-textmate/package.json b/editors/lyng-textmate/package.json index a68223e..0215009 100644 --- a/editors/lyng-textmate/package.json +++ b/editors/lyng-textmate/package.json @@ -2,7 +2,7 @@ "name": "lyng-textmate", "displayName": "Lyng", "description": "TextMate grammar for the Lyng language (for JetBrains IDEs via TextMate Bundles and VS Code).", - "version": "0.0.2", + "version": "0.0.3", "publisher": "lyng", "license": "Apache-2.0", "engines": { "vscode": "^1.0.0" }, diff --git a/editors/lyng-textmate/syntaxes/lyng.tmLanguage.json b/editors/lyng-textmate/syntaxes/lyng.tmLanguage.json index 577b054..345d18d 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": "#namedArgs" }, { "include": "#annotations" }, { "include": "#labels" }, { "include": "#directives" }, @@ -41,6 +42,18 @@ ] }, "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}_]*" } ] }, + "namedArgs": { + "patterns": [ + { + "name": "meta.argument.named.lyng", + "match": "(?:(?<=\\()|(?<=,))\\s*([\\p{L}_][\\p{L}\\p{N}_]*)\\s*(:)(?!:)", + "captures": { + "1": { "name": "variable.parameter.named.lyng" }, + "2": { "name": "punctuation.separator.colon.lyng" } + } + } + ] + }, "labels": { "patterns": [ { "name": "entity.name.label.lyng", "match": "[\\p{L}_][\\p{L}\\p{N}_]*:" } ] }, "directives": { "patterns": [ { "name": "meta.directive.lyng", "match": "^\\s*#[_A-Za-z][_A-Za-z0-9]*" } ] }, "declarations": { "patterns": [ { "name": "meta.function.declaration.lyng", "match": "\\b(?:fun|fn)\\s+([\\p{L}_][\\p{L}\\p{N}_]*)", "captures": { "1": { "name": "entity.name.function.lyng" } } }, { "name": "meta.type.declaration.lyng", "match": "\\b(?:class|enum)\\s+([\\p{L}_][\\p{L}\\p{N}_]*)", "captures": { "1": { "name": "entity.name.type.lyng" } } }, { "name": "meta.variable.declaration.lyng", "match": "\\b(?:val|var)\\s+([\\p{L}_][\\p{L}\\p{N}_]*)", "captures": { "1": { "name": "variable.other.declaration.lyng" } } } ] }, diff --git a/lyng/build.gradle.kts b/lyng/build.gradle.kts index 36c72ab..ffae1b2 100644 --- a/lyng/build.gradle.kts +++ b/lyng/build.gradle.kts @@ -31,6 +31,15 @@ repositories { } kotlin { + // Suppress Beta warning for expect/actual classes across all targets in this module + targets.configureEach { + compilations.configureEach { + compilerOptions.configure { + freeCompilerArgs.add("-Xexpect-actual-classes") + } + } + } + jvm { binaries { executable { diff --git a/lynglib/build.gradle.kts b/lynglib/build.gradle.kts index 61df8d4..a1f0998 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.0-SNAPSHOT" +version = "1.0.1-SNAPSHOT" // Removed legacy buildscript classpath declarations; plugins are applied via the plugins DSL below @@ -72,6 +72,15 @@ kotlin { nodejs() } + // Suppress Beta warning for expect/actual classes across all targets + targets.configureEach { + compilations.configureEach { + compilerOptions.configure { + freeCompilerArgs.add("-Xexpect-actual-classes") + } + } + } + sourceSets { all { languageSettings.optIn("kotlinx.coroutines.ExperimentalCoroutinesApi") diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ArgsDeclaration.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ArgsDeclaration.kt index 2a0be75..61424d4 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ArgsDeclaration.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ArgsDeclaration.kt @@ -57,72 +57,137 @@ data class ArgsDeclaration(val params: List, val endTokenType: Token.Type) recordType = ObjRecord.Type.Argument) } - // will be used with last lambda arg fix + // Prepare positional args and parameter count, handle tail-block binding val callArgs: List val paramsSize: Int - - if( arguments.tailBlockMode ) { + if (arguments.tailBlockMode) { + // If last parameter is already assigned by a named argument, it's an error + val lastParam = params.last() + if (arguments.named.containsKey(lastParam.name)) + scope.raiseIllegalArgument("trailing block cannot be used when the last parameter is already assigned by a named argument") paramsSize = params.size - 1 - assign(params.last(), arguments.list.last()) + assign(lastParam, arguments.list.last()) callArgs = arguments.list.dropLast(1) } else { paramsSize = params.size callArgs = arguments.list } - suspend fun processHead(index: Int): Int { + // Compute which parameter indexes are inevitably covered by positional arguments + // based on the number of supplied positionals, defaults and ellipsis placement. + val coveredByPositional = BooleanArray(paramsSize) + run { + // Count required (non-default, non-ellipsis) params in head and in tail + var headRequired = 0 + var tailRequired = 0 + val ellipsisIdx = params.subList(0, paramsSize).indexOfFirst { it.isEllipsis } + if (ellipsisIdx >= 0) { + for (i in 0 until ellipsisIdx) if (!params[i].isEllipsis && params[i].defaultValue == null) headRequired++ + for (i in paramsSize - 1 downTo ellipsisIdx + 1) if (params[i].defaultValue == null) tailRequired++ + } else { + for (i in 0 until paramsSize) if (params[i].defaultValue == null) headRequired++ + } + val P = callArgs.size + if (ellipsisIdx < 0) { + // No ellipsis: all positionals go to head until exhausted + val k = minOf(P, paramsSize) + for (i in 0 until k) coveredByPositional[i] = true + } else { + // With ellipsis: head takes min(P, headRequired) first + val headTake = minOf(P, headRequired) + for (i in 0 until headTake) coveredByPositional[i] = true + val remaining = P - headTake + // tail takes min(remaining, tailRequired) from the end + val tailTake = minOf(remaining, tailRequired) + var j = paramsSize - 1 + var taken = 0 + while (j > ellipsisIdx && taken < tailTake) { + coveredByPositional[j] = true + j-- + taken++ + } + } + } + + // Prepare arrays for named assignments + val assignedByName = BooleanArray(paramsSize) + val namedValues = arrayOfNulls(paramsSize) + if (arguments.named.isNotEmpty()) { + for ((k, v) in arguments.named) { + val idx = params.subList(0, paramsSize).indexOfFirst { it.name == k } + if (idx < 0) scope.raiseIllegalArgument("unknown parameter '$k'") + if (params[idx].isEllipsis) scope.raiseIllegalArgument("ellipsis (variadic) parameter cannot be assigned by name: '$k'") + if (coveredByPositional[idx]) scope.raiseIllegalArgument("argument '$k' is already set by positional argument") + if (assignedByName[idx]) scope.raiseIllegalArgument("argument '$k' is already set") + assignedByName[idx] = true + namedValues[idx] = v + } + } + + // Helper: assign head part, consuming from headPos; stop at ellipsis + suspend fun processHead(index: Int, headPos: Int): Pair { var i = index - while (i != paramsSize) { + var hp = headPos + while (i < paramsSize) { val a = params[i] if (a.isEllipsis) break - val value = when { - i < callArgs.size -> callArgs[i] - a.defaultValue != null -> a.defaultValue.execute(scope) - else -> { -// println("callArgs: ${callArgs.joinToString()}") -// println("tailBlockMode: ${arguments.tailBlockMode}") - scope.raiseIllegalArgument("too few arguments for the call") - } + if (assignedByName[i]) { + assign(a, namedValues[i]!!) + } else { + val value = if (hp < callArgs.size) callArgs[hp++] + else a.defaultValue?.execute(scope) + ?: scope.raiseIllegalArgument("too few arguments for the call") + assign(a, value) } - assign(a, value) i++ } - return i + return i to hp } - suspend fun processTail(index: Int): Int { + // Helper: assign tail part from the end, consuming from tailPos; stop before ellipsis index + // Do not consume elements below headPosBound to avoid overlap with head consumption + suspend fun processTail(startExclusive: Int, tailStart: Int, headPosBound: Int): Int { var i = paramsSize - 1 - var j = callArgs.size - 1 - while (i > index) { + var tp = tailStart + while (i > startExclusive) { val a = params[i] if (a.isEllipsis) break - val value = when { - j >= index -> { - callArgs[j--] - } - - a.defaultValue != null -> a.defaultValue.execute(scope) - else -> scope.raiseIllegalArgument("too few arguments for the call") + if (i < assignedByName.size && assignedByName[i]) { + assign(a, namedValues[i]!!) + } else { + val value = if (tp >= headPosBound) callArgs[tp--] + else a.defaultValue?.execute(scope) + ?: scope.raiseIllegalArgument("too few arguments for the call") + assign(a, value) } - assign(a, value) i-- } - return j + return tp } - fun processEllipsis(index: Int, toFromIndex: Int) { + fun processEllipsis(index: Int, headPos: Int, tailPos: Int) { val a = params[index] - val l = if (index > toFromIndex) ObjList() - else ObjList(callArgs.subList(index, toFromIndex + 1).toMutableList()) + val from = headPos + val to = tailPos + val l = if (from > to) ObjList() + else ObjList(callArgs.subList(from, to + 1).toMutableList()) assign(a, l) } - val leftIndex = processHead(0) - if (leftIndex < paramsSize) { - val end = processTail(leftIndex) - processEllipsis(leftIndex, end) + // Locate ellipsis index within considered parameters + val ellipsisIndex = params.subList(0, paramsSize).indexOfFirst { it.isEllipsis } + + if (ellipsisIndex >= 0) { + // Assign head first to know how many positionals are consumed from the start + val (afterHead, headConsumedTo) = processHead(0, 0) + // Then assign tail consuming from the end down to headConsumedTo boundary + val tailConsumedFrom = processTail(ellipsisIndex, callArgs.size - 1, headConsumedTo) + // Assign ellipsis list from remaining positionals between headConsumedTo..tailConsumedFrom + processEllipsis(ellipsisIndex, headConsumedTo, tailConsumedFrom) } else { - if (leftIndex < callArgs.size) + // No ellipsis: assign head only; any leftover positionals → error + val (_, headConsumedTo) = processHead(0, 0) + if (headConsumedTo != callArgs.size) scope.raiseIllegalArgument("too many arguments for the call") } } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Arguments.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Arguments.kt index 1629037..7e45a77 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Arguments.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Arguments.kt @@ -17,24 +17,27 @@ package net.sergeych.lyng -import net.sergeych.lyng.obj.Obj -import net.sergeych.lyng.obj.ObjIterable -import net.sergeych.lyng.obj.ObjList +import net.sergeych.lyng.obj.* - data class ParsedArgument(val value: Statement, val pos: Pos, val isSplat: Boolean = false) +data class ParsedArgument( + val value: Statement, + val pos: Pos, + val isSplat: Boolean = false, + val name: String? = null, + ) suspend fun Collection.toArguments(scope: Scope, tailBlockMode: Boolean): Arguments { - // Small-arity fast path (no splats) to reduce allocations + // Detect if we can use the fast path: no splats and no named args if (PerfFlags.ARG_BUILDER) { - val limit = if (PerfFlags.ARG_SMALL_ARITY_12) 12 else 8 - var hasSplat = false + val limit = if (PerfFlags.ARG_SMALL_ARITY_12) 12 else 8 + var hasSplatOrNamed = false var count = 0 for (pa in this) { - if (pa.isSplat) { hasSplat = true; break } + if (pa.isSplat || pa.name != null) { hasSplatOrNamed = true; break } count++ - if (count > limit) break + if (count > limit) break } - if (!hasSplat && count == this.size) { + if (!hasSplatOrNamed && count == this.size) { val quick = when (count) { 0 -> Arguments.EMPTY 1 -> Arguments(listOf(this.elementAt(0).value.execute(scope)), tailBlockMode) @@ -94,133 +97,130 @@ import net.sergeych.lyng.obj.ObjList val a7 = this.elementAt(7).value.execute(scope) Arguments(listOf(a0, a1, a2, a3, a4, a5, a6, a7), tailBlockMode) } - 9 -> if (PerfFlags.ARG_SMALL_ARITY_12) { - val a0 = this.elementAt(0).value.execute(scope) - val a1 = this.elementAt(1).value.execute(scope) - val a2 = this.elementAt(2).value.execute(scope) - val a3 = this.elementAt(3).value.execute(scope) - val a4 = this.elementAt(4).value.execute(scope) - val a5 = this.elementAt(5).value.execute(scope) - val a6 = this.elementAt(6).value.execute(scope) - val a7 = this.elementAt(7).value.execute(scope) - val a8 = this.elementAt(8).value.execute(scope) - Arguments(listOf(a0, a1, a2, a3, a4, a5, a6, a7, a8), tailBlockMode) - } else null - 10 -> if (PerfFlags.ARG_SMALL_ARITY_12) { - val a0 = this.elementAt(0).value.execute(scope) - val a1 = this.elementAt(1).value.execute(scope) - val a2 = this.elementAt(2).value.execute(scope) - val a3 = this.elementAt(3).value.execute(scope) - val a4 = this.elementAt(4).value.execute(scope) - val a5 = this.elementAt(5).value.execute(scope) - val a6 = this.elementAt(6).value.execute(scope) - val a7 = this.elementAt(7).value.execute(scope) - val a8 = this.elementAt(8).value.execute(scope) - val a9 = this.elementAt(9).value.execute(scope) - Arguments(listOf(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9), tailBlockMode) - } else null - 11 -> if (PerfFlags.ARG_SMALL_ARITY_12) { - val a0 = this.elementAt(0).value.execute(scope) - val a1 = this.elementAt(1).value.execute(scope) - val a2 = this.elementAt(2).value.execute(scope) - val a3 = this.elementAt(3).value.execute(scope) - val a4 = this.elementAt(4).value.execute(scope) - val a5 = this.elementAt(5).value.execute(scope) - val a6 = this.elementAt(6).value.execute(scope) - val a7 = this.elementAt(7).value.execute(scope) - val a8 = this.elementAt(8).value.execute(scope) - val a9 = this.elementAt(9).value.execute(scope) - val a10 = this.elementAt(10).value.execute(scope) - Arguments(listOf(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10), tailBlockMode) - } else null - 12 -> if (PerfFlags.ARG_SMALL_ARITY_12) { - val a0 = this.elementAt(0).value.execute(scope) - val a1 = this.elementAt(1).value.execute(scope) - val a2 = this.elementAt(2).value.execute(scope) - val a3 = this.elementAt(3).value.execute(scope) - val a4 = this.elementAt(4).value.execute(scope) - val a5 = this.elementAt(5).value.execute(scope) - val a6 = this.elementAt(6).value.execute(scope) - val a7 = this.elementAt(7).value.execute(scope) - val a8 = this.elementAt(8).value.execute(scope) - val a9 = this.elementAt(9).value.execute(scope) - val a10 = this.elementAt(10).value.execute(scope) - val a11 = this.elementAt(11).value.execute(scope) - Arguments(listOf(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11), tailBlockMode) - } else null + 9 -> if (PerfFlags.ARG_SMALL_ARITY_12) { + val a0 = this.elementAt(0).value.execute(scope) + val a1 = this.elementAt(1).value.execute(scope) + val a2 = this.elementAt(2).value.execute(scope) + val a3 = this.elementAt(3).value.execute(scope) + val a4 = this.elementAt(4).value.execute(scope) + val a5 = this.elementAt(5).value.execute(scope) + val a6 = this.elementAt(6).value.execute(scope) + val a7 = this.elementAt(7).value.execute(scope) + val a8 = this.elementAt(8).value.execute(scope) + Arguments(listOf(a0, a1, a2, a3, a4, a5, a6, a7, a8), tailBlockMode) + } else null + 10 -> if (PerfFlags.ARG_SMALL_ARITY_12) { + val a0 = this.elementAt(0).value.execute(scope) + val a1 = this.elementAt(1).value.execute(scope) + val a2 = this.elementAt(2).value.execute(scope) + val a3 = this.elementAt(3).value.execute(scope) + val a4 = this.elementAt(4).value.execute(scope) + val a5 = this.elementAt(5).value.execute(scope) + val a6 = this.elementAt(6).value.execute(scope) + val a7 = this.elementAt(7).value.execute(scope) + val a8 = this.elementAt(8).value.execute(scope) + val a9 = this.elementAt(9).value.execute(scope) + Arguments(listOf(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9), tailBlockMode) + } else null + 11 -> if (PerfFlags.ARG_SMALL_ARITY_12) { + val a0 = this.elementAt(0).value.execute(scope) + val a1 = this.elementAt(1).value.execute(scope) + val a2 = this.elementAt(2).value.execute(scope) + val a3 = this.elementAt(3).value.execute(scope) + val a4 = this.elementAt(4).value.execute(scope) + val a5 = this.elementAt(5).value.execute(scope) + val a6 = this.elementAt(6).value.execute(scope) + val a7 = this.elementAt(7).value.execute(scope) + val a8 = this.elementAt(8).value.execute(scope) + val a9 = this.elementAt(9).value.execute(scope) + val a10 = this.elementAt(10).value.execute(scope) + Arguments(listOf(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10), tailBlockMode) + } else null + 12 -> if (PerfFlags.ARG_SMALL_ARITY_12) { + val a0 = this.elementAt(0).value.execute(scope) + val a1 = this.elementAt(1).value.execute(scope) + val a2 = this.elementAt(2).value.execute(scope) + val a3 = this.elementAt(3).value.execute(scope) + val a4 = this.elementAt(4).value.execute(scope) + val a5 = this.elementAt(5).value.execute(scope) + val a6 = this.elementAt(6).value.execute(scope) + val a7 = this.elementAt(7).value.execute(scope) + val a8 = this.elementAt(8).value.execute(scope) + val a9 = this.elementAt(9).value.execute(scope) + val a10 = this.elementAt(10).value.execute(scope) + val a11 = this.elementAt(11).value.execute(scope) + Arguments(listOf(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11), tailBlockMode) + } else null else -> null } if (quick != null) return quick } } - // Single-splat fast path: if there is exactly one splat argument that evaluates to ObjList, - // avoid builder and copies by returning its list directly. - if (PerfFlags.ARG_BUILDER) { - if (this.size == 1) { - val only = this.first() - if (only.isSplat) { - val v = only.value.execute(scope) - if (v is ObjList) { - return Arguments(v.list, tailBlockMode) - } else if (v.isInstanceOf(ObjIterable)) { - // Convert iterable to list once and return directly - val i = (v.invokeInstanceMethod(scope, "toList") as ObjList).list - return Arguments(i, tailBlockMode) - } else { - scope.raiseClassCastError("expected list of objects for splat argument") - } - } - } - } - // General path with builder or simple list fallback - if (PerfFlags.ARG_BUILDER) { - val b = ArgBuilderProvider.acquire() - try { - b.reset(this.size) - for (x in this) { - val value = x.value.execute(scope) - if (x.isSplat) { - when { - value is ObjList -> { - b.addAll(value.list) - } - value.isInstanceOf(ObjIterable) -> { - val i = (value.invokeInstanceMethod(scope, "toList") as ObjList).list - b.addAll(i) - } - else -> scope.raiseClassCastError("expected list of objects for splat argument") - } - } else { - b.add(value) - } - } - return b.build(tailBlockMode) - } finally { - b.release() + // General path: build positional list and named map, enforcing ordering rules + val positional: MutableList = mutableListOf() + var named: MutableMap? = null + var namedSeen = false + for ((idx, x) in this.withIndex()) { + if (x.name != null) { + // Named argument + if (named == null) named = linkedMapOf() + if (named!!.containsKey(x.name)) scope.raiseIllegalArgument("argument '${x.name}' is already set") + val v = x.value.execute(scope) + named!![x.name] = v + namedSeen = true + continue } - } else { - val list: MutableList = mutableListOf() - for (x in this) { - val value = x.value.execute(scope) - if (x.isSplat) { - when { - value is ObjList -> list.addAll(value.list) - value.isInstanceOf(ObjIterable) -> { - val i = (value.invokeInstanceMethod(scope, "toList") as ObjList).list - list.addAll(i) + val value = x.value.execute(scope) + if (x.isSplat) { + when { + // IMPORTANT: handle ObjMap BEFORE generic Iterable to ensure map splats + // are treated as named splats, not as positional iteration over entries + value is ObjMap -> { + if (named == null) named = linkedMapOf() + for ((k, v) in value.map) { + if (k !is ObjString) scope.raiseIllegalArgument("named splat expects a Map with string keys") + val key = k.value + if (named!!.containsKey(key)) scope.raiseIllegalArgument("argument '$key' is already set") + named!![key] = v } - else -> scope.raiseClassCastError("expected list of objects for splat argument") + namedSeen = true } - } else { - list.add(value) + value is ObjList -> { + if (namedSeen) { + // allow only if this is the very last positional which will be the trailing block; but + // splat can never be a trailing block, so it's always illegal here + scope.raiseIllegalArgument("positional splat cannot follow named arguments") + } + positional.addAll(value.list) + } + value.isInstanceOf(ObjIterable) -> { + if (namedSeen) scope.raiseIllegalArgument("positional splat cannot follow named arguments") + val i = (value.invokeInstanceMethod(scope, "toList") as ObjList).list + positional.addAll(i) + } + else -> scope.raiseClassCastError("expected list of objects for splat argument") } + } else { + if (namedSeen) { + // Allow exactly one positional after named only when it is the very last argument overall + // and tailBlockMode is true (syntactic trailing block). Otherwise, forbid it. + val isLast = idx == this.size - 1 + if (!(isLast && tailBlockMode)) + scope.raiseIllegalArgument("positional argument cannot follow named arguments") + } + positional.add(value) } - return Arguments(list, tailBlockMode) } + val namedFinal = named ?: emptyMap() + return Arguments(positional, tailBlockMode, namedFinal) } - data class Arguments(val list: List, val tailBlockMode: Boolean = false) : List by list { + data class Arguments( + val list: List, + val tailBlockMode: Boolean = false, + val named: Map = emptyMap(), + ) : List by list { constructor(vararg values: Obj) : this(values.toList()) diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt index f6211db..b5528a5 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt @@ -881,6 +881,21 @@ class Compiler( private suspend fun parseArgs(): Pair, Boolean> { val args = mutableListOf() + suspend fun tryParseNamedArg(): ParsedArgument? { + val save = cc.savePos() + val t1 = cc.next() + if (t1.type == Token.Type.ID) { + val t2 = cc.next() + if (t2.type == Token.Type.COLON) { + // name: expr + val name = t1.value + val rhs = parseExpression() ?: t2.raiseSyntax("expected expression after named argument '${name}:'") + return ParsedArgument(rhs, t1.pos, isSplat = false, name = name) + } + } + cc.restorePos(save) + return null + } do { val t = cc.next() when (t.type) { @@ -895,10 +910,14 @@ class Compiler( else -> { cc.previous() - parseExpression()?.let { args += ParsedArgument(it, t.pos) } - ?: throw ScriptError(t.pos, "Expecting arguments list") - if (cc.current().type == Token.Type.COLON) - parseTypeDeclaration() + val named = tryParseNamedArg() + if (named != null) { + args += named + } else { + parseExpression()?.let { args += ParsedArgument(it, t.pos) } + ?: throw ScriptError(t.pos, "Expecting arguments list") + // In call-site arguments, ':' is reserved for named args. Do not parse type declarations here. + } // Here should be a valid termination: } } @@ -929,6 +948,20 @@ class Compiler( */ private suspend fun parseArgsNoTailBlock(): List { val args = mutableListOf() + suspend fun tryParseNamedArg(): ParsedArgument? { + val save = cc.savePos() + val t1 = cc.next() + if (t1.type == Token.Type.ID) { + val t2 = cc.next() + if (t2.type == Token.Type.COLON) { + val name = t1.value + val rhs = parseExpression() ?: t2.raiseSyntax("expected expression after named argument '${name}:'") + return ParsedArgument(rhs, t1.pos, isSplat = false, name = name) + } + } + cc.restorePos(save) + return null + } do { val t = cc.next() when (t.type) { @@ -943,10 +976,14 @@ class Compiler( else -> { cc.previous() - parseExpression()?.let { args += ParsedArgument(it, t.pos) } - ?: throw ScriptError(t.pos, "Expecting arguments list") - if (cc.current().type == Token.Type.COLON) - parseTypeDeclaration() + val named = tryParseNamedArg() + if (named != null) { + args += named + } else { + parseExpression()?.let { args += ParsedArgument(it, t.pos) } + ?: throw ScriptError(t.pos, "Expecting arguments list") + // Do not parse type declarations in call args + } } } } while (t.type != Token.Type.RPAREN) diff --git a/lynglib/src/commonTest/kotlin/NamedArgsTest.kt b/lynglib/src/commonTest/kotlin/NamedArgsTest.kt new file mode 100644 index 0000000..24b0dc9 --- /dev/null +++ b/lynglib/src/commonTest/kotlin/NamedArgsTest.kt @@ -0,0 +1,154 @@ +/* + * 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. + * + */ + +/* + * Named arguments and named splats test suite + */ + +import kotlinx.coroutines.test.runTest +import net.sergeych.lyng.ExecutionError +import net.sergeych.lyng.eval +import kotlin.test.Test +import kotlin.test.assertFailsWith + +class NamedArgsTest { + + @Test + fun basicNamedArgsAndDefaults() = runTest { + eval( + """ + fun test(a="foo", b="bar", c="bazz") { [a, b, c] } + assertEquals( ["foo", "b", "bazz"], test(b: "b") ) + assertEquals( ["a", "bar", "c"], test("a", c: "c") ) + """.trimIndent() + ) + } + + @Test + fun positionalAfterNamedIsError() = runTest { + assertFailsWith { + eval( + """ + fun f(a, b) { [a,b] } + f(a: 1, 2) + """.trimIndent() + ) + } + } + + @Test + fun namedSplatsBasic() = runTest { + eval( + """ + fun test(a="a", b="b", c="c", d="d") { [a, b, c, d] } + val r = test("A?", ...Map("d" => "D!", "b" => "B!")) + assertEquals(["A?","B!","c","D!"], r) + """.trimIndent() + ) + } + + @Test + fun namedSplatsNonStringKeysError() = runTest { + assertFailsWith { + eval( + """ + fun test(a,b) {} + test(1, ...Map(1 => "x")) + """.trimIndent() + ) + } + } + + @Test + fun trailingBlockConflictWhenLastNamed() = runTest { + // Error: last parameter already assigned by a named argument; trailing block must be rejected + assertFailsWith { + eval( + """ + fun f(x, onDone) { onDone(x) } + // Name the last parameter inside parentheses, then try to pass a trailing block + f(1, onDone: { it }) { 42 } + """.trimIndent() + ) + } + // Normal case still works when last parameter is not assigned by name + eval( + """ + fun f(x, onDone) { onDone(x) } + var res = 0 + f(1) { it -> res = it } + assertEquals(1, res) + """.trimIndent() + ) + } + + @Test + fun duplicateNamedIsError() = runTest { + assertFailsWith { + eval( + """ + fun f(a,b,c) {} + f(a: 1, a: 2) + """.trimIndent() + ) + } + assertFailsWith { + eval( + """ + fun f(a,b,c) {} + f(a: 1, ...Map("a" => 2)) + """.trimIndent() + ) + } + } + + @Test + fun unknownParameterIsError() = runTest { + assertFailsWith { + eval( + """ + fun f(a,b) {} + f(z: 1) + """.trimIndent() + ) + } + } + + @Test + fun ellipsisCannotBeNamed() = runTest { + assertFailsWith { + eval( + """ + fun g(args..., tail) {} + g(args: [1], tail: 2) + """.trimIndent() + ) + } + } + + @Test + fun positionalSplatAfterNamedIsError() = runTest { + assertFailsWith { + eval( + """ + fun f(a,b,c) {} + f(a: 1, ...[2,3]) + """.trimIndent() + ) + } + } +} diff --git a/site/src/jsTest/kotlin/HighlightSmokeTest.kt b/site/src/jsTest/kotlin/HighlightSmokeTest.kt index 69ced4f..651eb07 100644 --- a/site/src/jsTest/kotlin/HighlightSmokeTest.kt +++ b/site/src/jsTest/kotlin/HighlightSmokeTest.kt @@ -63,4 +63,29 @@ class HighlightSmokeTest { assertTrue(html.contains("hl-op")) } + @Test + fun highlightNamedArgsAndMapSplat() { + val text = """ + fun test(a,b,c,d) { [a,b,c,d] } + val r = test("A?", c: "C!", ...Map("d" => "D!", "b" => "B!")) + """.trimIndent() + val spans = SimpleLyngHighlighter().highlight(text) + val labeled = spansToLabeled(text, spans) + // Ensure identifier for function name appears + assertTrue(labeled.any { it.first == "test" && it.second == HighlightKind.Identifier }) + // Ensure colon for named argument is tokenized as punctuation + assertTrue(labeled.any { it.first == ":" && it.second == HighlightKind.Punctuation }) + // Ensure ellipsis operator present + assertTrue(labeled.any { it.first == "..." && it.second == HighlightKind.Operator }) + // Ensure Map identifier is present + assertTrue(labeled.any { it.first == "Map" && it.second == HighlightKind.Identifier }) + } + + @Test + fun highlightNamedArgsHtml() { + val text = "val res = test( a: 1, b: 2 )" + val html = SiteHighlight.renderHtml(text) + // Expect a colon wrapped as punctuation span + assertTrue(html.contains(":")) + } }