• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

orneryd / NornicDB / 29040798621

09 Jul 2026 06:28PM UTC coverage: 89.12% (-0.01%) from 89.131%
29040798621

push

github

orneryd
fix(cypher): multiple create hotpath query fixes

22 of 35 new or added lines in 4 files covered. (62.86%)

981 existing lines in 19 files now uncovered.

146342 of 164207 relevant lines covered (89.12%)

1.04 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.61
/pkg/cypher/pattern_parser.go
1
// Pattern parsing for NornicDB Cypher.
2
//
3
// This file contains functions for parsing Cypher node and relationship patterns.
4
// Patterns are the core syntax for describing graph structures in queries.
5
//
6
// # Pattern Syntax
7
//
8
// Node patterns:
9
//
10
//        (n)                    - Anonymous node
11
//        (n:Label)              - Node with label
12
//        (n:Label {prop: val})  - Node with label and properties
13
//        (:Label)               - Anonymous node with label
14
//
15
// Property patterns:
16
//
17
//        {name: 'Alice'}        - String property
18
//        {age: 30}              - Integer property
19
//        {active: true}         - Boolean property
20
//        {tags: ['a', 'b']}     - Array property
21
//
22
// # Parsing Process
23
//
24
//  1. parseNodePattern - Extract variable, labels, and properties
25
//  2. parseProperties - Parse {key: value, ...} syntax
26
//  3. parsePropertyValue - Convert string values to Go types
27
//  4. parseArrayValue - Handle array literals [1, 2, 3]
28
//
29
// # ELI12
30
//
31
// Pattern parsing is like reading a recipe:
32
//
33
//        "(alice:Person {name: 'Alice', age: 30})"
34
//
35
// The parser breaks this down:
36
//   - Variable: "alice" (what we'll call this in our query)
37
//   - Label: "Person" (what type of thing it is)
38
//   - Properties: name='Alice', age=30 (details about it)
39
//
40
// It's like reading "the red ball" and understanding:
41
//   - "ball" is what it is
42
//   - "red" describes it
43
//
44
// # Neo4j Compatibility
45
//
46
// Pattern parsing matches Neo4j Cypher syntax exactly for compatibility.
47

48
package cypher
49

50
import (
51
        "context"
52
        "fmt"
53
        "strconv"
54
        "strings"
55
)
56

57
// containsReservedKeyword checks if a string contains Cypher reserved keywords
58
// or special characters that could indicate an injection attempt.
59
// Only matches WHOLE keywords (not substrings like "OR" in "Order").
60
func containsReservedKeyword(s string) bool {
1✔
61
        // Check for special characters that shouldn't be in identifiers
1✔
62
        dangerousChars := []string{"--", "//", "/*", "*/", ";", "`", "\"", "'", "(", ")", "[", "]", "{", "}", ","}
1✔
63
        for _, ch := range dangerousChars {
2✔
64
                if strings.Contains(s, ch) {
2✔
65
                        return true
1✔
66
                }
1✔
67
        }
68
        // Only check for space (indicates multiple tokens which is invalid for identifiers)
69
        if strings.Contains(s, " ") {
2✔
70
                return true
1✔
71
        }
1✔
72
        return false
1✔
73
}
74

