Skip to content

Commit dfdade6

Browse files
committed
feat: add nameless identity function
1 parent 61459ae commit dfdade6

16 files changed

Lines changed: 543 additions & 27 deletions

File tree

docs/syntax-of-a-function-call.qd

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,18 @@ You can append additional arguments to any function in the chain. Just keep in m
9898

9999
Many core functions are designed to be called in a chain, for example [`None` operations](none.qd#examples).
100100

101+
### Identity function
102+
103+
A nameless function call uses the syntax `.{value}`, omitting the function name entirely. It acts as an identity function, passing its argument through unchanged. This is especially useful as a starting point for a chain when you want to begin with a literal value:
104+
105+
.examplemirror
106+
.{30}::multiply {2}
107+
108+
.examplemirror
109+
.{Hello}::text variant:{smallcaps}
110+
111+
Without the nameless call, you would need to either use a variable or nest the calls manually.
112+
101113
## Tight function calls
102114

103115
A function call must normally be surrounded by whitespace, a symbol, or the beginning or end of a line. This means that a call directly adjacent to a word character is not recognized:

quarkdown-core/src/main/kotlin/com/quarkdown/core/lexer/patterns/FunctionCallPatterns.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,10 @@ class FunctionCallPatterns {
2424
wrap = { error("Inline function call tokens are constructed by the walker") },
2525
// The name of the function prefixed by a dot.
2626
regex =
27-
RegexBuilder("(?:(?<=before)(?=call))|(?:(?=wrapcall))")
27+
RegexBuilder("(?:(?<=before)(?=call))|(?:(?<=before)(?=beginarg))|(?:(?=wrapcall))|(?:(?=wrapbeginarg))")
2828
.withReference("before", FUNCTION_CALL_PATTERN_BEFORE)
2929
.withReference("call", "begin(name)")
30+
.withReference("beginarg", "begin" + Regex.escape(FunctionCallGrammar.ARGUMENT_BEGIN.toString()))
3031
.withReference("wrap", Regex.escape(FunctionCallGrammar.ARGUMENT_BEGIN.toString()))
3132
.withReference("begin", Regex.escape(FunctionCallGrammar.BEGIN.toString()))
3233
.withReference("name", FunctionCallGrammar.IDENTIFIER_PATTERN)

quarkdown-core/src/main/kotlin/com/quarkdown/core/parser/walker/funcall/FunctionCallGrammar.kt

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -241,31 +241,41 @@ class FunctionCallGrammar(
241241
}
242242

243243
/**
244-
* Parses a single function call.
245-
* A function call consists of a function name, inline arguments and an optional body argument.
244+
* Parses the arguments and optional body of a function call, shared by both [namedCallParser] and [namelessCallParser].
246245
*/
247-
private val callParser =
248-
(
249-
// Function name.
250-
identifier and
251-
// Inline arguments.
252-
zeroOrMore(argumentParser) and
253-
// Body argument.
254-
optional(-optional(whitespace) and bodyArgumentParser)
255-
) map { (id, args, body) ->
256-
WalkedFunctionCall(
257-
id.text,
258-
args,
259-
body,
260-
)
246+
private val argumentsParser =
247+
// Inline arguments.
248+
zeroOrMore(argumentParser) and
249+
// Body argument.
250+
optional(-optional(whitespace) and bodyArgumentParser)
251+
252+
/**
253+
* Parses a named function call, where the function name is required.
254+
* This is used for chained calls (after `::`) where a name is mandatory.
255+
*/
256+
private val namedCallParser =
257+
(identifier and argumentsParser) map { (id, argsAndBody) ->
258+
val (args, body) = argsAndBody
259+
WalkedFunctionCall(id.text, args, body)
260+
}
261+
262+
/**
263+
* Parses a nameless (identity) function call, where the function name is omitted (e.g. `.{value}`).
264+
* This is only allowed as the first call in a chain, not after `::`.
265+
*/
266+
private val namelessCallParser =
267+
argumentsParser map { (args, body) ->
268+
WalkedFunctionCall("", args, body)
261269
}
262270

263271
/**
264272
* Parses a chain of function calls, separated by [chainSeparator].
273+
* The first call in the chain may be nameless (e.g. `.{value}::bar`),
274+
* but subsequent calls after `::` must have a name.
265275
* The result is an ordered linked list of [WalkedFunctionCall]s, and the first of them is returned.
266276
*/
267277
private val chainCallParser =
268-
callParser and zeroOrMore(-chainSeparator and callParser) map { (first, rest) ->
278+
(namedCallParser or namelessCallParser) and zeroOrMore(-chainSeparator and namedCallParser) map { (first, rest) ->
269279
var current = first
270280
for (next in rest) {
271281
current.next = next

quarkdown-core/src/test/kotlin/com/quarkdown/core/BlockParserTest.kt

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1222,6 +1222,21 @@ class BlockParserTest {
12221222
)
12231223
}
12241224
}
1225+
1226+
// Nameless (identity) function calls.
1227+
1228+
with(nodes.next()) {
1229+
assertEquals("", name)
1230+
assertEquals(1, arguments.size)
1231+
assertEquals("hello", arguments[0].value.unwrappedValue)
1232+
}
1233+
1234+
with(nodes.next()) {
1235+
assertEquals("", name)
1236+
assertEquals(2, arguments.size)
1237+
assertEquals("arg1", arguments[0].value.unwrappedValue)
1238+
assertEquals("arg2", arguments[1].value.unwrappedValue)
1239+
}
12251240
}
12261241

12271242
/**
@@ -1314,6 +1329,50 @@ class BlockParserTest {
13141329
assertEquals("bar", (arguments.first().expression as UncheckedFunctionCall<*>).name)
13151330
}
13161331

1332+
// .{x}::bar {y}
1333+
with(nodes.next()) {
1334+
assertEquals("bar", name)
1335+
assertEquals(2, arguments.size)
1336+
assertEquals("", (arguments.first().expression as UncheckedFunctionCall<*>).name)
1337+
assertEquals("y", arguments[1].value.unwrappedValue)
1338+
}
1339+
1340+
// .{10}::multiply {2}::sum {5}
1341+
with(nodes.next()) {
1342+
assertEquals("sum", name)
1343+
assertEquals(2, arguments.size)
1344+
assertEquals("5", arguments[1].value.unwrappedValue)
1345+
val multiply = arguments.first().expression as UncheckedFunctionCall<*>
1346+
assertEquals("multiply", multiply.name)
1347+
}
1348+
13171349
assertFalse(nodes.hasNext())
13181350
}
1351+
1352+
/**
1353+
* Verifies that nameless (identity) function calls are parsed correctly
1354+
* as inline function calls within paragraphs.
1355+
*/
1356+
@Test
1357+
fun namelessInlineFunctionCall() {
1358+
// Nameless call between text.
1359+
with(blocksIterator<Paragraph>("hello .{x} world").next()) {
1360+
val children = children.iterator()
1361+
assertEquals("hello ", assertIs<Text>(children.next()).text)
1362+
with(assertIs<FunctionCallNode>(children.next())) {
1363+
assertEquals("", name)
1364+
assertEquals(1, arguments.size)
1365+
assertEquals("x", arguments[0].value.unwrappedValue)
1366+
}
1367+
assertEquals(" world", assertIs<Text>(children.next()).text)
1368+
assertFalse(children.hasNext())
1369+
}
1370+
1371+
// Wrapped nameless call.
1372+
with(blocksIterator<FunctionCallNode>("{.{x}}").next()) {
1373+
assertEquals("", name)
1374+
assertEquals(1, arguments.size)
1375+
assertEquals("x", arguments[0].value.unwrappedValue)
1376+
}
1377+
}
13191378
}

quarkdown-core/src/test/kotlin/com/quarkdown/core/LexerTest.kt

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,50 @@ class LexerTest {
672672
assertEquals("bar", value.next!!.name)
673673
assertEquals(1, value.next!!.arguments.size)
674674
}
675+
676+
// Nameless (identity) function calls.
677+
678+
with(walk(".{x}")) {
679+
assertEquals("", value.name)
680+
assertEquals(".{x}".length, endIndex)
681+
with(value.arguments.single()) {
682+
assertEquals("x", value)
683+
assertNull(name)
684+
}
685+
}
686+
687+
with(walk(".{x} {y}")) {
688+
assertEquals("", value.name)
689+
assertEquals("x", value.arguments[0].value)
690+
assertEquals("y", value.arguments[1].value)
691+
}
692+
693+
with(walk(".{x}::bar {y}")) {
694+
assertEquals("", value.name)
695+
assertEquals(1, value.arguments.size)
696+
assertEquals("bar", value.next!!.name)
697+
assertEquals(1, value.next!!.arguments.size)
698+
}
699+
700+
// Trailing `::` should not produce a nameless chained call.
701+
with(walk(".foo::")) {
702+
assertEquals("foo", value.name)
703+
assertNull(value.next)
704+
// The `::` is not consumed: the walker stops at the end of the named call.
705+
assertEquals(".foo".length, endIndex)
706+
}
707+
708+
with(walk(".{x}::bar {y}::baz {z}")) {
709+
assertEquals("", value.name)
710+
assertEquals("bar", value.next!!.name)
711+
assertEquals("baz", value.next!!.next!!.name)
712+
}
713+
714+
with(walk("{.{x}}")) {
715+
assertEquals("", value.name)
716+
assertEquals("{.{x}}".length, endIndex)
717+
assertEquals("x", value.arguments.single().value)
718+
}
675719
}
676720

677721
/**
@@ -851,4 +895,50 @@ class LexerTest {
851895
assertEquals("second", tokens[1].walkerResult.value.name)
852896
assertEquals("third", tokens[2].walkerResult.value.name)
853897
}
898+
899+
/**
900+
* Verifies that nameless (identity) function calls are tokenized correctly at the inline level.
901+
*/
902+
@Test
903+
fun namelessInlineFunctionCall() {
904+
with(inlineLex(".{x}")) {
905+
assertIs<FunctionCallToken>(next())
906+
assertFalse(hasNext())
907+
}
908+
909+
with(inlineLex("hello .{x} world")) {
910+
assertIs<PlainTextToken>(next())
911+
assertIs<FunctionCallToken>(next())
912+
assertIs<PlainTextToken>(next())
913+
assertFalse(hasNext())
914+
}
915+
916+
with(inlineLex("{.{x}}")) {
917+
assertIs<FunctionCallToken>(next())
918+
assertFalse(hasNext())
919+
}
920+
}
921+
922+
/**
923+
* Verifies that nameless (identity) function calls are tokenized correctly at the block level.
924+
*/
925+
@Test
926+
fun namelessBlockFunctionCall() {
927+
val tokens =
928+
blockLexer(".{hello}")
929+
.tokenize()
930+
.filterIsInstance<FunctionCallToken>()
931+
.toList()
932+
933+
assertEquals(1, tokens.size)
934+
with(tokens.single()) {
935+
assertEquals("", walkerResult.value.name)
936+
assertEquals(
937+
"hello",
938+
walkerResult.value.arguments
939+
.single()
940+
.value,
941+
)
942+
}
943+
}
854944
}