75
// parseNodePattern parses a Cypher node pattern like (n:Label {prop: value}).
76
//
77
// # Parameters
78
//
79
//   - pattern: The node pattern string (with or without parentheses)
80
//
81
// # Returns
82
//
83
//   - nodePatternInfo containing variable, labels, and properties
84
//
85
// # Example
86
//
87
//        parseNodePattern(ctx, "(n:Person {name: 'Alice'})")
88
//        // Returns: {variable: "n", labels: ["Person"], properties: {"name": "Alice"}}
89
//
90
//        parseNodePattern(ctx, "(:Employee)")
91
//        // Returns: {variable: "", labels: ["Employee"], properties: {}}
92
func (e *StorageExecutor) parseNodePattern(ctx context.Context, pattern string) nodePatternInfo {
1✔
93
        info := nodePatternInfo{
1✔
94
                labels:     []string{},
1✔
95
                properties: make(map[string]interface{}),
1✔
96
        }
1✔
97

1✔
98
        // Remove outer parens
1✔
99
        pattern = strings.TrimSpace(pattern)
1✔
100
        if strings.HasPrefix(pattern, "(") && strings.HasSuffix(pattern, ")") {
2✔
101
                pattern = pattern[1 : len(pattern)-1]
1✔
102
        }
1✔
103

104
        // Extract properties
105
        braceIdx := strings.Index(pattern, "{")
1✔
106
        if braceIdx >= 0 {
2✔
107
                propsStr := pattern[braceIdx:]
1✔
108
                pattern = pattern[:braceIdx]
1✔
109
                info.properties = e.parseProperties(ctx, propsStr)
1✔
110
        }
1✔
111

112
        // Parse variable:Label:Label2
113
        parts := strings.Split(strings.TrimSpace(pattern), ":")
1✔
114
        if len(parts) > 0 && parts[0] != "" {
2✔
115
                info.variable = strings.TrimSpace(parts[0])
1✔
116
        }
1✔
117
        for i := 1; i < len(parts); i++ {
2✔
118
                if label := strings.TrimSpace(parts[i]); label != "" {
2✔
119
                        info.labels = append(info.labels, label)
1✔
120
                }
1✔
121
        }
122

123
        return info
1✔
124
}
125

126
// parseProperties parses a Cypher property map like {key1: value1, key2: value2}.
127
//
128
// # Parameters
129
//
130
//   - propsStr: The property map string (with or without braces)
131
//
132
// # Returns
133
//
134
//   - Map of property names to values (converted to Go types)
135
//
136
// # Example
137
//
138
//        parseProperties("{name: 'Alice', age: 30}")
139
//        // Returns: {"name": "Alice", "age": int64(30)}
140
//
141
//        parseProperties("{tags: ['a', 'b'], active: true}")
142
//        // Returns: {"tags": []interface{}{"a", "b"}, "active": true}
143
func (e *StorageExecutor) parseProperties(ctx context.Context, propsStr string) map[string]interface{} {
1✔
144
        props := make(map[string]interface{})
1✔
145

1✔
146
        // Remove outer braces
1✔
147
        propsStr = strings.TrimSpace(propsStr)
1✔
148
        if strings.HasPrefix(propsStr, "{") && strings.HasSuffix(propsStr, "}") {
2✔
149
                propsStr = propsStr[1 : len(propsStr)-1]
1✔
150
        }
1✔
151
        propsStr = strings.TrimSpace(propsStr)
1✔
152

1✔
153
        if propsStr == "" {
2✔
154
                return props
1✔
155
        }
1✔
156

157
        // Parse key-value pairs using a state machine that respects quotes, brackets, and nested structures
158
        pairs := e.splitPropertyPairs(propsStr)
1✔
159

1✔
160
        for _, pair := range pairs {
2✔
161
                colonIdx := findTopLevelMapKeyValueSeparator(pair)
1✔
162
                if colonIdx <= 0 {
2✔
163
                        continue
1✔
164
                }
165

166
                key := normalizePropertyKey(strings.TrimSpace(pair[:colonIdx]))
1✔
167
                valueStr := strings.TrimSpace(pair[colonIdx+1:])
1✔
168

1✔
169
                // Parse the value
1✔
170
                props[key] = e.parsePropertyValue(ctx, valueStr)
1✔
171
        }
172

173
        return props
1✔
174
}
175

176
func normalizePropertyKey(key string) string {
1✔
177
        key = strings.TrimSpace(key)
1✔
178
        if len(key) >= 2 {
2✔
179
                if strings.HasPrefix(key, "`") && strings.HasSuffix(key, "`") {
2✔
180
                        return strings.ReplaceAll(key[1:len(key)-1], "``", "`")
1✔
181
                }
1✔
182
                if (strings.HasPrefix(key, "'") && strings.HasSuffix(key, "'")) ||
1✔
183
                        (strings.HasPrefix(key, "\"") && strings.HasSuffix(key, "\"")) {
2✔
184
                        return key[1 : len(key)-1]
1✔
185
                }
1✔
186
        }
187
        return key
1✔
188
}
189

190
func findTopLevelMapKeyValueSeparator(pair string) int {
1✔
191
        depth := 0
1✔
192
        inSingle := false
1✔
193
        inDouble := false
1✔
194
        inBacktick := false
1✔
195

1✔
196
        for i := 0; i < len(pair); i++ {
2✔
197
                ch := pair[i]
1✔
198

1✔
199
                switch {
1✔
200
                case inBacktick:
1✔
201
                        if ch == '`' {
2✔
202
                                if i+1 < len(pair) && pair[i+1] == '`' {
1✔
UNCOV
203
                                        i++
×
UNCOV
204
                                        continue
×
205
                                }
206
                                inBacktick = false
1✔
207
                        }
208
                case inSingle:
1✔
209
                        if ch == '\'' {
2✔
210
                                if i+1 < len(pair) && pair[i+1] == '\'' {
1✔
UNCOV
211
                                        i++
×
UNCOV
212
                                        continue
×
213
                                }
214
                                if !isBackslashEscaped(pair, i) {
2✔
215
                                        inSingle = false
1✔
216
                                }
1✔
217
                        }
218
                case inDouble:
1✔
219
                        if ch == '"' {
2✔
220
                                if i+1 < len(pair) && pair[i+1] == '"' {
1✔
UNCOV
221
                                        i++
×
UNCOV
222
                                        continue
×
223
                                }
224
                                if !isBackslashEscaped(pair, i) {
2✔
225
                                        inDouble = false
1✔
226
                                }
1✔
227
                        }
228
                default:
1✔
229
                        switch ch {
1✔
230
                        case '\'':
1✔
231
                                inSingle = true
1✔
232
                        case '"':
1✔
233
                                inDouble = true
1✔
234
                        case '`':
1✔
235
                                inBacktick = true
1✔
UNCOV
236
                        case '{', '[', '(':
×
UNCOV
237
                                depth++
×
UNCOV
238
                        case '}', ']', ')':
×
UNCOV
239
                                if depth > 0 {
×
UNCOV
240
                                        depth--
×
UNCOV
241
                                }
×
242
                        case ':':
1✔
243
                                if depth == 0 {
2✔
244
                                        return i
1✔
245
                                }
1✔
246
                        }
247
                }
248
        }
249

250
        return -1
1✔
251
}
252

253
func isBackslashEscaped(s string, idx int) bool {
1✔
254
        if idx <= 0 {
1✔
UNCOV
255
                return false
×
UNCOV
256
        }
×
257
        backslashes := 0
1✔
258
        for i := idx - 1; i >= 0 && s[i] == '\\'; i-- {
1✔
UNCOV
259
                backslashes++
×
UNCOV
260
        }
×
261
        return backslashes%2 == 1
1✔
262
}
263