quarkdown-core/src/test/resources/parsing/functioncall-chain.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,8 @@
22

33
.foo {x}::bar name:{y}
44

5-
.foo {x}::bar {y}::baz {z}
5+
.foo {x}::bar {y}::baz {z}
6+
7+
.{x}::bar {y}
8+
9+
.{10}::multiply {2}::sum {5}

quarkdown-core/src/test/resources/parsing/functioncall.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,8 @@ not body
4242
.function {arg{1}} {arg2}}
4343

4444
.function {arg{1} arg} { { arg2 } }
45-
body content
45+
body content
46+
47+
.{hello}
48+
49+
.{arg1} {arg2}

quarkdown-lsp/src/main/kotlin/com/quarkdown/lsp/cache/CacheableFunctionCatalogue.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,5 +63,5 @@ object CacheableFunctionCatalogue {
6363
nameQuery: String,
6464
): Sequence<DocumentedFunction> =
6565
getCatalogue(docsDirectory)
66-
.filter { it.data.name.startsWith(nameQuery, ignoreCase = true) }
66+
.filter { it.data.name.isNotEmpty() && it.data.name.startsWith(nameQuery, ignoreCase = true) }
6767
}

quarkdown-lsp/src/test/kotlin/com/quarkdown/lsp/FunctionCallTokenizerTest.kt

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,4 +249,86 @@ class FunctionCallTokenizerTest {
249249
assertNotNull(paramNameToken)
250250
assertEquals("param", paramNameToken.lexeme)
251251
}
252+
253+
@Test
254+
fun `nameless function call`() {
255+
val text = ".{hello}"
256+
val calls = tokenizer.getFunctionCalls(text)
257+
258+
assertEquals(1, calls.size)
259+
260+
val call = calls.first()
261+
assertEquals(0..text.length, call.range)
262+
263+
// No FUNCTION_NAME token should be present.
264+
val nameToken = call.tokens.find { it.type == FunctionCallToken.Type.FUNCTION_NAME }
265+
assertEquals(null, nameToken)
266+
267+
// The BEGIN token is still present.
268+
val beginToken = call.tokens.find { it.type == FunctionCallToken.Type.BEGIN }
269+
assertNotNull(beginToken)
270+
assertEquals(0..1, beginToken.range)
271+
272+
// The argument is tokenized normally.
273+
val argValueToken = call.tokens.find { it.type == FunctionCallToken.Type.INLINE_ARGUMENT_VALUE }
274+
assertNotNull(argValueToken)
275+
assertEquals("hello", argValueToken.lexeme)
276+
}
277+
278+
@Test
279+
fun `nameless function call with chaining`() {
280+
val text = ".{x}::bar {y}"
281+
val calls = tokenizer.getFunctionCalls(text)
282+
283+
assertEquals(1, calls.size)
284+
285+
val call = calls.first()
286+
val tokens = call.tokens.iterator()
287+
288+
assertEquals(FunctionCallToken.Type.BEGIN, tokens.next().type)
289+
// No FUNCTION_NAME for the nameless part — argument follows directly.
290+
assertEquals(FunctionCallToken.Type.INLINE_ARGUMENT_BEGIN, tokens.next().type)
291+
assertEquals(FunctionCallToken.Type.INLINE_ARGUMENT_VALUE, tokens.next().type)
292+
assertEquals(FunctionCallToken.Type.INLINE_ARGUMENT_END, tokens.next().type)
293+
assertEquals(FunctionCallToken.Type.CHAINING_SEPARATOR, tokens.next().type)
294+
// The chained function has a name.
295+
with(tokens.next()) {
296+
assertEquals(FunctionCallToken.Type.FUNCTION_NAME, type)
297+
assertEquals("bar", lexeme)
298+
}
299+
assertEquals(FunctionCallToken.Type.INLINE_ARGUMENT_BEGIN, tokens.next().type)
300+
assertEquals(FunctionCallToken.Type.INLINE_ARGUMENT_VALUE, tokens.next().type)
301+
assertEquals(FunctionCallToken.Type.INLINE_ARGUMENT_END, tokens.next().type)
302+
assertFalse(tokens.hasNext())
303+
}
304+
305+
@Test
306+
fun `nameless function call in text`() {
307+
val text = "hello .{world} goodbye"
308+
val calls = tokenizer.getFunctionCalls(text)
309+
310+
assertEquals(1, calls.size)
311+
312+
val call = calls.first()
313+
val nameToken = call.tokens.find { it.type == FunctionCallToken.Type.FUNCTION_NAME }
314+
assertEquals(null, nameToken)
315+
316+
val argValueToken = call.tokens.find { it.type == FunctionCallToken.Type.INLINE_ARGUMENT_VALUE }
317+
assertNotNull(argValueToken)
318+
assertEquals("world", argValueToken.lexeme)
319+
}
320+
321+
@Test
322+
fun `wrapped nameless function call`() {
323+
val text = "{.{x}}"
324+
val calls = tokenizer.getFunctionCalls(text)
325+
326+
assertEquals(1, calls.size)
327+
328+
val call = calls.first()
329+
assertEquals(0..text.length, call.range)
330+
331+
val nameToken = call.tokens.find { it.type == FunctionCallToken.Type.FUNCTION_NAME }
332+
assertEquals(null, nameToken)
333+
}
252334
}

0 commit comments

Comments
 (0)