264
// splitPropertyPairs splits a property string into key:value pairs,
265
// respecting quotes, brackets, and nested braces.
266
//
267
// # Parameters
268
//
269
//   - propsStr: The property pairs string (without outer braces)
270
//
271
// # Returns
272
//
273
//   - Slice of "key: value" strings
274
//
275
// # Example
276
//
277
//        splitPropertyPairs("name: 'Alice', age: 30")
278
//        // Returns: ["name: 'Alice'", "age: 30"]
279
//
280
//        splitPropertyPairs("tags: ['a', 'b'], data: {nested: true}")
281
//        // Returns: ["tags: ['a', 'b']", "data: {nested: true}"]
282
func (e *StorageExecutor) splitPropertyPairs(propsStr string) []string {
1✔
283
        var pairs []string
1✔
284
        var current strings.Builder
1✔
285
        depth := 0 // Track [], {} nesting
1✔
286
        inQuote := false
1✔
287
        quoteChar := rune(0)
1✔
288

1✔
289
        for i, c := range propsStr {
2✔
290
                switch {
1✔
291
                case c == '\'' || c == '"':
1✔
292
                        if !inQuote {
2✔
293
                                inQuote = true
1✔
294
                                quoteChar = c
1✔
295
                        } else if c == quoteChar {
3✔
296
                                // Check for escaped quote (look back for \)
1✔
297
                                escaped := false
1✔
298
                                if i > 0 {
2✔
299
                                        // Count consecutive backslashes before this quote
1✔
300
                                        backslashes := 0
1✔
301
                                        for j := i - 1; j >= 0 && propsStr[j] == '\\'; j-- {
2✔
302
                                                backslashes++
1✔
303
                                        }
1✔
304
                                        escaped = backslashes%2 == 1
1✔
305
                                }
306
                                if !escaped {
2✔
307
                                        inQuote = false
1✔
308
                                }
1✔
309
                        }
310
                        current.WriteRune(c)
1✔
311
                case (c == '[' || c == '{' || c == '(') && !inQuote:
1✔
312
                        depth++
1✔
313
                        current.WriteRune(c)
1✔
314
                case (c == ']' || c == '}' || c == ')') && !inQuote:
1✔
315
                        depth--
1✔
316
                        current.WriteRune(c)
1✔
317
                case c == ',' && !inQuote && depth == 0:
1✔
318
                        if s := strings.TrimSpace(current.String()); s != "" {
2✔
319
                                pairs = append(pairs, s)
1✔
320
                        }
1✔
321
                        current.Reset()
1✔
322
                default:
1✔
323
                        current.WriteRune(c)
1✔
324
                }
325
        }
326

327
        // Add final pair
328
        if s := strings.TrimSpace(current.String()); s != "" {
2✔
329
                pairs = append(pairs, s)
1✔
330
        }
1✔
331

332
        return pairs
1✔
333
}
334

335
// parsePropertyValue parses a single property value string into the appropriate Go type.
336
//
337
// Supported types:
338
//   - null → nil
339
//   - 'string' or "string" → string
340
//   - true/false → bool
341
//   - 123 → int64
342
//   - 1.23 → float64
343
//   - [1, 2, 3] → []interface{}
344
//   - {key: value} → map[string]interface{}
345
//   - function() → evaluated result
346
//
347
// # Parameters
348
//
349
//   - valueStr: The value string to parse
350
//
351
// # Returns
352
//
353
//   - The parsed Go value
354
//
355
// # Example
356
//
357
//        parsePropertyValue("'Alice'")  // "Alice"
358
//        parsePropertyValue("30")       // int64(30)
359
//        parsePropertyValue("true")     // true
360
//        parsePropertyValue("[1, 2]")   // []interface{}{int64(1), int64(2)}
361
func (e *StorageExecutor) parsePropertyValue(ctx context.Context, valueStr string) interface{} {
1✔
362
        valueStr = strings.TrimSpace(valueStr)
1✔
363

1✔
364
        if valueStr == "" {
2✔
365
                return nil
1✔
366
        }
1✔
367

368
        // Handle $param and dotted $param.path references directly so typed values
369
        // survive intact during pattern parsing.
370
        if v, ok := resolveParamPathRef(ctx, valueStr); ok {
2✔
371
                return normalizePropValue(v)
1✔
372
        }
1✔
373
        if v, ok := resolveContextPathRef(ctx, valueStr); ok {
2✔
374
                return normalizePropValue(v)
1✔
375
        }
1✔
376

377
        // Handle bare $param references directly so the typed value (e.g. []string,
378
        // []float64) survives intact. Without this, the param-skip in
379
        // substituteParams leaves "$name" as literal text here and the
380
        // remaining branches would either misparse it or fall through to the
381
        // invalid-value catch-all.
382
        if v, ok := resolveDirectParamRef(ctx, valueStr); ok {
1✔
UNCOV
383
                return normalizePropValue(v)
×
UNCOV
384
        }
×
385

386
        // Handle null
387
        if strings.EqualFold(valueStr, "null") {
2✔
388
                return nil
1✔
389
        }
1✔
390

391
        // Handle quoted strings
392
        if len(valueStr) >= 2 {
2✔
393
                first, last := valueStr[0], valueStr[len(valueStr)-1]
1✔
394
                if (first == '\'' && last == '\'') || (first == '"' && last == '"') {
2✔
395
                        if decoded, ok := decodeCypherQuotedString(valueStr); ok {
2✔
396
                                return decoded
1✔
397
                        }
1✔
398
                }
399
        }
400

401
        // Handle booleans
402
        lowerVal := strings.ToLower(valueStr)
1✔
403
        if lowerVal == "true" {
2✔
404
                return true
1✔
405
        }
1✔
406
        if lowerVal == "false" {
2✔
407
                return false
1✔
408
        }
1✔
409

410
        // Handle integers
411
        if intVal, err := strconv.ParseInt(valueStr, 10, 64); err == nil {
2✔
412
                return intVal
1✔
413
        }
1✔
414

415
        // Handle floats
416
        if floatVal, err := strconv.ParseFloat(valueStr, 64); err == nil {
2✔
417
                return floatVal
1✔
418
        }
1✔
419

420
        // Handle arrays
421
        if strings.HasPrefix(valueStr, "[") && strings.HasSuffix(valueStr, "]") {
2✔
422
                return e.parseArrayValue(ctx, valueStr)
1✔
423
        }
1✔
424

425
        // Handle nested maps (rare in properties, but possible)
426
        if strings.HasPrefix(valueStr, "{") && strings.HasSuffix(valueStr, "}") {
2✔
427
                return e.parseProperties(ctx, valueStr)
1✔
428
        }
1✔
429

430
        // Handle expression-valued properties such as 't' + toString(0).
431
        // Keep this after scalar/list/map literals so ordinary property values do
432
        // not get routed through expression evaluation unnecessarily.
433
        if hasTopLevelPlus(valueStr) {
2✔
434
                return normalizePropValue(e.evaluateStringConcatForProperty(ctx, valueStr))
1✔
435
        }
1✔
436

437
        // Handle function calls like kalman.init(), toUpper('test'), etc.
438
        // A function call has the pattern: name(...) or name.sub.name(...)
439
        if looksLikeFunctionCall(valueStr) {
2✔
440
                result := e.evaluateExpressionWithContext(ctx, valueStr, nil, nil)
1✔
441
                // Only use the result if evaluation succeeded (not returned as original string)
1✔
442
                if result != nil && result != valueStr {
2✔
443
                        return result
1✔
444
                }
1✔
445
        }
446

447
        // Check for malformed values (unquoted colon indicates injection attempt or syntax error)
448
        if strings.Contains(valueStr, ":") && !strings.HasPrefix(valueStr, "{") {
2✔
449
                // Return a special marker that will trigger validation error
1✔
450
                return invalidPropertyValue{raw: valueStr}
1✔
451
        }
1✔
452

453
        // Otherwise return as string (handles unquoted identifiers, etc.)
454
        return valueStr
1✔
455
}
456

457
func hasTopLevelPlus(expr string) bool {
1✔
458
        inQuote := false
1✔
459
        quoteChar := rune(0)
1✔
460
        parenDepth := 0
1✔
461
        bracketDepth := 0
1✔
462
        braceDepth := 0
1✔
463

1✔
464
        for i, c := range expr {
2✔
465
                switch {
1✔
466
                case c == '\'' || c == '"':
1✔
467
                        if !inQuote {
2✔
468
                                inQuote = true
1✔
469
                                quoteChar = c
1✔
470
                        } else if c == quoteChar {
3✔
471
                                inQuote = false
1✔
472
                        }
1✔
473
                case c == '(' && !inQuote:
1✔
474
                        parenDepth++
1✔
475
                case c == ')' && !inQuote:
1✔
476
                        parenDepth--
1✔
477
                case c == '[' && !inQuote:
1✔
478
                        bracketDepth++
1✔
479
                case c == ']' && !inQuote:
1✔
480
                        bracketDepth--
1✔
481
                case c == '{' && !inQuote:
1✔
482
                        braceDepth++
1✔
483
                case c == '}' && !inQuote:
1✔
484
                        braceDepth--
1✔
485
                case c == '+' && !inQuote && parenDepth == 0 && bracketDepth == 0 && braceDepth == 0:
1✔
486
                        if i > 0 && i+1 < len(expr) && expr[i-1] != '+' && expr[i+1] != '+' && expr[i+1] != '=' {
2✔
487
                                return true
1✔
488
                        }
1✔
489
                }
490
        }
491
        return false
1✔
492
}
493

494
func (e *StorageExecutor) evaluateStringConcatForProperty(ctx context.Context, expr string) string {
1✔
495
        var result strings.Builder
1✔
496
        for _, part := range e.splitByPlus(expr) {
2✔
497
                part = strings.TrimSpace(part)
1✔
498
                if part == "" {
1✔
UNCOV
499
                        continue
×
500
                }
501
                if v, ok := resolveParamPathRef(ctx, part); ok {
1✔
UNCOV
502
                        result.WriteString(fmt.Sprintf("%v", v))
×
UNCOV
503
                        continue
×
504
                }
505
                if v, ok := resolveContextPathRef(ctx, part); ok {
1✔
UNCOV
506
                        result.WriteString(fmt.Sprintf("%v", v))
×
UNCOV
507
                        continue
×
508
                }
509
                result.WriteString(fmt.Sprintf("%v", e.evaluateExpressionWithContext(ctx, part, nil, nil)))
1✔
510
        }
511
        return result.String()
1✔
512
}
513

514
// invalidPropertyValue marks a property value that failed parsing validation
515
type invalidPropertyValue struct {
516
        raw string
517
}
518

519
// parseArrayValue parses a Cypher array literal like [1, 2, 3] or ['a', 'b', 'c'].
520
//
521
// # Parameters
522
//
523
//   - arrayStr: The array literal string (with brackets)
524
//
525
// # Returns
526
//
527
//   - Slice of parsed values
528
//
529
// # Example
530
//
531
//        parseArrayValue("[1, 2, 3]")      // []interface{}{int64(1), int64(2), int64(3)}
532
//        parseArrayValue("['a', 'b']")    // []interface{}{"a", "b"}
533
//        parseArrayValue("[[1], [2]]")    // []interface{}{[]interface{}{1}, []interface{}{2}}
534
func (e *StorageExecutor) parseArrayValue(ctx context.Context, arrayStr string) []interface{} {
1✔
535
        // Remove brackets
1✔
536
        inner := strings.TrimSpace(arrayStr[1 : len(arrayStr)-1])
1✔
537
        if inner == "" {
2✔
538
                return []interface{}{}
1✔
539
        }
1✔
540

541
        // Split array elements respecting nested structures
542
        elements := e.splitArrayElements(inner)
1✔
543
        result := make([]interface{}, len(elements))
1✔
544

1✔
545
        for i, elem := range elements {
2✔
546
                result[i] = e.parsePropertyValue(ctx, strings.TrimSpace(elem))
1✔
547
        }
1✔
548

549
        return result
1✔
550
}
551

552
// splitArrayElements splits array contents by comma, respecting nested structures and quotes.
553
//
554
// # Parameters
555
//
556
//   - inner: The array contents (without brackets)
557
//
558
// # Returns
559
//
560
//   - Slice of element strings
561
//
562
// # Example
563
//
564
//        splitArrayElements("1, 2, 3")            // ["1", "2", "3"]
565
//        splitArrayElements("'a,b', 'c'")         // ["'a,b'", "'c'"]
566
//        splitArrayElements("[1, 2], [3, 4]")     // ["[1, 2]", "[3, 4]"]
567
func (e *StorageExecutor) splitArrayElements(inner string) []string {
1✔
568
        var elements []string
1✔
569
        var current strings.Builder
1✔
570
        depth := 0
1✔
571
        inQuote := false
1✔
572
        quoteChar := rune(0)
1✔
573

1✔
574
        for i, c := range inner {
2✔
575
                switch {
1✔
576
                case c == '\'' || c == '"':
1✔
577
                        if !inQuote {
2✔
578
                                inQuote = true
1✔
579
                                quoteChar = c
1✔
580
                        } else if c == quoteChar {
3✔
581
                                escaped := false
1✔
582
                                if i > 0 && inner[i-1] == '\\' {
2✔
583
                                        escaped = true
1✔
584
                                }
1✔
585
                                if !escaped {
2✔
586
                                        inQuote = false
1✔
587
                                }
1✔
588
                        }
589
                        current.WriteRune(c)
1✔
590
                case (c == '[' || c == '{') && !inQuote:
1✔
591
                        depth++
1✔
592
                        current.WriteRune(c)
1✔
593
                case (c == ']' || c == '}') && !inQuote:
1✔
594
                        depth--
1✔
595
                        current.WriteRune(c)
1✔
596
                case c == ',' && !inQuote && depth == 0:
1✔
597
                        if s := strings.TrimSpace(current.String()); s != "" {
2✔
598
                                elements = append(elements, s)
1✔
599
                        }
1✔
600
                        current.Reset()
1✔
601
                default:
1✔
602
                        current.WriteRune(c)
1✔
603
                }
604
        }
605

606
        if s := strings.TrimSpace(current.String()); s != "" {
2✔
607
                elements = append(elements, s)
1✔
608
        }
1✔
609

610
        return elements
1✔
611
}
612

613
// looksLikeFunctionCall checks if a string looks like a function call.
614
//
615
// A function call matches: identifier(...) or namespace.function(...)
616
//
617
// # Parameters
618
//
619
//   - s: The string to check
620
//
621
// # Returns
622
//
623
//   - true if the string looks like a function call
624
//
625
// # Example
626
//
627
//        looksLikeFunctionCall("toUpper('test')")     // true
628
//        looksLikeFunctionCall("apoc.coll.sum([1])")  // true
629
//        looksLikeFunctionCall("'not a function'")    // false
630
//        looksLikeFunctionCall("x + y")               // false
631
func looksLikeFunctionCall(s string) bool {
1✔
632
        s = strings.TrimSpace(s)
1✔
633
        if s == "" {
2✔
634
                return false
1✔
635
        }
1✔
636

637
        // Must end with )
638
        if !strings.HasSuffix(s, ")") {
2✔
639
                return false
1✔
640
        }
1✔
641

642
        // Find the opening parenthesis
643
        parenIdx := strings.Index(s, "(")
1✔
644
        if parenIdx <= 0 {
2✔
645
                return false
1✔
646
        }
1✔
647

648
        // The part before ( must be a valid identifier (possibly with dots for namespacing)
649
        name := s[:parenIdx]
1✔
650

1✔
651
        // Allow dots for namespaced functions like apoc.coll.sum
1✔
652
        for i, c := range name {
2✔
653
                if i == 0 {
2✔
654
                        // First char must be letter or underscore
1✔
655
                        if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_') {
2✔
656
                                return false
1✔
657
                        }
1✔
658
                } else {
1✔
659
                        // Subsequent chars can be alphanumeric, underscore, or dot
1✔
660
                        if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '.') {
2✔
661
                                return false
1✔
662
                        }
1✔
663
                }
664
        }
665

666
        return true
1✔
667
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc